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 Uint8Array or 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:

  1. 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].
  2. 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 (move then 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.