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.
Top comments (0)