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.
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:
- HTML Structure: Building game interface with semantic tags
- CSS Styling: Using CSS variables and Grid layout
- 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