~/blog/making_of_tetris
cd ..
Ξ~/blog/making_of_tetriscat making_of_tetris.md

Building a Tetris Game

Published:2025-10-15
Read time:20 min read

Making of Tetris

This is a friendly, slightly silly, and very practical "how I built a vanilla Tetris" write-up — exactly the code I used, step-by-step, so you can copy-paste, learn, and laugh at my variable names.

I built this app using plain HTML, CSS, and JavaScript — no frameworks, no build step, just the kind of code you can open in any browser and shame into working. Below you get the full files for the project. Drop them into the same folder structure as the repo and open index.html.

Why this guide? Because Tetris is a great small project: it has rendering, state, user input, a simple physics loop, and surprisingly dramatic feelings when you lose.

Quick file map (what I edited/provide here):

  • index.html — markup + canvas
  • css/style.css — styling
  • js/Board.js — board/grid logic
  • js/Piece.js — piece shapes & rotation
  • js/Game.js — game loop, scoring, rules
  • js/Input.js — keyboard handling
  • js/Renderer.js — canvas drawing
  • js/main.js — app bootstrap

Ready? Let's build a Tetris and pretend we knew what we were doing the whole time.


1) index.html

Create a simple page with a canvas and a few UI bits.

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Vanilla Tetris</title>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <div class="wrap">
        <h1>Vanilla Tetris</h1>
        <canvas id="game" width="240" height="480"></canvas>
        <div class="info">
            <p>Score: <span id="score">0</span></p>
            <p>Level: <span id="level">1</span></p>
            <p>Lines: <span id="lines">0</span></p>
            <p class="hint">Use ← → ↓ to move, ↑ to rotate, space to hard drop</p>
        </div>
    </div>
    <script type="module" src="js/main.js"></script>
</body>
</html>

2) css/style.css

Minimal styles to center everything and make the canvas look like a tiny arcade.

/* css/style.css */
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background:#0e0e10; color:#eee; display:flex; height:100vh; align-items:center; justify-content:center; margin:0; }
.wrap { text-align:center; }
h1 { margin:0 0 12px 0; font-weight:600; }
canvas { image-rendering: pixelated; background:#111; display:block; margin:0 auto; border:8px solid #222; box-shadow: 0 10px 30px rgba(0,0,0,0.7); }
.info { margin-top:12px; font-size:14px; color:#ddd; }
.hint { color:#a8a8a8; font-size:12px; margin-top:8px; }

/* block colors used by renderer */
.c0 { background:#111 }

3) js/Board.js

Board manages the grid, placing pieces, checking collisions, and clearing lines.

// js/Board.js
export default class Board {
    constructor(cols = 10, rows = 20) {
        this.cols = cols;
        this.rows = rows;
        this.reset();
    }

    reset() {
        this.grid = Array.from({ length: this.rows }, () => Array(this.cols).fill(0));
    }

    inside(x, y) {
        return x >= 0 && x < this.cols && y >= 0 && y < this.rows;
    }

    cell(x, y) {
        if (!this.inside(x, y)) return 1; // treat out-of-bounds as filled for collision tests
        return this.grid[y][x];
    }

    canPlace(piece, offsetX, offsetY) {
        for (const [x, y] of piece.cells()) {
            const nx = x + offsetX;
            const ny = y + offsetY;
            if (!this.inside(nx, ny) || this.cell(nx, ny)) return false;
        }
        return true;
    }

    place(piece, offsetX, offsetY) {
        for (const [x, y] of piece.cells()) {
            const nx = x + offsetX;
            const ny = y + offsetY;
            if (this.inside(nx, ny)) this.grid[ny][nx] = piece.id || 1;
        }
    }

    clearLines() {
        let lines = 0;
        for (let y = this.rows - 1; y >= 0; ) {
            if (this.grid[y].every(v => v !== 0)) {
                this.grid.splice(y, 1);
                this.grid.unshift(Array(this.cols).fill(0));
                lines++;
                // stay on same y to check the row that shifted down
            } else {
                y--;
            }
        }
        return lines;
    }
}

4) js/Piece.js

Piece shapes and rotation. This file encodes Tetris standard pieces in small matrices, and provides rotation and cell list helpers.

// js/Piece.js
const SHAPES = {
    I: [[1,1,1,1]],
    O: [[1,1],[1,1]],
    T: [[0,1,0],[1,1,1]],
    S: [[0,1,1],[1,1,0]],
    Z: [[1,1,0],[0,1,1]],
    J: [[1,0,0],[1,1,1]],
    L: [[0,0,1],[1,1,1]]
};

const IDS = { I:1, O:2, T:3, S:4, Z:5, J:6, L:7 };

export default class Piece {
    constructor(type) {
        this.type = type;
        this.matrix = SHAPES[type].map(r => r.slice());
        this.id = IDS[type];
    }

    rotate() {
        const m = this.matrix;
        const rows = m.length, cols = m[0].length;
        const res = Array.from({ length: cols }, () => Array(rows).fill(0));
        for (let r = 0; r < rows; r++) {
            for (let c = 0; c < cols; c++) {
                res[c][rows - 1 - r] = m[r][c];
            }
        }
        this.matrix = res;
    }

    cells() {
        const out = [];
        for (let y = 0; y < this.matrix.length; y++) {
            for (let x = 0; x < this.matrix[y].length; x++) {
                if (this.matrix[y][x]) out.push([x, y]);
            }
        }
        return out;
    }

    width() { return this.matrix[0].length; }
    height() { return this.matrix.length; }
}

export const randomPiece = () => {
    const keys = Object.keys(SHAPES);
    const t = keys[Math.floor(Math.random() * keys.length)];
    return new Piece(t);
};

5) js/Game.js

This ties board and pieces together, runs gravity, scoring, and game over logic.

// js/Game.js
import Board from './Board.js';
import Piece, { randomPiece } from './Piece.js';

export default class Game {
    constructor(cols=10, rows=20) {
        this.board = new Board(cols, rows);
        this.score = 0;
        this.level = 1;
        this.lines = 0;
        this.reset();
    }

    reset() {
        this.board.reset();
        this.spawn();
        this.next = randomPiece();
        this.score = 0; this.level = 1; this.lines = 0;
        this.gameOver = false;
        this.gravityMs = 600;
        this.dropAccumulator = 0;
    }

    spawn() {
        this.current = this.next || randomPiece();
        this.next = randomPiece();
        this.x = Math.floor((this.board.cols - this.current.width()) / 2);
        this.y = -this.current.height();
        if (!this.board.canPlace(this.current, this.x, this.y + 1)) {
            this.gameOver = true;
        }
    }

    hardDrop() {
        while (this.move(0,1)) {}
        this.lock();
    }

    move(dx, dy) {
        if (this.board.canPlace(this.current, this.x + dx, this.y + dy)) {
            this.x += dx; this.y += dy;
            return true;
        }
        return false;
    }

    rotate() {
        const backup = this.current.matrix.map(r => r.slice());
        this.current.rotate();
        if (!this.board.canPlace(this.current, this.x, this.y)) {
            // simple wall kick: try shifting left or right
            if (this.board.canPlace(this.current, this.x - 1, this.y)) this.x -= 1;
            else if (this.board.canPlace(this.current, this.x + 1, this.y)) this.x += 1;
            else this.current.matrix = backup;
        }
    }

    lock() {
        this.board.place(this.current, this.x, this.y);
        const cleared = this.board.clearLines();
        if (cleared > 0) {
            const points = [0, 40, 100, 300, 1200];
            this.score += (points[cleared] || 0) * this.level;
            this.lines += cleared;
            this.level = Math.floor(this.lines / 10) + 1;
            this.gravityMs = Math.max(80, 600 - (this.level - 1) * 40);
        }
        this.spawn();
    }

    update(deltaMs) {
        if (this.gameOver) return;
        this.dropAccumulator += deltaMs;
        if (this.dropAccumulator >= this.gravityMs) {
            this.dropAccumulator = 0;
            if (!this.move(0,1)) this.lock();
        }
    }
}

6) js/Input.js

Keyboard handling for the classic controls.

// js/Input.js
export default class Input {
    constructor(game) {
        this.game = game;
        this._onKey = this._onKey.bind(this);
        window.addEventListener('keydown', this._onKey);
    }

    _onKey(e) {
        if (this.game.gameOver) return;
        switch (e.code) {
            case 'ArrowLeft': e.preventDefault(); this.game.move(-1,0); break;
            case 'ArrowRight': e.preventDefault(); this.game.move(1,0); break;
            case 'ArrowDown': e.preventDefault(); this.game.move(0,1); break;
            case 'ArrowUp': e.preventDefault(); this.game.rotate(); break;
            case 'Space': e.preventDefault(); this.game.hardDrop(); break;
        }
    }

    destroy() { window.removeEventListener('keydown', this._onKey); }
}

7) js/Renderer.js

Draws the board and current falling piece to the canvas. Uses a simple color palette.

// js/Renderer.js
const COLORS = [
    '#000000', // empty
    '#00d2ff', // I
    '#ffd500', // O
    '#a020f0', // T
    '#24e03a', // S
    '#ff4b4b', // Z
    '#2b6cff', // J
    '#ff8c1a'  // L
];

export default class Renderer {
    constructor(canvas, cols=10, rows=20, scale=24) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
        this.cols = cols; this.rows = rows; this.scale = scale;
        canvas.width = cols * scale;
        canvas.height = rows * scale;
        this.ctx.imageSmoothingEnabled = false;
    }

    draw(game) {
        const ctx = this.ctx;
        ctx.clearRect(0,0,this.canvas.width,this.canvas.height);

        // board cells
        for (let y = 0; y < game.board.rows; y++) {
            for (let x = 0; x < game.board.cols; x++) {
                const v = game.board.grid[y][x];
                this._drawCell(x, y, COLORS[v]);
            }
        }

        // current piece
        if (!game.gameOver && game.current) {
            for (const [cx, cy] of game.current.cells()) {
                const x = cx + game.x;
                const y = cy + game.y;
                if (y >= 0) this._drawCell(x, y, COLORS[game.current.id]);
            }
        }

        if (game.gameOver) {
            ctx.fillStyle = 'rgba(0,0,0,0.6)';
            ctx.fillRect(0, this.canvas.height/2 - 30, this.canvas.width, 60);
            ctx.fillStyle = '#fff';
            ctx.font = '20px sans-serif';
            ctx.textAlign = 'center';
            ctx.fillText('Game Over', this.canvas.width/2, this.canvas.height/2 + 8);
        }
    }

    _drawCell(x, y, color) {
        const s = this.scale;
        const ctx = this.ctx;
        ctx.fillStyle = color || '#111';
        ctx.fillRect(x * s, y * s, s - 1, s - 1);
        // simple highlight
        ctx.strokeStyle = 'rgba(255,255,255,0.04)';
        ctx.strokeRect(x * s + 0.5, y * s + 0.5, s - 1, s - 1);
    }
}

8) js/main.js

Bootstraps everything, runs the game loop, and updates the UI counters.

// js/main.js
import Game from './Game.js';
import Renderer from './Renderer.js';
import Input from './Input.js';

const canvas = document.getElementById('game');
const scoreEl = document.getElementById('score');
const levelEl = document.getElementById('level');
const linesEl = document.getElementById('lines');

const game = new Game(10, 20);
const renderer = new Renderer(canvas, 10, 20, 24);
const input = new Input(game);

let last = performance.now();
function loop(now = performance.now()) {
    const delta = now - last;
    last = now;
    game.update(delta);
    renderer.draw(game);
    scoreEl.textContent = game.score;
    levelEl.textContent = game.level;
    linesEl.textContent = game.lines;
    requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

// expose a tiny convenience to restart by pressing R (useful while testing)
window.addEventListener('keydown', (e) => { if (e.key.toLowerCase() === 'r') game.reset(); });

Run it locally

  1. Ensure the files are placed in the structure you already have:
  2. index.html
  3. css/style.css
  4. js/Board.js, js/Piece.js, js/Game.js, js/Input.js, js/Renderer.js, js/main.js
  5. Open index.html in your browser. If your browser blocks local modules due to CORS (some do), run a simple HTTP server from the folder. Example with Python 3:
  6. python3 -m http.server 8000
    # then open http://localhost:8000 in your browser
  7. Play. Press R to restart. Curse softly when you stack a column too high.
  8. Notes, tips, and jokes

  9. Game design: Tetris is mostly about decision speed and eternal regret.
  10. Performance: This project is tiny; if you want smoother rendering, tweak Renderer.scale or upgrade to a WebGL canvas.
  11. Extensibility: Add a next-piece preview, hold piece, sound effects, or online multiplayer if you crave pain.

If you want, I can also:

  • Add a next-piece preview and hold queue.
  • Implement soft/hard drop scoring differences and DAS/ARR for console-like controls.

Enjoy the code. If anything's unclear or you want features (ghost piece, animations, music), tell me which one and I will add it — and probably make yet another soft joke about my Tetris high score (which is embarrassingly low).

#JavaScript#Games#Tutorial