DEV Community

SEN LLC
SEN LLC

Posted on

Writing 2048 As a Pure-Function Engine, With All Four Directions Expressed Through One 'Slide Left' Function

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 slideRowLeft function 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

Screenshot

  • 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=en query 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
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

Two things make this clean:

  1. 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] becomes compact = [2, 2, 4], and the left-to-right walk finds the pair immediately.
  2. i += 2 on 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 }
Enter fullscreen mode Exit fullscreen mode

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 }
  );
});
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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); }
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

18 cases on node --test, ~40 ms total. Coverage:

  • createBoard returns a correctly-sized zero board
  • slideRowLeft: normal merge, no-chained-merge, four-in-a-row (two merges), non-adjacent pair
  • move in all four directions, including right-as-mirror-of-left
  • moved = false when the board is unchanged
  • emptyCells and spawnTile only write into empty cells
  • spawnTile only produces 2 or 4
  • canMove / isGameOver on locked and playable full boards
  • hasWon detects the 2048 tile
  • seededRng is 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.

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)