← Back to posts

≈ 3 min

Building a Tiny 2048 Clone with Vanilla JavaScript

I wanted a lightweight version of 2048 that runs from a single index.html with zero build tools. The result is a compact, modular implementation in plain HTML/CSS/JS — easy to read, easy to tweak, and fun to extend.

👉 Live demo: https://seryogakovalyov.github.io/2048-js-classic/
👉 GitHub: https://github.com/seryogakovalyov/2048-js-classic


📦 Project structure

  • index.html contains the HUD (score/best, restart) and the board container.
  • js/main.js is the entry point: initialize state → render → bind input.
  • Core mechanics live in js/core/*, rendering helpers in js/components/*, and matrix utilities in js/utils/*.

Simple, modular, dependency-free.


🔢 Representing the board

The game state is a 4×4 matrix plus meta fields:

export const state = {
  grid: [],
  score: 0,
  best: 0,
  status: "playing",
  animations: [],
  lastAdded: null
};

A new game resets the grid and places two random tiles.


↔️ Movement and merging

The core logic implements “move left.”
Other directions are rotations/transposes of the same process.

Here’s the main pure function:

export function moveRowLeft(row) {
    let nonZero = row
        .map((value, index) => ({ value, index }))
        .filter(({ value }) => value !== 0);

    let result = [];
    let scoreGain = 0;
    let mergedTo2048 = false;
    let moves = [];

    for (let i = 0; i < nonZero.length; i++) {
        const current = nonZero[i];
        const next = nonZero[i + 1];

        if (next && current.value === next.value) {
            const merged = current.value * 2;
            const toCol = result.length;
            const fromCol = next.index;

            result.push(merged);
            scoreGain += merged;
            if (merged === 2048) mergedTo2048 = true;

            moves.push({
                from: fromCol,
                to: toCol,
                value: merged,
                merged: true
            });
            i++;
        } else {
            result.push(current.value);
            moves.push({
                from: current.index,
                to: result.length - 1,
                value: current.value,
                merged: false
            });
        }
    }

    while (result.length < 4) result.push(0);

    return { row: result, scoreGain, mergedTo2048, moves };
}

At the grid level, this runs on each row, applies rotations for up/down/right, and detects win/lose states.


🎨 Rendering & animations

  • Background consists of 16 static cells.
  • Actual tiles render in a separate .tiles layer with absolute positioning.
  • Each tile gets CSS variables --x and --y for its start position, then requestAnimationFrame updates them to trigger smooth transitions.
  • Newly spawned and merged tiles use new/merge classes for pop animations.

Smooth motion with minimal JS.


🎮 Input: keyboard + touch

initInput handles:

  • Arrow keys and WASD
  • Swipes (simple dx/dy measurement from touchstarttouchend)

All inputs call move(direction) followed by render().


💾 Persistence

Every state mutation is saved in localStorage:

  • grid
  • score
  • best
  • status

Reloading the page restores the session automatically.


💅 Styling

css/styles.css defines:

  • the grid using CSS Grid,
  • tile colors per value,
  • absolute-positioned animated tiles,
  • win/lose overlays.

🚀 Running the project

Open index.html directly, or run a tiny local server:

npx serve .
# or
python -m http.server 8000

Visit http://localhost:8000.


🔧 Possible upgrades

  • Undo or move history
  • Reset best score button
  • Tests for moveRowLeft and rotation helpers
  • Additional themes or larger board sizes

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *