DEV Community

Cover image for Building 2048 in React — Merge Logic, Direction Rotation, and CSS Slide Animations
Shaishav Patel
Shaishav Patel

Posted on

Building 2048 in React — Merge Logic, Direction Rotation, and CSS Slide Animations

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');
Enter fullscreen mode Exit fullscreen mode

The Merge Algorithm

Every move processes a single row (or column, reduced to a row) in four steps:

  1. Compress — remove zeroes, slide non-zero values together
  2. Merge — adjacent equal values combine; mark merged cells to prevent chain merges
  3. Compress again — collapse gaps left by merging
  4. 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 };
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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.

Play 2048 game live

Top comments (0)