Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 3 min read

Representing a 2048 Board in JavaScript (and Why Flat Arrays Win)

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. Thi

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 Uint8Array or 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:

  1. 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].
  2. 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 (move then 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.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.