Tile-merge games look simple on screen and get messy in code the moment you handle "slide left" four different ways. Most bugs I see in beginner 2048 clones come from the board representation, not from the animation. This post shows a compact model you can reason about, plus the slide routine that falls out of it.
The board is 16 cells, not a vibe
2048 is a 4×4 grid. You can store it as:
// nested const nested = [ [2, 0, 2, 4], [0, 0, 0, 0], [8, 8, 0, 0], [16, 0, 0, 0], ]; // flat (row-major) const flat = [2,0,2,4, 0,0,0,0, 8,8,0,0, 16,0,0,0]; I prefer the flat form for game logic:
- One allocation, easy to clone:
board.slice() - Index math is boring and testable:
i = r * 4 + c - Undo/history is just a stack of
Uint8Arrayor number arrays - The same slide helper works for all four directions if you feed it index lists
Nested arrays are fine for rendering. Keep a toRows(board) helper at the view boundary and leave the engine flat.
Index helpers
const N = 4; const idx = (r, c) => r * N + c; function rows() { return [0, 1, 2, 3].map((r) => [0, 1, 2, 3].map((c) => idx(r, c))); } function cols() { return [0, 1, 2, 3].map((c) => [0, 1, 2, 3].map((r) => idx(r, c))); } rows() left-to-right is "slide left" order. Reverse each row for "slide right". cols() top-to-bottom is "slide up"; reverse for "slide down". One merge function, four line lists.
Merge one line
The only non-trivial piece: take four values, slide non-zeros toward the start, merge equal neighbors once.
function slideLine(line) { // line: number[4], 0 = empty const tiles = line.filter((v) => v !== 0); const out = []; for (let i = 0; i < tiles.length; i++) { if (i + 1 < tiles.length && tiles[i] === tiles[i + 1]) { out.push(tiles[i] * 2); i++; // skip the merged partner } else { out.push(tiles[i]); } } while (out.length < 4) out.push(0); return out; } console.assert( slideLine([2, 2, 2, 2]).join() === "4,4,0,0" ); console.assert( slideLine([2, 0, 2, 4]).join() === "4,4,0,0" ); console.assert( slideLine([4, 4, 8, 8]).join() === "8,16,0,0" ); Two rules people miss:
- A tile merges at most once per move. That is why we
i++after a merge. Without it,[2,2,2,2]becomes[8,0,0,0]instead of[4,4,0,0]. - Zeros are not tiles. Filter them before merging; do not "merge with empty".
Apply a move to the flat board
function applyMove(board, lineIndexes) { const next = board.slice(); let moved = false; for (const line of lineIndexes) { const before = line.map((i) => board[i]); const after = slideLine(before); line.forEach((i, k) => { if (next[i] !== after[k]) moved = true; next[i] = after[k]; }); } return { board: next, moved }; } function moveLeft(board) { return applyMove(board, rows()); } function moveRight(board) { return applyMove( board, rows().map((line) => line.slice().reverse()) ); } Always check moved before spawning a new 2 (or 4). Spawning on a no-op move is a classic softlock bug.
Spawning and game-over
function emptyCells(board) { return board.map((v, i) => (v === 0 ? i : -1)).filter((i) => i >= 0); } function spawn(board, rand = Math.random) { const spots = emptyCells(board); if (!spots.length) return board; const i = spots[Math.floor(rand() * spots.length)]; const next = board.slice(); next[i] = rand() < 0.9 ? 2 : 4; return next; } function canMove(board) { if (emptyCells(board).length) return true; for (let r = 0; r < 4; r++) { for (let c = 0; c < 4; c++) { const v = board[idx(r, c)]; if (c + 1 < 4 && board[idx(r, c + 1)] === v) return true; if (r + 1 < 4 && board[idx(r + 1, c)] === v) return true; } } return false; } Why this model helps when the UI gets fancy
Once the engine is pure functions over a flat array, you can:
- snapshot history for undo
- run property tests (
movethen opposite direction is not identity, but double-left is idempotent) - drive a canvas, DOM, or WebGL view from the same state
I have been poking at variants that keep the same merge rules but change how the board is presented — including the browser game at https://cubes-2048.io, which wraps the familiar 2048 merges in a cube-style view. The rendering changes; the slideLine contract above still holds. That separation is the whole point of a boring board representation.
Minimal smoke test
let b = Array(16).fill(0); b[0] = 2; b[1] = 2; let { board, moved } = moveLeft(b); console.assert(moved && board[0] === 4 && board[1] === 0); board = spawn(board); console.assert(emptyCells(board).length === 14); If those pass, you have a core you can trust before you touch CSS transitions.
Takeaway
Store 2048 as 16 numbers. Build four lists of indexes. Merge with a single slideLine that merges each tile at most once. Spawn only when something actually moved. Fancy skins can come later; a flat array keeps the rules honest.

