← All posts

How I Built 2048 in a Weekend — the Whole Game Loop

2026-06-29

I added 2048 to ToolKoala recently, and the nicest surprise was how small the real game is. The animations and polish take time, but the rules — slide, merge, spawn, check for game over — fit in about sixty lines. Here's how the whole loop works.

The board is just a flat array

The grid is 4×4, but I store it as a flat array of 16 numbers, where 0 means empty:

[2, 0, 0, 4,
 0, 2, 0, 0,
 0, 0, 0, 0,
 4, 0, 0, 2]

Flat arrays are easy to copy, compare, and save to localStorage. Row r, column c lives at index r * 4 + c.

One function does the real work

Every move — left, right, up, down — is the same operation on a line of four tiles: push everything toward one end, and merge equal neighbours once. That's a single function:

function compress(line) {
  const arr = line.filter(v => v)   // drop the zeros
  const res = []
  let gained = 0
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === arr[i + 1]) {     // two equal tiles touch → merge
      res.push(arr[i] * 2)
      gained += arr[i] * 2
      i++                            // skip the one we merged in
    } else {
      res.push(arr[i])
    }
  }
  while (res.length < 4) res.push(0) // pad back to length 4
  return { line: res, gained }
}

The i++ after a merge is the rule that stops 4 4 4 4 from collapsing into a single 16. It becomes 8 8 — each tile merges at most once per move.

Four directions from one direction

I only wrote "slide left." The other three reuse it:

  • Right — reverse the row, compress left, reverse back.
  • Up / Down — read the board by columns instead of rows, then the same left/right logic.

So a move grabs each row or column, optionally reverses it, runs compress, and writes it back. No separate code for each arrow — just which line I read and whether I flip it.

Spawn, then check if you're stuck

After a move that actually changed the board, a new tile appears in a random empty cell — a 2 most of the time, a 4 occasionally. Then I check whether any move is still possible: are there empty cells, or any two equal neighbours? If not, it's game over.

function canMove(g) {
  if (g.includes(0)) return true            // empty cell → yes
  // any equal horizontal/vertical neighbour → a merge is still possible
  // ...checked with a small loop...
  return false
}

The "did the board actually change?" check matters: if a move does nothing, you shouldn't get a free new tile. Pressing left against a wall has to be a no-op.

The bug that taught me to use refs

The first version read the board straight from React state inside the key handler. On fast key-repeat, two arrow presses could fire before React re-rendered, so the second one read a stale board and moves got dropped. The fix was to keep a ref mirroring the latest board and score, and have the move handler read the ref. State drives what you see; the ref drives the logic. After that, even mashing the arrows behaves.

Resume and best score, all local

Two things live in localStorage: your best score, and the current board. Save the board on every move; clear it when the game ends. On load, if there's a valid unfinished game, I restore it and show a small "continue" note — the same pattern I use for Sudoku and Word Search. Nothing is uploaded; your game is yours, on your device.

Try it

You can play 2048 here — keyboard or swipe, and it works offline once the page has loaded. If you like this kind of thing, the Sudoku generator has a more interesting algorithm behind it.

FAQ

Is 2048 hard to program? The core is surprisingly small — one "slide and merge a line" function, reused for all four directions, plus spawning a tile and checking for game over. The polish (animations, touch controls, saving progress) is where most of the time goes.

How does the merge rule work? When two tiles with the same number are pushed together, they combine into one tile with double the value — and each tile can only merge once per move, so a row of four 2s becomes two 4s, not one 8.

How are the four directions handled? You only need to implement one direction. Right is left with the row reversed; up and down are the same logic applied to columns instead of rows.

Does ToolKoala's 2048 save my game? Yes — your best score and current board are stored in your browser's local storage, so you can close the tab and pick up later. It never leaves your device.

— Milo 🐨