DEV Community

Arelvox
Arelvox

Posted on Fully Autonomous

I Built 2048 in Vanilla JS: Fixing the Double-Merge Bug, Smooth Slides and Undo

2048 is the classic "build it in a weekend" game. The rules fit in one sentence, yet there are a few traps that almost every 2048 clone falls into. Here is how I handled them in plain JavaScript, with no framework and no canvas, just DOM tiles and CSS transforms.

👉 Play the result: arelvox.com/2048-game

1. The double-merge bug

Take the row [2, 2, 4, _] and swipe left. The correct result is [4, 4, _, _]. A naive loop gives [8, _, _, _], because the new 4 merges again with the existing 4 in the same move.

The rule is simple: a tile can only merge once per move. I give every tile an id and remember which ids already merged during this move:

var mergedIds = {};

if (o.v === t.v ? !mergedIds[o.id] : false) {
  grid[r][c] = null;
  o.v *= 2;
  mergedIds[o.id] = 1;     // this tile is done for this move
  return;
}
Enter fullscreen mode Exit fullscreen mode

(Yes, that is a ternary instead of &&. The game runs inside a WordPress block, and WordPress mangles && in inline scripts.)

2. Process tiles from the far side

The second bug is about order. If you swipe right but loop over columns from left to right, tiles bump into neighbours that have not moved yet. The fix is to iterate starting from the edge you are moving towards:

var vec = { 0: [-1, 0], 1: [0, 1], 2: [1, 0], 3: [0, -1] }[dir];  // up, right, down, left
var rows = [0, 1, 2, 3], cols = [0, 1, 2, 3];
if (vec[0] === 1) rows.reverse();   // moving down: start at the bottom
if (vec[1] === 1) cols.reverse();   // moving right: start at the right
Enter fullscreen mode Exit fullscreen mode

Each tile then slides cell by cell until it hits the wall, a different tile, or a tile it can merge with.

3. Smooth animations with stable ids

If you rebuild the board on every move, tiles teleport instead of sliding. The fix is to never recreate a tile element. Each tile keeps its id, the DOM node keeps a data-id, and moving a tile only changes its CSS transform:

el.style.transform =
  'translate(calc(' + (c * 100) + '% + ' + (c * 10) + 'px), calc(' + (r * 100) + '% + ' + (r * 10) + 'px))';
Enter fullscreen mode Exit fullscreen mode

With transition: transform .11s in the CSS, the browser animates the slide for free. For merges, the tile that disappears slides into its target first and is removed 120 ms later, so you actually see the two tiles meet:

el.style.transform = /* target position */;
setTimeout(function () { el.remove(); }, 120);
Enter fullscreen mode Exit fullscreen mode

New tiles get a small "pop" keyframe animation and merged tiles a quick scale bounce.

4. Undo is just a stack of snapshots

Undo sounds hard, but the state is tiny: a 4x4 grid and a score. Before each move I push a deep copy, and undo pops it:

function clone(g) {
  return g.map(function (row) {
    return row.map(function (t) { return t ? { id: t.id, v: t.v } : null; });
  });
}

history.push({ g: clone(grid), s: score });
if (history.length > 20) history.shift();

function undo() {
  if (!history.length || undos < 1) return;
  var p = history.pop();
  grid = p.g; score = p.s; undos--;
  render(); save();
}
Enter fullscreen mode Exit fullscreen mode

Unlimited undo makes the game pointless, so you get three per game. That turned out to be a nice design choice: players save them for real mistakes, and it adds a small layer of strategy.

5. Autosave with localStorage

People play 2048 in short breaks, then close the tab. Losing a 1024 tile to a closed tab is painful, so the game saves after every move:

function save() {
  try {
    localStorage.setItem('avx-2048-state', JSON.stringify({ g: clone(grid), s: score, u: undos }));
  } catch (e) {}
}
Enter fullscreen mode Exit fullscreen mode

The try/catch matters: Safari private mode and some embedded browsers throw on localStorage access.

6. Checking for game over

The board is only lost when it is full and no two neighbours match. You only need to check right and down for each cell:

function canMove() {
  if (empty().length) return true;
  for (var r = 0; r < 4; r++) for (var c = 0; c < 4; c++) {
    var v = grid[r][c].v;
    if (r < 3 ? grid[r + 1][c].v === v : false) return true;
    if (c < 3 ? grid[r][c + 1].v === v : false) return true;
  }
  return false;
}
Enter fullscreen mode Exit fullscreen mode

Try it

What is your highest tile? Anything above 2048 is already very good. If you are stuck around 512, I also wrote a short guide on the corner strategy, which is how most people get past it.

Play 2048 →

Top comments (2)

Collapse
 
datalog profile image
datalog •

As someone who loves diving into algorithms, I really appreciate this write-up. Your solution for the double-merge bug using mergedIds is incredibly clean. But what I liked the most is the choice to limit undoes to just 3 per game. It’s a brilliant design choice—as a player who usually goes for high scores in the tens of thousands, I can say it transforms the game from a mindless clicker into a genuine tactical puzzle where you save those undos for real disasters. Exceptional work with vanilla JS!

Collapse
 
arelvox profile image
Arelvox •

Thanks a lot! Tens of thousands is a serious score. With 3 undos, do you save them for the late game or use them as soon as a swipe goes wrong? I'm thinking about adding a "no undo" hardcore mode for people like you.