Development Featured Article

Building 2048 Game with JavaScript (Part 1): Basic Architecture

Learn how to build a complete 2048 game from scratch using JavaScript, HTML, and CSS. This tutorial covers project structure, HTML templates, CSS styling, and JavaScript architecture design.

2048 Cupcakes Team
15 min read
#JavaScript #Game Development #Tutorial #2048 #Frontend

Building 2048 Game with JavaScript (Part 1): Basic Architecture

2048 is a classic puzzle game whose implementation involves the comprehensive use of HTML, CSS, and JavaScript. This tutorial will guide you through building a complete 2048 game from scratch.

Project Overview

Tech Stack

  • HTML5: Game structure and semantic tags
  • CSS3: Game styling and animations
  • JavaScript (ES6+): Game logic and interaction
  • LocalStorage: Game progress saving

Project Structure

2048-game/
├── index.html          # Main HTML file
├── css/
│   └── style.css       # Game styles
├── js/
│   ├── game.js         # Core game logic
│   ├── grid.js         # Grid management
│   ├── tile.js         # Tile class
│   ├── input.js        # Input handling
│   └── storage.js      # Local storage
└── assets/
    └── images/         # Game images

HTML Structure

Basic Template

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>2048 Game</title>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <div class="game-container">
        <header class="game-header">
            <h1 class="game-title">2048</h1>
            <div class="scores-container">
                <div class="score-box">
                    <span class="score-label">Score</span>
                    <span class="score-value" id="score">0</span>
                </div>
                <div class="score-box">
                    <span class="score-label">Best</span>
                    <span class="score-value" id="best-score">0</span>
                </div>
            </div>
        </header>

        <div class="game-controls">
            <button class="new-game-btn">New Game</button>
        </div>

        <div class="game-message" id="game-message"></div>

        <div class="grid-container">
            <div class="grid-background">
                <!-- 16 grid cell backgrounds -->
            </div>
            <div class="tile-container" id="tile-container">
                <!-- Dynamically generated tiles -->
            </div>
        </div>

        <footer class="game-footer">
            <p>Use arrow keys or swipe to move tiles</p>
        </footer>
    </div>

    <script src="js/storage.js"></script>
    <script src="js/tile.js"></script>
    <script src="js/grid.js"></script>
    <script src="js/input.js"></script>
    <script src="js/game.js"></script>
</body>
</html>

Semantic Markup

  • <header>: Game title and score board
  • <div class="game-message">: Game messages (win/lose)
  • <div class="grid-container">: Game board container
  • <footer>: Game instructions

CSS Styling

CSS Variables

:root {
    /* Color system */
    --grid-size: 500px;
    --grid-gap: 15px;
    --tile-size: calc((var(--grid-size) - var(--grid-gap) * 5) / 4);

    /* Tile colors */
    --tile-1-bg: #eee4da;
    --tile-2-bg: #ede0c8;
    --tile-4-bg: #f2b179;
    --tile-8-bg: #f59563;
    --tile-16-bg: #f67c5f;
    --tile-32-bg: #f65e3b;
    --tile-64-bg: #edcf72;
    --tile-128-bg: #edcc61;
    --tile-256-bg: #edc850;
    --tile-512-bg: #edc53f;
    --tile-1024-bg: #edc22e;
    --tile-2048-bg: #3c3a32;

    /* Text colors */
    --text-dark: #776e65;
    --text-light: #f9f6f2;
}

Grid Styling

.grid-container {
    position: relative;
    width: var(--grid-size);
    height: var(--grid-size);
    background: #bbada0;
    border-radius: 6px;
    padding: var(--grid-gap);
    box-sizing: border-box;
}

.grid-background {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    grid-template-rows: repeat(4, 1fr);
    gap: var(--grid-gap);
    width: 100%;
    height: 100%;
}

.grid-background .grid-cell {
    background: rgba(238, 228, 218, 0.35);
    border-radius: 3px;
}

.tile-container {
    position: absolute;
    top: var(--grid-gap);
    left: var(--grid-gap);
    width: calc(100% - var(--grid-gap) * 2);
    height: calc(100% - var(--grid-gap) * 2);
    pointer-events: none;
}

Tile Styling

.tile {
    position: absolute;
    width: var(--tile-size);
    height: var(--tile-size);
    border-radius: 3px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
    font-weight: bold;
    transition: transform 100ms ease-in-out;
    z-index: 10;
}

/* Tile positions */
.tile.position-0-0 { transform: translate(0, 0); }
.tile.position-1-0 { transform: translate(calc(var(--tile-size) + var(--grid-gap)), 0); }
.tile.position-2-0 { transform: translate(calc((var(--tile-size) + var(--grid-gap)) * 2), 0); }
.tile.position-3-0 { transform: translate(calc((var(--tile-size) + var(--grid-gap)) * 3), 0); }
/* ... other position classes */

/* Tile colors */
.tile.tile-1 { background: var(--tile-1-bg); color: var(--text-dark); }
.tile.tile-2 { background: var(--tile-2-bg); color: var(--text-dark); }
.tile.tile-4 { background: var(--tile-4-bg); color: var(--text-light); }
/* ... other color classes */

/* New tile animation */
.tile.tile-new {
    animation: appear 200ms ease;
}

@keyframes appear {
    0% { opacity: 0; transform: scale(0); }
    100% { opacity: 1; transform: scale(1); }
}

/* Merge animation */
.tile.tile-merged {
    animation: pop 200ms ease;
    z-index: 20;
}

@keyframes pop {
    0% { transform: scale(1); }
    50% { transform: scale(1.2); }
    100% { transform: scale(1); }
}

Responsive Design

@media screen and (max-width: 520px) {
    :root {
        --grid-size: 300px;
        --grid-gap: 10px;
    }

    .tile {
        font-size: 1.5rem;
    }
}

JavaScript Architecture

1. Tile Class (tile.js)

class Tile {
    constructor(position, value) {
        this.x = position.x;
        this.y = position.y;
        this.value = value || 2;
        this.previousPosition = null;
        this.mergedFrom = null; // Record tiles before merge
    }

    // Save position
    savePosition() {
        this.previousPosition = { x: this.x, y: this.y };
    }

    // Update position
    updatePosition(position) {
        this.x = position.x;
        this.y = position.y;
    }
}

2. Grid Class (grid.js)

class Grid {
    constructor(size) {
        this.size = size;
        this.cells = this.empty();
    }

    // Create empty grid
    empty() {
        const cells = [];
        for (let x = 0; x < this.size; x++) {
            const row = cells[x] = [];
            for (let y = 0; y < this.size; y++) {
                row.push(null);
            }
        }
        return cells;
    }

    // Get available cells
    availableCells() {
        const cells = [];
        this.eachCell((x, y, tile) => {
            if (!tile) {
                cells.push({ x, y });
            }
        });
        return cells;
    }

    // Iterate through all cells
    eachCell(callback) {
        for (let x = 0; x < this.size; x++) {
            for (let y = 0; y < this.size; y++) {
                callback(x, y, this.cells[x][y]);
            }
        }
    }

    // Get cell at specified position
    getCell(position) {
        if (this.withinBounds(position)) {
            return this.cells[position.x][position.y];
        }
        return null;
    }

    // Check if position is within bounds
    withinBounds(position) {
        return position.x >= 0 && position.x < this.size &&
               position.y >= 0 && position.y < this.size;
    }

    // Check if cell is available
    cellAvailable(position) {
        return !this.cellOccupied(position);
    }

    // Check if cell is occupied
    cellOccupied(position) {
        return !!this.getCell(position);
    }

    // Insert tile
    insertTile(tile) {
        this.cells[tile.x][tile.y] = tile;
    }

    // Remove tile
    removeTile(tile) {
        this.cells[tile.x][tile.y] = null;
    }
}

3. InputManager Class (input.js)

class InputManager {
    constructor() {
        this.events = {};
        this.listen();
    }

    on(event, callback) {
        if (!this.events[event]) {
            this.events[event] = [];
        }
        this.events[event].push(callback);
    }

    emit(event, data) {
        const callbacks = this.events[event];
        if (callbacks) {
            callbacks.forEach(callback => callback(data));
        }
    }

    listen() {
        // Keyboard events
        document.addEventListener('keydown', (event) => {
            const map = {
                38: 0, // Up
                39: 1, // Right
                40: 2, // Down
                37: 3, // Left
                75: 0, // Vim Up
                76: 1, // Vim Right
                74: 2, // Vim Down
                72: 3, // Vim Left
                87: 0, // W Up
                68: 1, // D Right
                83: 2, // S Down
                65: 3  // A Left
            };

            const modifiers = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
            const direction = map[event.which];

            if (!modifiers && direction !== undefined) {
                event.preventDefault();
                this.emit('move', direction);
            }
        });

        // Touch events
        let touchStartClientX, touchStartClientY;
        const gameContainer = document.querySelector('.game-container');

        gameContainer.addEventListener('touchstart', (event) => {
            if (event.touches.length > 1) return;
            touchStartClientX = event.touches[0].clientX;
            touchStartClientY = event.touches[0].clientY;
            event.preventDefault();
        }, { passive: false });

        gameContainer.addEventListener('touchmove', (event) => {
            event.preventDefault();
        }, { passive: false });

        gameContainer.addEventListener('touchend', (event) => {
            if (event.touches.length > 0) return;

            const touchEndClientX = event.changedTouches[0].clientX;
            const touchEndClientY = event.changedTouches[0].clientY;

            const dx = touchEndClientX - touchStartClientX;
            const dy = touchEndClientY - touchStartClientY;
            const absDx = Math.abs(dx);
            const absDy = Math.abs(dy);

            if (Math.max(absDx, absDy) > 10) {
                // Right: 1, Left: 3, Down: 2, Up: 0
                const direction = absDx > absDy ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
                this.emit('move', direction);
            }
        });
    }
}

4. StorageManager Class (storage.js)

class StorageManager {
    constructor() {
        this.bestScoreKey = 'bestScore';
    }

    // Get best score
    getBestScore() {
        return localStorage.getItem(this.bestScoreKey) || 0;
    }

    // Set best score
    setBestScore(score) {
        localStorage.setItem(this.bestScoreKey, score);
    }
}

5. Game Class (game.js)

class Game {
    constructor(size, InputManager, StorageManager) {
        this.size = size;
        this.inputManager = new InputManager;
        this.storageManager = new StorageManager;

        this.startTiles = 2;
        this.inputManager.on('move', this.move.bind(this));

        this.setup();
    }

    // Game initialization
    setup() {
        this.grid = new Grid(this.size);
        this.score = 0;
        this.over = false;
        this.won = false;
        this.bestScore = this.storageManager.getBestScore();

        // Add initial tiles
        this.addStartTiles();

        // Activate input
        this.actuate();
    }

    // Add initial tiles
    addStartTiles() {
        for (let i = 0; i < this.startTiles; i++) {
            this.addRandomTile();
        }
    }

    // Add random tile
    addRandomTile() {
        if (this.grid.availableCells().length > 0) {
            const value = Math.random() < 0.9 ? 2 : 4;
            const cell = this.grid.randomAvailableCell();
            const tile = new Tile({ x: cell.x, y: cell.y }, value);
            this.grid.insertTile(tile);
        }
    }

    // Save all tile positions
    prepareTiles() {
        this.grid.eachCell((x, y, tile) => {
            if (tile) {
                tile.mergedFrom = null;
                tile.savePosition();
            }
        });
    }

    // Move tile
    moveTile(tile, cell) {
        this.grid.cells[tile.x][tile.y] = null;
        this.grid.cells[cell.x][cell.y] = tile;
        tile.updatePosition(cell);
    }

    // Move game
    move(direction) {
        if (this.over || this.won) return;

        const vector = this.getVector(direction);
        const traversals = this.buildTraversals(vector);
        let moved = false;

        this.prepareTiles();

        traversals.x.forEach((x) => {
            traversals.y.forEach((y) => {
                const cell = { x, y };
                const tile = this.grid.getCell(cell);

                if (tile) {
                    const positions = this.findFarthestPosition(cell, vector);
                    const next = this.grid.getCell(positions.next);

                    // Merge logic
                    if (next && next.value === tile.value && !next.mergedFrom) {
                        const merged = new Tile(positions.next, tile.value * 2);
                        merged.mergedFrom = [tile, next];

                        this.grid.insertTile(merged);
                        this.grid.removeTile(tile);

                        // Update position
                        tile.updatePosition(positions.next);

                        // Update score
                        this.score += merged.value;

                        // Check win
                        if (merged.value === 2048) this.won = true;
                    } else {
                        this.moveTile(tile, positions.farthest);
                    }

                    if (!this.positionsEqual(cell, tile)) {
                        moved = true;
                    }
                }
            });
        });

        if (moved) {
            this.addRandomTile();
            if (!this.movesAvailable()) {
                this.over = true;
            }
            this.actuate();
        }
    }

    // Get direction vector
    getVector(direction) {
        const map = {
            0: { x: 0, y: -1 }, // Up
            1: { x: 1, y: 0 },  // Right
            2: { x: 0, y: 1 },  // Down
            3: { x: -1, y: 0 }  // Left
        };
        return map[direction];
    }

    // Build traversal order
    buildTraversals(vector) {
        const traversals = { x: [], y: [] };

        for (let pos = 0; pos < this.size; pos++) {
            traversals.x.push(pos);
            traversals.y.push(pos);
        }

        // Adjust traversal order based on direction
        if (vector.x === 1) traversals.x = traversals.x.reverse();
        if (vector.y === 1) traversals.y = traversals.y.reverse();

        return traversals;
    }

    // Find farthest position
    findFarthestPosition(cell, vector) {
        let previous;

        // Search along vector direction
        do {
            previous = cell;
            cell = { x: previous.x + vector.x, y: previous.y + vector.y };
        } while (this.grid.withinBounds(cell) && this.grid.cellAvailable(cell));

        return {
            farthest: previous,
            next: cell
        };
    }

    // Check if moves are available
    movesAvailable() {
        return this.grid.availableCells().length > 0 || this.tileMatchesAvailable();
    }

    // Check if mergeable tiles exist
    tileMatchesAvailable() {
        for (let x = 0; x < this.size; x++) {
            for (let y = 0; y < this.size; y++) {
                const tile = this.grid.getCell({ x, y });
                if (tile) {
                    for (let direction = 0; direction < 4; direction++) {
                        const vector = this.getVector(direction);
                        const cell = { x: x + vector.x, y: y + vector.y };
                        const other = this.grid.getCell(cell);

                        if (other && other.value === tile.value) {
                            return true;
                        }
                    }
                }
            }
        }
        return false;
    }

    // Position comparison
    positionsEqual(first, second) {
        return first.x === second.x && first.y === second.y;
    }

    // Update UI
    actuate() {
        // Update score display
        document.getElementById('score').textContent = this.score;
        document.getElementById('best-score').textContent = this.bestScore;

        // Update best score
        if (this.score > this.bestScore) {
            this.bestScore = this.score;
            this.storageManager.setBestScore(this.bestScore);
        }

        // Update board
        // ... (rendering logic will be detailed in next article)

        // Show game message
        const messageContainer = document.getElementById('game-message');
        if (this.over) {
            messageContainer.textContent = 'Game Over!';
        } else if (this.won) {
            messageContainer.textContent = 'You Win!';
        } else {
            messageContainer.textContent = '';
        }
    }
}

// Start game
window.requestAnimationFrame(() => {
    new Game(4, InputManager, StorageManager);
});

Summary

This tutorial covered the basic architecture of the 2048 game:

  1. HTML Structure: Building game interface with semantic tags
  2. CSS Styling: Using CSS variables and Grid layout
  3. JavaScript Architecture: Object-oriented design with separation of concerns

In the next article, we will cover:

  • Rendering logic and DOM manipulation
  • Animation implementation
  • Game optimization and performance improvement