Technical Walkthrough
How the Level‑Based Tetris Was Engineered
Technical Walkthrough How the Level‑Based Tetris Was Engineered
This page documents the internal architecture of a self‑contained HTML5 Tetris implementation.
Every design decision — from grid geometry to the game loop — is explained so you can
understand exactly which code does what and why it was written that way.
The entire game lives inside a single .html file: no CDN, no external JavaScript,
no framework. Just HTML, CSS, and vanilla JS.
1. Why a Self‑Contained Single File?
Blogger and most lightweight CMS platforms strip or block external <script src>
tags and CDN dependencies for security reasons. To guarantee the game works everywhere,
all logic is inlined. The trade‑off is a larger file, but the payoff is total portability:
paste the code into any HTML widget and it runs.
- No CDN Zero external requests → faster load, no CORS issues.
- No Build Step Plain ES5‑compatible JavaScript wrapped in an IIFE.
- Responsive Canvas scales via CSS while keeping a fixed logical resolution.
2. The Coordinate System & Canvas
The board is a 10×20 grid. Each cell is 30px in logical space,
giving a fixed canvas resolution of 300 × 600. This never changes — the browser
scales it visually with CSS to fit any screen width.
const COLS = 10;
const ROWS = 20;
const BLOCK_SIZE = 30;
const CANVAS_W = COLS * BLOCK_SIZE; // 300
const CANVAS_H = ROWS * BLOCK_SIZE; // 600
Keeping the logical resolution constant means all collision math uses clean integers.
The resizeCanvas() function only touches canvas.style.width/height,
never the width/height attributes — so the drawing buffer is untouched
and no blurry scaling occurs.
3. Board Representation
The playfield is a 2D array of ROWS rows, each containing COLS cells.
A cell is either null (empty) or a color string like #00f0f0.
Using color strings directly means we never need a separate lookup table when drawing.
function initBoard() {
board = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
}
4. Tetromino Definitions
Each of the seven classic tetrominoes is stored as a matrix of 0/1 values
plus a hex color. The I piece uses a 4×4 matrix so that rotation works uniformly
across all pieces without special cases.
const SHAPES = [
{ matrix: [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], color: '#00f0f0' },
{ matrix: [[1,1],[1,1]], color: '#f0f000' },
// ... T, S, Z, J, L
];
5. Collision Detection
This is the heart of the game. collision(matrix, offsetX, offsetY) walks every
filled cell of a piece and checks three things: left/right walls, the floor, and existing
locked blocks on the board. It returns true the moment any overlap is found.
function collision(matrix, offsetX, offsetY) {
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c] !== 0) {
const bx = offsetX + c, by = offsetY + r;
if (bx < 0 || bx >= COLS || by >= ROWS || by < 0) return true;
if (board[by][bx] !== null) return true;
}
}
}
return false;
}
Every movement, rotation, and drop calls this function first. If it returns false,
the move is safe and applied. This single guard prevents all impossible states.
6. Rotation with Wall Kicks
Rotating a matrix 90° clockwise is done with a transpose‑and‑reverse one‑liner. But near a
wall, a plain rotation would collide. To fix this, we try five horizontal offsets
(0, -1, +1, -2, +2) and accept the first one that does not collide.
This is a lightweight version of the “SRS wall kick” system used in modern Tetris.
function rotatePiece() {
const rotated = currentPiece.matrix[0].map((_, i) =>
currentPiece.matrix.map(row => row[i]).reverse()
);
for (const dx of [0, -1, 1, -2, 2]) {
if (!collision(rotated, currentPiece.x + dx, currentPiece.y)) {
currentPiece.matrix = rotated;
currentPiece.x += dx;
return;
}
}
}
7. Line Clearing & Scoring
When a piece locks, we scan rows from bottom to top. Any fully filled row is removed with
splice, and a fresh empty row is unshifted to the top. Score follows the classic
Tetris table, multiplied by the current level.
| Lines Cleared | Base Score |
|---|---|
| 1 (Single) | 100 |
| 2 (Double) | 300 |
| 3 (Triple) | 500 |
| 4 (Tetris) | 800 |
const LINE_SCORES = [0, 100, 300, 500, 800];
score += LINE_SCORES[Math.min(linesCleared, 4)] * level;
8. Level Progression
Every 10 cleared lines advances the level. The drop interval shrinks geometrically, so the game accelerates smoothly instead of jumping. Level is capped at 10 to keep the game playable on touch devices.
const LINES_PER_LEVEL = 10;
const MAX_LEVEL = 10;
const BASE_DROP_INTERVAL = 800; // ms at level 1
const SPEED_FACTOR = 0.82; // per level
const dropInterval = BASE_DROP_INTERVAL * Math.pow(SPEED_FACTOR, level - 1);
At level 1 the piece falls every 800 ms. At level 10 it falls every
800 × 0.82⁹ ≈ 135 ms — roughly six times faster.
9. The Game Loop
The loop uses requestAnimationFrame and a delta‑time accumulator.
Every frame we add the elapsed milliseconds to dropCounter. When it exceeds
dropInterval, the piece moves down one cell and the counter resets.
function gameLoop(time) {
const delta = time - lastTime;
lastTime = time;
dropCounter += delta;
if (dropCounter > dropInterval) {
// move down or lock
dropCounter = 0;
}
draw();
requestAnimationFrame(gameLoop);
}
setInterval drifts and cannot be paused cleanly.
Delta‑time with requestAnimationFrame is frame‑rate independent, pauses perfectly,
and lets us redraw the canvas every frame for smooth visuals.
10. Rendering Strategy
Every frame we clear the canvas and redraw three layers: the grid lines, the locked blocks on the board, and the currently falling piece. Each block is drawn with a base fill, a subtle inner highlight, and a dark border — three cheap operations that give a polished “beveled” look without images or gradients.
function drawBlock(x, y, color, size) {
ctx.fillStyle = color;
ctx.fillRect(x, y, size, size);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(x + 2, y + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.strokeRect(x + 1, y + 1, size - 2, size - 2);
}
11. Input Handling
Two input paths are supported: physical keyboard and on‑screen buttons. The keyboard handler prevents default browser scrolling on arrow keys and space, which is critical — otherwise pressing “down” would scroll the page instead of moving the piece.
document.addEventListener('keydown', (e) => {
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) {
e.preventDefault();
}
switch (e.key) {
case 'ArrowLeft': movePiece(-1); break;
case 'ArrowRight': movePiece(1); break;
case 'ArrowDown': softDrop(); break;
case 'ArrowUp': rotatePiece(); break;
case ' ': hardDrop(); break;
}
});
Touch buttons use a touchstart listener with preventDefault()
to eliminate the 300 ms tap delay and prevent double‑tap zoom on mobile.
12. Responsive Design
The wrapper is capped at 480px and the canvas scales proportionally inside it.
This ensures the game is playable on phones, tablets, and desktops without ever cropping
the board or requiring horizontal scrolling.
function resizeCanvas() {
const containerWidth = wrapper.clientWidth - 24;
const scale = Math.min(1, containerWidth / CANVAS_W);
canvas.style.width = (CANVAS_W * scale) + 'px';
canvas.style.height = (CANVAS_H * scale) + 'px';
}
The scale factor is capped at 1 so on large screens the canvas stays at its
native 300×600 instead of becoming blurry from upscaling.
13. Summary of Design Principles
- Separation of concerns: State (board, piece, score) is decoupled from rendering and input.
- Single source of truth:
collision()is the only authority on whether a move is legal. - Frame‑rate independence: Delta‑time accumulation makes the game feel identical on 60 Hz and 120 Hz displays.
- Progressive difficulty: Geometric speed scaling produces a smooth curve rather than a stair‑step.
- Zero dependencies: The whole game ships in one file, works offline, and drops into any HTML page.