2048 looks simple: combine same-valued tiles, hit 2048. Once you start implementing it you discover the slide+merge mechanic has a quietly tricky rule, and the animation is impossible to fake with plain tile replacement. This post walks through a ~300-line vanilla JS implementation: a pure board engine, the no-chain-merge invariant, and CSS-transition animation that doesn't need
requestAnimationFrame.
🌐 Demo: https://sen.ltd/portfolio/2048/
📦 GitHub: https://github.com/sen-ltd/2048
Sliding one row
The core 2048 rule is "compact a row to the left, then merge adjacent equals." Here's the whole thing:
export function slideRow(input) {
// 1. drop zeros (compact)
const filtered = input.filter((v) => v !== 0);
// 2. walk left-to-right, merging adjacent equal pairs
const out = [];
let i = 0;
while (i < filtered.length) {
if (i + 1 < filtered.length && filtered[i] === filtered[i + 1]) {
out.push(filtered[i] * 2); // merge
i += 2; // consume both
} else {
out.push(filtered[i]);
i++;
}
}
// 3. pad right with zeros
while (out.length < 4) out.push(0);
return out;
}
15 lines. Handles both [2, 2, 0, 0] → [4, 0, 0, 0] and [2, 0, 0, 2] → [4, 0, 0, 0] correctly. The key is compacting before scanning — if you scan in place you have to handle holes everywhere.
The "no chain merge" rule
This is the rule that catches every naive implementation.
[2, 2, 2, 0] → ?
A naive "merge as you find pairs" pass gives [4, 2, 0, 0] — correct. But if you let the result keep merging:
[4, 2, 2, 0] → [4, 4, 0, 0] // correct
→ [8, 0, 0, 0] // WRONG, this is the chain-merge bug
A tile that already merged this turn cannot merge again until next turn. That's the actual 2048 rule.
My slideRow() accidentally gets this right because i += 2 skips past the merged pair to the next un-touched tile. But you should pin it down with an explicit test, because it's the kind of rule that's easy to break during a refactor:
test("a tile cannot merge twice in one move", () => {
// [4, 2, 2, 0] → [4, 4, 0, 0], not [8, 0, 0, 0]
assert.deepEqual(slideRow([4, 2, 2, 0]).row, [4, 4, 0, 0]);
});
test("three same tiles merge only the left pair (no chain)", () => {
// [2, 2, 2, 0] → [4, 2, 0, 0], not [8, 0, 0, 0]
assert.deepEqual(slideRow([2, 2, 2, 0]).row, [4, 2, 0, 0]);
});
The reason these rules matter: three- and four-in-a-row are common on a real board. If you chain-merge by mistake, the score runs ahead of "real 2048" by a factor of 2× within a few moves and your players notice that something feels off — but they have no idea what.
Generalising to four directions
If "slide left" is the only direction you implement honestly, the rest are reuse:
- right: reverse the row, slideRow, reverse back
- up: same code as left but operate on a column instead of a row
- down: column, reversed
export function slide(board, direction) {
if (direction === "left" || direction === "right") {
for (let r = 0; r < SIZE; r++) {
let cells = row(board, r);
const reversed = direction === "right";
if (reversed) cells = [...cells].reverse();
const { row: out, gained: g } = slideRow(cells);
const final = reversed ? [...out].reverse() : out;
setRow(next, r, final);
gained += g;
}
} else if (direction === "up" || direction === "down") {
// same shape, columns
}
}
For a 4×4 board the cost of "extract column, run slide, paste back" is irrelevant. Don't optimise that.
RNG as an argument
The classic 2048 spawn rule is "90% chance of a 2, 10% chance of a 4 in a random empty cell." Don't call Math.random directly:
export function spawnTile(board, rng) {
const empties = [];
for (let i = 0; i < board.length; i++) {
if (board[i] === 0) empties.push(i);
}
if (empties.length === 0) return -1;
const idx = empties[Math.floor(rng() * empties.length)];
board[idx] = rng() < 0.9 ? 2 : 4;
return idx;
}
Tests get to inject a deterministic RNG:
function mockRng(values) {
let i = 0;
return () => values[i++ % values.length];
}
test("places a 4 when rng() >= 0.9", () => {
const b = emptyBoard();
spawnTile(b, mockRng([0, 0.95])); // [position, value-roll]
assert.equal(b[0], 4);
});
The production caller just does const rng = Math.random;. One line of dependency injection, infinite gain in test predictability.
Animation with CSS transitions, not requestAnimationFrame
The textbook approach to game animation is "use requestAnimationFrame, interpolate positions per frame." For something the scale of 2048 you can get away with pure CSS transitions on transform/left/top and skip the rAF loop entirely.
.tile {
position: absolute;
width: calc((100% - 3 * var(--gap)) / 4);
height: calc((100% - 3 * var(--gap)) / 4);
left: calc(var(--col) * ((100% - 3 * var(--gap)) / 4 + var(--gap)));
top: calc(var(--row) * ((100% - 3 * var(--gap)) / 4 + var(--gap)));
transition: left 130ms ease, top 130ms ease, transform 130ms ease;
}
Position is driven by CSS variables --row and --col. JS just updates them:
function placeAt(el, cellIdx) {
const r = Math.floor(cellIdx / SIZE);
const c = cellIdx % SIZE;
el.style.setProperty("--row", String(r));
el.style.setProperty("--col", String(c));
}
On a slide, the tile DOM elements are not destroyed — we just change their --row/--col values, and the browser does the frame interpolation. The JS main thread gets to sit idle for the entire 130ms.
For merges the trick is:
- Slide both source tiles to the destination cell (the browser animates this for free).
-
await new Promise(r => setTimeout(r, 130))for the transition to finish. - Remove the two source elements.
- Insert a new tile with the doubled value and a
popanimation.
.tile.pop {
animation: pop 160ms ease;
}
@keyframes pop {
0% { transform: scale(0.85); }
50% { transform: scale(1.12); }
100% { transform: scale(1); }
}
Six lines of CSS gives you the satisfying "two tiles collide and pop into one" feel.
Architecture
board.js ← board logic (no DOM, no audio, no RNG dependency — Node-testable)
render.js ← DOM + animation
app.js ← UI glue (keyboard, touch, score, theme)
board.js has neither document nor window nor Math.random. So Node's built-in test runner verifies all 21 cases without a browser:
npm test # 21 tests, all pass
The dependency arrow goes app.js → render.js → board.js only. When you add a variant (6×6 board, negative-value tiles, chain-merge mode for fun, whatever), you change board.js and ship tests. The animation layer doesn't budge.
Takeaways
- The 2048 slide rule is compact + left-to-right scan + skip past merged pairs — ~15 lines.
- No tile merges twice per turn is the rule that catches every naive implementation; explicitly test the 3-in-a-row and 4-in-a-row cases.
- Generalising to 4 directions = reuse "slide left" with row/column extraction and optional
reverse(). - RNG as an argument makes spawn behavior deterministic in tests at zero cost.
- For board-game animation, CSS transitions + CSS variables for position are smaller and smoother than a JS animation loop.
This is OSS portfolio #241 from SEN LLC (Tokyo). We ship small, sharp tools continuously: https://sen.ltd/portfolio/
























