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;
}
(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
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))';
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);
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();
}
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) {}
}
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;
}
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.
Top comments (2)
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!
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.