Writing 2048 As a Pure-Function Engine, With All Four Directions Expressed Through One 'Slide Left' Function
The prettiest moment in writing 2048 isn't finishing it โ it's realizing you can implement the whole thing with a single
slideRowLeftfunction and map the other three directions to it through rotations and reflections. Here's a pure-function engine plus a vanilla-JS UI, zero dependencies, 18 passing tests.
Everyone's played 2048, but implementing it is where the puzzle gets interesting. Rules fit in five lines; implementing them without duplicating code in four near-identical functions is the actual design exercise.
This is entry #32 in my SEN portfolio series: a rewrite of 2048 built around a pure-function engine, with swipe controls, three themes, persistent best score, and bilingual UI.
๐ Live demo: https://sen.ltd/portfolio/twenty-forty-eight/
๐ฆ GitHub: https://github.com/sen-ltd/twenty-forty-eight
- Arrow keys / WASD / hjkl / mobile swipe
- Three themes (Classic, Dark, Pastel) driven by CSS variables
- Best score persisted in
localStorage; theme and language too - Bilingual UI (
?lang=enquery param or selector) - Keep-going after hitting 2048
- Vanilla JS, zero deps, no build step
One slide function, four directions
The naive 2048 implementation has four nearly-identical functions: moveLeft, moveRight, moveUp, moveDown. The merge rule (adjacent equal tiles merge once) is shared across all four, yet the code for it ends up duplicated in four places. Fix a merge-bug in one, forget another, wonder why up-moves score wrong.
The classic fix: implement only the left slide, then reduce every other direction to it via rotations and reflections:
right = reverse-rows ยป left ยป reverse-rows
up = transpose ยป left ยป transpose
down = transpose ยป reverse-rows ยป left ยป reverse-rows ยป transpose
In plain English: reverse each row and "right" looks like "left"; transpose the board and "up" looks like "left." That's it. Code:
export function move(board, dir) {
let work;
switch (dir) {
case 'left': work = cloneBoard(board); break;
case 'right': work = reverseRows(board); break;
case 'up': work = transpose(board); break;
case 'down': work = reverseRows(transpose(board)); break;
}
let gained = 0;
const slid = work.map((row) => {
const r = slideRowLeft(row);
gained += r.gained;
return r.row;
});
let next;
switch (dir) {
case 'left': next = slid; break;
case 'right': next = reverseRows(slid); break;
case 'up': next = transpose(slid); break;
case 'down': next = transpose(reverseRows(slid)); break;
}
const moved = !boardsEqual(board, next);
return { board: next, gained, moved };
}
The merge logic lives in exactly one place. If slideRowLeft is correct, all four directions are automatically correct too. Fix a bug once, not four times.
The no-chained-merge rule, for free
2048 has a subtle rule that trips people up:
[4, 4, 8, 0] โ [8, 8, 0, 0] โ
[4, 4, 8, 0] โ [16, 0, 0, 0] โ (the new 8 cannot immediately merge with the next 8)
[2, 2, 2, 2] โ [4, 4, 0, 0] โ
[2, 2, 2, 2] โ [8, 0, 0, 0] โ (same โ the new 4 cannot immediately merge)
You could track "already merged" flags on each cell. Or you can just bump the loop counter by two when you merge, so the cells you just combined become untouchable for the rest of this pass:
export function slideRowLeft(row) {
const compact = row.filter((v) => v !== 0);
const out = [];
let gained = 0;
let i = 0;
while (i < compact.length) {
if (i + 1 < compact.length && compact[i] === compact[i + 1]) {
const merged = compact[i] * 2;
out.push(merged);
gained += merged;
i += 2; // skip past both merged cells
} else {
out.push(compact[i]);
i += 1;
}
}
while (out.length < row.length) out.push(0);
return { row: out, gained };
}
Two things make this clean:
-
Compact first, then merge. Stripping zeros with
row.filter((v) => v !== 0)up front removes the whole "should we merge across a gap?" question.[2, 0, 2, 4]becomescompact = [2, 2, 4], and the left-to-right walk finds the pair immediately. -
i += 2on merge makes chained merges structurally impossible. No flags, no extra state.
Purity means the tests don't need mocks
Every function in the engine is side-effect-free:
move(board, dir) โ { board: nextBoard, gained, moved }
spawnTile(board, rng) โ nextBoard
canMove(board) โ boolean
isGameOver(board) โ boolean
hasWon(board) โ boolean
slideRowLeft(row) โ { row, gained }
Which means the tests are deepStrictEqual calls that line up inputs next to expected outputs:
test('slideRowLeft: 4 equals yields two merges', () => {
assert.deepStrictEqual(
slideRowLeft([2, 2, 2, 2]),
{ row: [4, 4, 0, 0], gained: 8 }
);
});
test('slideRowLeft: no chained merge on one slide', () => {
assert.deepStrictEqual(
slideRowLeft([4, 4, 8, 0]),
{ row: [8, 8, 0, 0], gained: 8 }
);
});
The one function that's not pure is spawnTile, which has to roll a die for the new tile's position and value. The trick there is to inject the RNG so tests can hand in a deterministic one:
export function spawnTile(board, rng = Math.random) {
const cells = emptyCells(board);
if (cells.length === 0) return cloneBoard(board);
const [x, y] = cells[Math.floor(rng() * cells.length)];
const value = rng() < 0.9 ? 2 : 4;
const next = cloneBoard(board);
next[y][x] = value;
return next;
}
// Mulberry32: 20-line deterministic PRNG.
export function seededRng(seed) {
let t = seed >>> 0;
return function () {
t = (t + 0x6d2b79f5) >>> 0;
let r = t;
r = Math.imul(r ^ (r >>> 15), r | 1);
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
spawnTile(board, seededRng(42)) produces the same result every time. Test it like any other pure function โ no jest.mock, no spies.
Swipe with just two touch points
Phone controls sound like they need a gesture library, but 2048 only cares about the start and end points of a single finger drag. That's two event listeners:
wrap.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return;
tracking = true;
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}, { passive: true });
wrap.addEventListener('touchend', (e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
const THRESHOLD = 24;
if (Math.max(Math.abs(dx), Math.abs(dy)) < THRESHOLD) return;
if (Math.abs(dx) > Math.abs(dy)) applyMove(dx > 0 ? 'right' : 'left');
else applyMove(dy > 0 ? 'down' : 'up');
});
The THRESHOLD = 24 filters out jittery taps that the user didn't mean as a swipe. One extra listener on touchmove with { passive: false } calls preventDefault() to stop the page from scrolling while you drag on the board. That's the whole thing.
Themes through CSS variables on the body
Three themes (Classic / Dark / Pastel) came out cleanest as CSS custom properties grouped under a .theme-<id> selector on body:
.theme-classic {
--bg: #faf8ef;
--panel: #bbada0;
--t-2-bg: #eee4da;
--t-2048-bg: #edc22e;
/* ... 12 tile shades ... */
}
.theme-dark {
--bg: #0f1115;
--panel: #1e222b;
--t-2-bg: #2a3042;
--t-2048-bg: #f7b955;
/* ... */
}
.tile-2 { background: var(--t-2-bg); }
.tile-2048 { background: var(--t-2048-bg); }
JavaScript sets document.body.className = 'theme-dark' and everything cascades. The tile CSS doesn't know any theme names โ it just references var(--t-2-bg) and inherits whichever palette <body> is in. Switching themes is a single DOM write.
Theme choice, language, and best score all persist via namespaced localStorage keys:
const BEST_KEY = 'twenty-forty-eight:best';
const LANG_KEY = 'twenty-forty-eight:lang';
const THEME_KEY = 'twenty-forty-eight:theme';
Namespacing matters here because this portfolio series is going to include Vue and Svelte reimplementations of the same app, and I don't want them overwriting each other's storage.
Tests
npm test
18 cases on node --test, ~40 ms total. Coverage:
-
createBoardreturns a correctly-sized zero board -
slideRowLeft: normal merge, no-chained-merge, four-in-a-row (two merges), non-adjacent pair -
movein all four directions, including right-as-mirror-of-left -
moved = falsewhen the board is unchanged -
emptyCellsandspawnTileonly write into empty cells -
spawnTileonly produces 2 or 4 -
canMove/isGameOveron locked and playable full boards -
hasWondetects the 2048 tile -
seededRngis deterministic
Because nothing in the engine touches the DOM or a timer, there's nothing to mock. You just compare return values.
Series
This is entry #32 in my 100+ public portfolio series.
- ๐ฆ Repo: https://github.com/sen-ltd/twenty-forty-eight
- ๐ Live: https://sen.ltd/portfolio/twenty-forty-eight/
- ๐ข Company: https://sen.ltd/
The "implement one direction, rotate for the rest" trick isn't specific to 2048 โ it generalizes to any grid puzzle where the rules are rotationally symmetric. Minesweeper neighbor scans, cellular-automaton mirrored patterns, Sokoban move validation: same move. 2048 just happens to be the easiest place to see why it's worth the effort.

Top comments (0)