Build Tetris in Vanilla TypeScript and HTML5 Canvas
Code-first walkthrough of writing Tetris in strict TypeScript with bun.
Tetris has a property that makes it the right teaching example for vanilla TypeScript and Canvas. The data model is small (a 10x20 grid of cell colors), the rendering loop is trivial (clear the frame, draw the board, draw the active piece), and the input layer fits inside one keydown handler. What makes it interesting is the interaction between those layers. A tetromino is a small matrix that travels across the board, locks into it, then disappears as full rows clear. Every concept here (immutability of cells, rotation matrices, requestAnimationFrame pacing) has a direct, visible consequence on screen.
This walkthrough rebuilds Tetris in six commits using bun as the bundler and HTML5 Canvas as the renderer. No React, no Phaser, no Pixi. The point is to keep the surface area small enough that you can read every line in one sitting and understand exactly where each frame's pixels come from. If you finish, you will have shipped roughly 300 lines of TypeScript that produce a playable game with rotation, wall kicks, hard drop, scrolling difficulty, and a restart key.
Compared to a React canvas wrapper or a full engine like Phaser, this approach trades convenience (no scene graph, no asset pipeline) for transparency (you own the entire frame loop). For a learning project that is the right trade. For a shipping product targeting modern browsers, you would benefit from a small layer of structure on top of what we build here, but the core game state and tick loop carries over to that world unchanged.
The full source lives at https://github.com/vytharion/tetris-game-canvas-ts. Every lesson below corresponds to one commit. Clone the repo and check out the commit hash if you want the exact state at that lesson.
Lesson 0: Scaffolding the project
Before code, the project gets the smallest possible scaffold: a README, a Bun-friendly package.json, a strict tsconfig.json, and an index.html that hosts a single canvas element. Strict TypeScript matters here because the game logic touches array indices in nested loops constantly. Without strict: true the compiler stops complaining about matrix[y][x] reads that may be undefined, and you lose its help catching off-by-one bugs.
The bundler choice is Bun. Bun's build command is fast enough that you can rebuild the entire game on every save without a watcher pipeline, and it bundles ESM modules into a single dist/main.js without a config file. There is no Vite, no Webpack, no esbuild plugin chain. The whole build command is one line in package.json.
{
"name": "tetris-game-canvas-ts",
"type": "module",
"private": true,
"scripts": {
"build": "bun build src/main.ts --outdir dist --target browser"
}
}
For readers who have only used npm, the practical difference is install speed and the lack of a config layer for the bundler. Bun resolves a dependency tree in milliseconds and ships a bundler that defaults to "make a browser bundle." If you prefer npm and esbuild, the source code in this repo works without change. Only the build command differs. The Bun docs cover both modes at https://bun.sh/docs/bundler.
Commit at c6b9f09. After running bun run build, the bundle is ready and index.html loads it.
Lesson 1: An empty board
The first interesting commit puts a grid on screen. Two constants define the board dimensions (COLS = 10, ROWS = 20, CELL = 28), and the canvas size derives from them. Hard-coding the cell size in pixels keeps later rendering math obvious. Column 4 lives at 4 * CELL on the x axis.
export const COLS = 10;
export const ROWS = 20;
export const CELL = 28;
export const CANVAS_W = COLS * CELL;
export const CANVAS_H = ROWS * CELL;
The board itself is a 2D array of color strings. An empty cell is "", a locked cell holds a hex color matching the tetromino that landed there. Storing colors directly (instead of an enum and a lookup map) keeps the render path one branch shorter. Read the string, fill the cell if it is non-empty, skip otherwise.
export function createEmptyBoard(): Board {
return Array.from({ length: ROWS }, () =>
Array.from({ length: COLS }, () => "")
);
}
Rendering happens in drawBackground, which paints the background color and then the grid lines, plus drawBoard, which loops over every cell and fills the non-empty ones. The + 0.5 offsets in the line coordinates are not a typo. Canvas strokes a 1-pixel line centered on the coordinate you pass, so a line at integer x is painted across two pixels with 50% alpha each. Half-pixel offsets snap the line to a single crisp pixel. The MDN canvas guide covers this in detail at https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Applying_styles_and_colors.
Commit at 91a1471. Running the bundle now shows an empty 280 by 560 grid against a near-black background.
Lesson 2: The seven pieces
A Piece in this codebase is a small record. Its kind, its matrix (a 4x4 or 3x3 grid of 0/1 cells), its hex color, and its (x, y) position on the board. Storing the matrix on the piece, rather than computing it from a global table on every render, makes rotation work cleanly later. Rotation is just a matrix transform on piece.matrix.
The seven tetromino specs live in one record indexed by PieceKind. Each spec stores the canonical orientation, not the current one. spawn clones the matrix so rotation does not mutate the canonical table.
export const TETROMINOES: Record<PieceKind, Spec> = {
I: { color: "#4ec5d6", matrix: [
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
[0, 0, 0, 0],
] },
O: { color: "#e8c63b", matrix: [[1, 1], [1, 1]] },
T: { color: "#a663d6", matrix: [[0, 1, 0], [1, 1, 1], [0, 0, 0]] },
// S, Z, J, L follow the same pattern
};
The I piece deserves a comment about the 4x4 frame. The standard Tetris rotation system needs the I piece to occupy a 4-row matrix so that rotating it in place keeps its pivot at the center. A naive 1x4 representation rotates into a 4x1 column that visibly jumps to the left every time. The 4x4 frame, with the row of cells offset by one, makes a CW rotation produce a column offset by one. The same trick applies to O (which is rotation-invariant, so a 2x2 is fine) and S, Z, T, J, L (3x3 frames).
spawn centers the piece horizontally and starts it at row 0. randomKind picks uniformly from the seven kinds. A real implementation would use a 7-bag randomizer for fairness, but uniform is good enough for the tutorial scope and one fewer concept to introduce.
export function spawn(kind: PieceKind = randomKind()): Piece {
const spec = TETROMINOES[kind];
const matrix: Matrix = spec.matrix.map((row) => [...row]);
const size = matrix.length;
return {
kind, matrix, color: spec.color,
x: Math.floor((COLS - size) / 2),
y: 0,
};
}
Render-side, drawPiece walks the piece's matrix and offsets every cell by (piece.x, piece.y). The board and the active piece use the same drawCell helper, so they look identical except that the active piece moves and the board cells do not.
Commit at e260515. Reload and you see a single piece sitting at the top, frozen.
Lesson 3: Gravity and locking
This commit adds the game loop. A piece falls one row every tickInterval(state) milliseconds, and when it cannot fall further it locks into the board. The state machine is small enough to express in a handful of pure functions.
export function tick(state: GameState): void {
if (state.over) return;
if (canMove(state, 0, 1)) {
state.current.y += 1;
return;
}
lockPiece(state);
clearLines(state);
state.current = spawn();
if (!canMove(state, 0, 0)) {
state.over = true;
}
}
canMove accepts a hypothetical offset (dx, dy) and reports whether the active piece would fit at that translated position. The check walks the piece matrix and rejects anything that goes out of bounds (left, right, or bottom) or overlaps a non-empty board cell. The same function is reused for left and right movement in the next lesson. Not having two near-duplicate functions makes the rotation kick logic cleaner later on.
export function canMove(
state: GameState,
dx: number,
dy: number
): boolean {
const { current, board } = state;
for (let y = 0; y < current.matrix.length; y++) {
for (let x = 0; x < current.matrix[y]!.length; x++) {
if (!current.matrix[y]![x]) continue;
const newX = current.x + x + dx;
const newY = current.y + y + dy;
if (newX < 0 || newX >= COLS) return false;
if (newY >= ROWS) return false;
if (newY >= 0 && board[newY]![newX]) return false;
}
}
return true;
}
lockPiece copies the active piece's colored cells into the board, then the spawn of the next piece happens. If the freshly spawned piece overlaps a board cell on row 0, the game is over. This is the standard top-out condition.
The driving loop sits in main.ts and uses requestAnimationFrame for the render cycle, but it does not tick on every frame. It tracks lastTick in milliseconds and only calls tick when enough wall-clock time has passed. Decoupling render rate from game rate matters. A 144 Hz monitor should not run Tetris six times faster than a 60 Hz monitor, and a frame that takes 80 ms to render should not skip game logic.
function frame(now: number): void {
if (now - lastTick >= tickInterval(state)) {
tick(state);
lastTick = now;
}
render(ctx, state);
updateHud(hud, state);
requestAnimationFrame(frame);
}
Compared to a fixed-step setInterval, this approach pays the slight cost of an extra date-math call per frame in exchange for jitter resistance. The requestAnimationFrame reference at https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame explains the timestamp guarantee.
Commit at c5c1e4e. Reload and pieces now fall and stack.
Lesson 4: Keyboard input and rotation
Until this commit the player is a spectator. Lesson 4 adds a keydown listener that translates arrow keys (and WASD as a backup) into game actions: left, right, soft drop, rotate, and a space-bar hard drop.
window.addEventListener("keydown", (e) => {
if (BLOCK_KEYS.has(e.key)) e.preventDefault();
if (e.key === "r" || e.key === "R") { restart(state); return; }
if (state.over) return;
switch (e.key) {
case "ArrowLeft": case "a": tryMove(state, -1, 0); return;
case "ArrowRight": case "d": tryMove(state, 1, 0); return;
case "ArrowDown": case "s": tryMove(state, 0, 1); return;
case "ArrowUp": case "w": tryRotate(state); return;
case " ": hardDrop(state); return;
}
});
tryMove is a thin wrapper. Ask canMove, apply the offset if true. hardDrop is even simpler. Call tryMove(state, 0, 1) in a loop until it returns false. The piece will lock on the next tick because gravity finds nowhere to go.
Rotation is where Tetris gets opinionated. A piece rotated against a wall would normally clip out of bounds. Instead, the standard pattern (called a "wall kick") tries small horizontal offsets before giving up on the rotation. The implementation here checks -1, +1, -2, +2 in order, accepts the first one where the rotated matrix fits, and reverts the rotation if none work.
function tryRotate(state: GameState): void {
const original = state.current.matrix;
state.current.matrix = rotateCW(original);
if (canMove(state, 0, 0)) return;
for (const dx of [-1, 1, -2, 2]) {
if (canMove(state, dx, 0)) {
state.current.x += dx;
return;
}
}
state.current.matrix = original;
}
This is a simplified version of the Super Rotation System used by modern Tetris. SRS uses per-piece, per-rotation-state offset tables and gives the I piece special treatment because of its 4-wide footprint. The implementation here is roughly 80% of SRS's perceived behavior at 5% of its code complexity, which is the right trade for a tutorial. If you ever ship this and want to feel the difference, the Tetris Wiki SRS page at https://tetris.wiki/Super_Rotation_System is the reference.
rotateCW is a plain 90 degree clockwise matrix transform. For each cell (x, y) in the input, write it to (n - 1 - y, x) of the output. The new matrix is allocated fresh so the rotation does not mutate the canonical table in TETROMINOES.
Commit at 49a2f18. The game is now playable, just no scoring yet.
Lesson 5: Lines, scoring, levels, and game over
The final commit closes the loop. clearLines filters the board down to only rows that still have at least one empty cell, then prepends empty rows to bring the row count back to 20.
export function clearLines(state: GameState): number {
const remaining = state.board.filter((row) => row.some((c) => !c));
const cleared = ROWS - remaining.length;
if (cleared === 0) return 0;
while (remaining.length < ROWS) {
remaining.unshift(Array.from({ length: COLS }, () => ""));
}
state.board = remaining;
state.lines += cleared;
state.score += (LINE_POINTS[cleared] ?? 0) * (state.level + 1);
state.level = Math.floor(state.lines / 10);
return cleared;
}
Scoring follows the classic table. 100 points for a single line, 300 for a double, 500 for a triple, 800 for a tetris. Each tier scales with (level + 1), so a tetris at level 9 is worth 8000 points. Level increments every 10 lines cleared.
The level also drives the gravity interval. tickInterval(state) = Math.max(80, 500 - state.level * 40) means level 0 ticks every 500 ms, level 10 every 100 ms, and the speed clamps at 80 ms. A faster floor would make the game ungovernable. 80 ms still leaves about three frames at 60 Hz to react.
restart lives next to the game-over check and rebuilds state from scratch. Press R at any time and the board, score, and level reset. The keyboard handler treats R as a global key even when state.over === true, so the player does not have to wait for some other UI to surface a restart button.
Commit at faaecce. Reload, build the bundle once with bun run build, and the game is feature-complete.
Where this differs from a framework approach
A reasonable alternative would have been to reach for Phaser, Pixi, or a small React canvas wrapper. The cost of vanilla here is real. About 50 lines of glue you would not write if a framework did it for you (canvas setup, requestAnimationFrame plumbing, keydown wiring). The benefit is that every state mutation happens in one of three places (tick, the move and rotate helpers, restart), and the entire game logic file stays under 100 lines.
For a tutorial that someone might extend with a second player, networked multiplayer, or a different ruleset, that compactness pays back fast. For a production game, you would want to add a scene structure on top, but the core state shape and the tick function would survive unchanged.
The other notable choice is strict TypeScript with explicit non-null assertions on indexed access (board[y]![x]!). The assertions are not vanity. Without them, every cell read returns the type string | undefined under noUncheckedIndexedAccess style, and the rendering code becomes a sea of if guards. The assertions encode an invariant. We have already validated (y, x) is inside the board. Comparing this against an optional-chain-heavy alternative, the assertion version reads about 30% shorter and runs the same at runtime.
Repository
Full source at https://github.com/vytharion/tetris-game-canvas-ts. Clone the repo, check out any commit, and bun run build to see the state at that point in the tutorial.
- Lesson 0: c6b9f09: project scaffold (README, package.json, tsconfig, index.html)
- Lesson 1: 91a1471: project setup + empty canvas board grid
- Lesson 2: e260515: tetromino shapes + spawn + render active piece
- Lesson 3: c5c1e4e: game loop + gravity + lock-and-respawn
- Lesson 4: 49a2f18: keyboard input + rotation with wall kicks + hard drop
- Lesson 5: faaecce: line clear + score + level scaling + game over + restart
Where to go from here
The codebase has obvious extensions. A 7-bag randomizer replaces randomKind and removes the rare "I drought." A hold slot needs one new state field and one keyboard branch. A next-piece queue is a 4-deep PieceKind[] and a small rectangle on the HUD. A ghost piece adds one render pass at the bottom of the play column, using a translucent fill of the same color. None of these break the existing structure. They slot in next to the lessons above.
Two harder follow-ups, if you want to keep going. Persisting high scores to localStorage (one function to read, one to write, no state-management library). Adding a sound layer with the Web Audio API for line clears and lock thuds. The Web Audio API docs at https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API give you a 20-line beep without any dependency.
Clone the repo, check out a commit, and start there.