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.
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
Exercises
- Ship V1 end-to-end. Working puzzle, click to slide, shuffle button, win state. Goal: under 4 hours.
- Add the polish. Move counter, timer, undo. Each is <30 lines.
- Picture mode. Each tile shows a slice of an uploaded image. The classic "puzzle picture" version.
UP NEXT
S15-03 — Procedural Terrain → A walkable landscape, your portfolio screenshot generator.