2048 is one of the most technically satisfying browser games to build. The rules are simple, but the merge logic has a subtle correctness requirement that trips up most first implementations.
Here's how we built the 2048 game on Ultimate Tools — the merge algorithm, direction handling, CSS animations, and localStorage persistence.
Grid State
The board is a flat array of 16 numbers — index 0 to 15, row-major. Zero means empty.
const [grid, setGrid] = useState<number[]>(Array(16).fill(0));
const [score, setScore] = useState(0);
const [best, setBest] = useState(() => Number(localStorage.getItem('2048-best') || 0));
type Status = 'idle' | 'playing' | 'won' | 'over';
const [status, setStatus] = useState<Status>('idle');
The Merge Algorithm
Every move processes a single row (or column, reduced to a row) in four steps:
- Compress — remove zeroes, slide non-zero values together
- Merge — adjacent equal values combine; mark merged cells to prevent chain merges
- Compress again — collapse gaps left by merging
- Spawn — add a new tile (90% → 2, 10% → 4) to a random empty cell
The critical detail is step 2: a tile that was just merged cannot merge again in the same move. Without the mergedFlag, two 2s becoming a 4 could then immediately merge with the next 4 — not the correct 2048 behavior.
function compressRow(row: number[]): number[] {
return [...row.filter(n => n !== 0), ...Array(4).fill(0)].slice(0, 4);
}
function mergeRow(row: number[]): { result: number[]; score: number } {
const merged = [...row];
const mergedFlag = [false, false, false, false];
let score = 0;
for (let i = 0; i < 3; i++) {
if (merged[i] !== 0 && merged[i] === merged[i + 1] && !mergedFlag[i]) {
merged[i] *= 2;
score += merged[i];
merged[i + 1] = 0;
mergedFlag[i] = true;
}
}
return { result: merged, score };
}
function processRow(row: number[]): { result: number[]; score: number } {
const compressed = compressRow(row);
const { result: merged, score } = mergeRow(compressed);
return { result: compressRow(merged), score };
}
Direction Handling via Grid Rotation
Rather than writing separate logic for left, right, up, and down, we transform the grid so that every direction reduces to a left-merge.
// Extract rows: left direction = identity
function getRows(g: number[]): number[][] {
return [0, 1, 2, 3].map(r => g.slice(r * 4, r * 4 + 4));
}
// Transpose: rows become columns and vice versa
function transpose(g: number[]): number[] {
return [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15].map(
i => g[(i % 4) * 4 + Math.floor(i / 4)]
);
}
function move(grid: number[], dir: 'left'|'right'|'up'|'down'): { grid: number[]; score: number } {
let g = [...grid];
let totalScore = 0;
// Transform to "left" perspective
if (dir === 'right') g = g.map((_, i) => g[Math.floor(i/4)*4 + (3 - i%4)]);
if (dir === 'up') g = transpose(g);
if (dir === 'down') g = transpose(g).map((_, i) => g[Math.floor(i/4)*4 + (3 - i%4)]);
// Process all rows as left merges
const rows = getRows(g);
const processed = rows.map(r => processRow(r));
g = processed.flatMap(r => r.result);
totalScore = processed.reduce((sum, r) => sum + r.score, 0);
// Reverse the transformation
if (dir === 'right') g = g.map((_, i) => g[Math.floor(i/4)*4 + (3 - i%4)]);
if (dir === 'up') g = transpose(g);
if (dir === 'down') { g = g.map((_, i) => g[Math.floor(i/4)*4 + (3 - i%4)]); g = transpose(g); }
return { grid: g, score: totalScore };
}
Tile Spawning
After every successful move, a new tile is added to a random empty cell:
function spawnTile(g: number[]): number[] {
const empty = g.map((v, i) => v === 0 ? i : -1).filter(i => i !== -1);
if (empty.length === 0) return g;
const idx = empty[Math.floor(Math.random() * empty.length)];
const next = [...g];
next[idx] = Math.random() < 0.9 ? 2 : 4;
return next;
}
A move is only applied if it actually changed the grid — comparing JSON.stringify(before) vs JSON.stringify(after) before spawning prevents the game from consuming a turn when no tiles moved.
CSS Slide Animations
Each tile gets a CSS transition on transform so merges animate smoothly. React handles positioning via absolute CSS (top/left based on grid index), and transitions take care of the slide.
Newly spawned tiles get a scale-in class for one render cycle:
@keyframes tile-spawn {
from { transform: scale(0.5); opacity: 0.5; }
to { transform: scale(1); opacity: 1; }
}
.tile-new {
animation: tile-spawn 120ms ease-out;
}
Game Over and Win Detection
Win: any tile in the grid equals 2048.
Game Over: no empty cells AND no two adjacent tiles share the same value (horizontal or vertical).
function hasMovesLeft(g: number[]): boolean {
if (g.includes(0)) return true;
for (let i = 0; i < 16; i++) {
if (i % 4 < 3 && g[i] === g[i + 1]) return true; // horizontal
if (i < 12 && g[i] === g[i + 4]) return true; // vertical
}
return false;
}
localStorage Persistence
Best score persists across sessions. Update after every move that improves it:
if (newScore > best) {
setBest(newScore);
localStorage.setItem('2048-best', String(newScore));
}
Keyboard and Touch Input
Arrow key events on window (cleaned up on unmount). Swipe detection via touchstart/touchend delta — threshold of 30px to distinguish a swipe from a tap.
Result
The full component runs in about 300 lines with no external game library. Grid state as a flat array, direction handling via rotation transforms, and CSS for animations.
Top comments (0)