Code — how this page is built
Under the hood
The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.
TetrisGameview on GitHub ↗
Canvas Tetris: matrix pieces, transpose+reverse rotation, collision detection, gravity on a timer, line clears and scoring, keyboard control with hard drop.
'use client';
import * as React from 'react';
import { Win98Button } from '@/components/ui/win-98-button';
const COLS = 10;
const ROWS = 20;
const CELL = 20;
const KEY = 'kaspirius:tetris:best';
type Cell = number; // 0 empty, else color index
const COLORS = ['', '#2f6db0', '#c9a23f', '#9b5cc0', '#6f7d4a', '#c2453a', '#4c8c7a', '#b8772e'];
const SHAPES: number[][][] = [
[[1, 1, 1, 1]], // I
[[2, 0, 0], [2, 2, 2]], // J
[[0, 0, 3], [3, 3, 3]], // L
[[4, 4], [4, 4]], // O
[[0, 5, 5], [5, 5, 0]], // S
[[0, 6, 0], [6, 6, 6]], // T
[[7, 7, 0], [0, 7, 7]], // Z
];
const rotate = (m: number[][]) => m[0].map((_, c) => m.map((row) => row[c]).reverse());
export function TetrisGame() {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [status, setStatus] = React.useState<'ready' | 'playing' | 'over'>('ready');
const [score, setScore] = React.useState(0);
const [best, setBest] = React.useState(0);
const game = React.useRef({
grid: [] as Cell[][],
piece: { shape: SHAPES[0], x: 3, y: 0 },
score: 0,
});
React.useEffect(() => {
setBest(Number(localStorage.getItem(KEY)) || 0);
}, []);
const newPiece = () => {
const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)];
return { shape, x: Math.floor((COLS - shape[0].length) / 2), y: 0 };
};
const collides = (g: Cell[][], shape: number[][], px: number, py: number) => {
for (let r = 0; r < shape.length; r++)
for (let c = 0; c < shape[r].length; c++) {
if (!shape[r][c]) continue;
const x = px + c;
const y = py + r;
if (x < 0 || x >= COLS || y >= ROWS) return true;
if (y >= 0 && g[y][x]) return true;
}
return false;
};
const draw = React.useCallback(() => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
const { grid, piece } = game.current;
ctx.fillStyle = '#14110b';
ctx.fillRect(0, 0, COLS * CELL, ROWS * CELL);
const cell = (x: number, y: number, ci: number) => {
ctx.fillStyle = COLORS[ci];
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
};
for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) if (grid[y]?.[x]) cell(x, y, grid[y][x]);
for (let r = 0; r < piece.shape.length; r++)
for (let c = 0; c < piece.shape[r].length; c++)
if (piece.shape[r][c]) cell(piece.x + c, piece.y + r, piece.shape[r][c]);
}, []);
const lock = () => {
const g = game.current.grid;
const p = game.current.piece;
for (let r = 0; r < p.shape.length; r++)
for (let c = 0; c < p.shape[r].length; c++)
if (p.shape[r][c] && p.y + r >= 0) g[p.y + r][p.x + c] = p.shape[r][c];
// clear lines
let cleared = 0;
for (let y = ROWS - 1; y >= 0; y--) {
if (g[y].every((v) => v)) {
g.splice(y, 1);
g.unshift(Array(COLS).fill(0));
cleared++;
y++;
}
}
if (cleared) {
game.current.score += [0, 40, 100, 300, 1200][cleared];
setScore(game.current.score);
}
const np = newPiece();
if (collides(g, np.shape, np.x, np.y)) {
setBest((b) => { const n = Math.max(b, game.current.score); localStorage.setItem(KEY, String(n)); return n; });
setStatus('over');
return;
}
game.current.piece = np;
};
const tick = React.useCallback(() => {
const { grid, piece } = game.current;
if (!collides(grid, piece.shape, piece.x, piece.y + 1)) piece.y++;
else lock();
draw();
}, [draw]);
const start = () => {
game.current = {
grid: Array.from({ length: ROWS }, () => Array(COLS).fill(0)),
piece: newPiece(),
score: 0,
};
setScore(0);
setStatus('playing');
};
React.useEffect(() => {
if (status !== 'playing') return;
draw();
const id = window.setInterval(tick, 520);
return () => window.clearInterval(id);
}, [status, tick, draw]);
React.useEffect(() => {
if (status !== 'playing') return;
const onKey = (e: KeyboardEvent) => {
const { grid, piece } = game.current;
if (e.key === 'ArrowLeft' && !collides(grid, piece.shape, piece.x - 1, piece.y)) piece.x--;
else if (e.key === 'ArrowRight' && !collides(grid, piece.shape, piece.x + 1, piece.y)) piece.x++;
else if (e.key === 'ArrowDown' && !collides(grid, piece.shape, piece.x, piece.y + 1)) piece.y++;
else if (e.key === 'ArrowUp') {
const r = rotate(piece.shape);
if (!collides(grid, r, piece.x, piece.y)) piece.shape = r;
} else if (e.key === ' ') {
while (!collides(grid, piece.shape, piece.x, piece.y + 1)) piece.y++;
lock();
} else return;
e.preventDefault();
draw();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [status, draw]);
return (
<div className="flex flex-col items-center gap-4">
<div className="flex gap-8 font-mono text-sm">
<span>score {score}</span>
<span className="opacity-60">best {best}</span>
</div>
<div className="relative">
<canvas
ref={canvasRef}
width={COLS * CELL}
height={ROWS * CELL}
className="rounded-md border border-foreground/20"
/>
{status !== 'playing' ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-md bg-[var(--paper)]/85">
<div className="text-xl font-bold">{status === 'over' ? 'Game over' : 'Tetris'}</div>
<Win98Button onClick={start} className="h-10 px-6 text-sm">
{status === 'ready' ? 'Start' : 'Play again'}
</Win98Button>
</div>
) : null}
</div>
<span className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-45">
← → move · ↑ rotate · ↓ soft drop · space hard drop
</span>
</div>
);
}