Three.js From Zero · Article s15-02

Sliding Puzzle in 3D

Sliding Puzzle in 3D is Article s15-02 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS15-02 · Portfolio & Career

Season 15 · Article 02 · Portfolio & Career

The 4×4 sliding-tile puzzle, in 3D. Picking, animation, win detection, shuffle, victory state. A ~300-line portfolio piece that shows you can ship a complete interactive experience, not just a flashy demo.

Why this works for a portfolio

A sliding puzzle is small enough to finish in a weekend, complex enough to demonstrate real skills:

  • Raycasting — clicking a tile
  • State management — board configuration, history
  • Animation — smooth slide between cells
  • Win condition — detect solved state
  • Polish — particles on win, undo button, shuffle

Every interview question about "build a game in WebGL" is some version of this.

The board representation

// 4×4 board, values 0-15 where 0 = empty slot
let board = [
  1, 2, 3, 4,
  5, 6, 7, 8,
  9, 10, 11, 12,
  13, 14, 15, 0,    // solved state
];

function indexToXY(i) { return [i % 4, Math.floor(i / 4)]; }
function xyToIndex(x, y) { return y * 4 + x; }

Building the tiles

const tiles = [];
for (let i = 0; i < 16; i++) {
  if (board[i] === 0) { tiles.push(null); continue; }
  const tile = new THREE.Mesh(
    new THREE.BoxGeometry(0.95, 0.95, 0.2),
    new THREE.MeshStandardMaterial({ color: tileColor(board[i]) }),
  );
  const [x, y] = indexToXY(i);
  tile.position.set(x - 1.5, -(y - 1.5), 0);
  tile.userData.value = board[i];
  scene.add(tile);
  tiles.push(tile);
}

userData.value stores the number on each tile. Used for picking + win detection.

Clicking a tile

const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();

canvas.addEventListener('click', (e) => {
  const rect = canvas.getBoundingClientRect();
  ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
  ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
  raycaster.setFromCamera(ndc, camera);

  const hit = raycaster.intersectObjects(tiles.filter(Boolean), false)[0];
  if (!hit) return;

  const idx = tiles.indexOf(hit.object);
  attemptMove(idx);
});

The move rule

function attemptMove(idx) {
  const emptyIdx = board.indexOf(0);
  const [ex, ey] = indexToXY(emptyIdx);
  const [tx, ty] = indexToXY(idx);
  // Adjacent (4-connectivity)?
  if (Math.abs(ex - tx) + Math.abs(ey - ty) !== 1) return;
  swap(idx, emptyIdx);
  animateSlide(idx, emptyIdx);
  if (isSolved()) celebrate();
}

function swap(a, b) {
  [board[a], board[b]] = [board[b], board[a]];
  [tiles[a], tiles[b]] = [tiles[b], tiles[a]];
}

Animating the slide

function animateSlide(from, to) {
  const tile = tiles[to];       // tile that's now in the destination
  const [tx, ty] = indexToXY(to);
  const target = new THREE.Vector3(tx - 1.5, -(ty - 1.5), 0);
  const start = tile.position.clone();
  const duration = 180;          // ms
  const t0 = performance.now();
  function tick() {
    const t = Math.min(1, (performance.now() - t0) / duration);
    const eased = 1 - Math.pow(1 - t, 3);   // ease-out cubic
    tile.position.lerpVectors(start, target, eased);
    if (t < 1) requestAnimationFrame(tick);
  }
  tick();
}

Animations are short — 180ms feels snappy. Cubic ease-out feels organic. Linear feels robotic.

Shuffle (without making it unsolvable)

function shuffle() {
  // 100 random valid moves from the solved state
  for (let i = 0; i < 100; i++) {
    const emptyIdx = board.indexOf(0);
    const [ex, ey] = indexToXY(emptyIdx);
    const neighbors = [[1,0],[-1,0],[0,1],[0,-1]]
      .map(([dx, dy]) => xyToIndex(ex + dx, ey + dy))
      .filter(i => i >= 0 && i < 16);
    const pick = neighbors[Math.floor(Math.random() * neighbors.length)];
    swap(pick, emptyIdx);
  }
  // Snap tiles to new positions (no animation)
  tiles.forEach((tile, i) => {
    if (!tile) return;
    const [x, y] = indexToXY(i);
    tile.position.set(x - 1.5, -(y - 1.5), 0);
  });
}

The "100 random moves from solved" trick guarantees solvability. Randomly shuffling values directly gives you a 50% chance of an unsolvable position (parity invariant).

Win detection

function isSolved() {
  for (let i = 0; i < 15; i++) {
    if (board[i] !== i + 1) return false;
  }
  return board[15] === 0;
}

function celebrate() {
  // S0-07 fireworks, S0-06 hologram glow, your pick.
  // Reuse code you've already written.
}

Polish that elevates it

  • Move counter + timer in the HUD.
  • "Undo" button — keep a move history stack.
  • Tile number rendered in 3D (use Text3D or a CanvasTexture).
  • Mobile-friendly — touch events, fit-to-screen layout.
  • Win celebration — particles or scene rotation that releases tension.

Common first-time pitfalls

"All tiles look identical." Add the number. Either Text3D (heavier) or a CanvasTexture per tile with the number painted on. CanvasTexture is faster.
"Shuffling sometimes produces unsolvable boards." Don't randomize positions directly — that breaks the parity invariant. Random moves from solved state preserve solvability.
"Click sometimes misses the tile." Raycaster matches the tile's geometry. With small gaps between tiles, near-edge clicks miss. Either pad each tile's collision (a slightly bigger invisible mesh) or widen the gap visually.

Exercises

  1. Ship V1 end-to-end. Working puzzle, click to slide, shuffle button, win state. Goal: under 4 hours.
  2. Add the polish. Move counter, timer, undo. Each is <30 lines.
  3. Picture mode. Each tile shows a slice of an uploaded image. The classic "puzzle picture" version.