Tetris
Neo-brutalist falling blocks: hard-edged tetrominoes, a ghost preview, and the next three pieces.
experimental
Try it
Tetris
Next
Score
0
Lines
0
Level
1
Best
0
Press Start, or Enter on the board.
How it works
- Arrows move and rotate (Z rotates back), Down drops a row, Space drops to the ghost, and P pauses. On touch, tap to rotate, swipe sideways to move, and swipe down to drop.
- The rules live in src/lib/games/tetris.ts as pure functions: a 7-bag randomizer, wall kicks, classic scoring, and a level every 10 lines.
- Each piece gets a palette color with an ink border and a pixel bevel, so even the pale ones read on the board.
- Your best score is remembered in this browser once you accept the cookie banner.
Source
src/components/demos/TetrisLab.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { ArrowDownToLine, ChevronDown, ChevronLeft, ChevronRight, Pause, Play, RotateCcw, RotateCw } from "lucide-react";
import { Button } from "@streamuilib/react";
import { cn } from "@/lib/cn";
import { cryptoInt } from "@/lib/games/memory";
import { useBestScore } from "@/lib/games/best-score";
import {
COLS,
cells,
createGame,
dropDistance,
gravityMs,
hardDrop,
move,
rotate,
softDrop,
tick,
type PieceType,
type TetrisGame,
} from "@/lib/games/tetris";
/** One fill per piece, from the palette (there's no yellow or cyan here). */
export const PIECE_TONE: Record<PieceType, string> = {
I: "bg-blue",
O: "bg-orange",
T: "bg-violet",
S: "bg-lime",
Z: "bg-pink",
J: "bg-paper-raised",
L: "bg-pink-soft",
};
/** The first board renders the same on server and client; randomness starts on Start. */
const fixed = () => 0;
function Block({ type, ghost }: { type: PieceType | null; ghost?: boolean }) {
if (ghost) return <span className="block size-full border-2 border-dashed border-ink/50" />;
if (!type) return <span className="block size-full" />;
return <span className={cn("block size-full border-2 border-ink pixel-bevel", PIECE_TONE[type])} />;
}
/** A small preview of an upcoming piece, centered in a 4×4 box. */
function Preview({ type, small }: { type: PieceType; small?: boolean }) {
const shape = cells(type, 0);
const xs = shape.map(([x]) => x);
const ys = shape.map(([, y]) => y);
const w = Math.max(...xs) - Math.min(...xs) + 1;
const h = Math.max(...ys) - Math.min(...ys) + 1;
const filled = new Set(shape.map(([x, y]) => `${x - Math.min(...xs)},${y - Math.min(...ys)}`));
return (
<div
className={cn("grid gap-px", small ? "w-8" : "w-12")}
style={{ gridTemplateColumns: `repeat(${w}, 1fr)`, aspectRatio: `${w} / ${h}` }}
>
{Array.from({ length: w * h }, (_, i) => (
<Block key={i} type={filled.has(`${i % w},${Math.floor(i / w)}`) ? type : null} />
))}
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="border-2 border-ink bg-paper-raised px-2 py-1.5">
<p className="font-mono text-xs font-bold tracking-wider text-ink-muted uppercase">{label}</p>
<p className="font-mono text-sm font-bold tabular-nums">{value}</p>
</div>
);
}
export function TetrisLab({ gravity }: { gravity?: number }) {
const [game, setGame] = useState<TetrisGame>(() => createGame(fixed));
const [best, saveBest] = useBestScore("tetris");
const board = useRef<HTMLDivElement>(null);
const swipe = useRef<{ x: number; y: number } | null>(null);
const statusId = useId();
const playing = game.status === "playing";
// Gravity: one row per tick while playing, faster every level.
useEffect(() => {
if (!playing) return;
const id = setInterval(() => setGame((g) => tick(g, cryptoInt)), gravity ?? gravityMs(game.level));
return () => clearInterval(id);
}, [playing, game.level, gravity]);
useEffect(() => {
if (game.status === "over") saveBest(game.score);
}, [game.status, game.score, saveBest]);
// The board as drawn: locked blocks, then the ghost, then the falling piece.
const view = useMemo(() => {
const grid: { type: PieceType | null; ghost?: boolean }[][] = game.board.map((row) => row.map((type) => ({ type })));
const shape = cells(game.piece.type, game.piece.rotation);
if (playing) {
const d = dropDistance(game.board, game.piece);
for (const [cx, cy] of shape) {
const y = game.piece.y + cy + d;
if (y >= 0 && !grid[y][game.piece.x + cx].type) grid[y][game.piece.x + cx] = { type: null, ghost: true };
}
}
if (game.status !== "ready") {
for (const [cx, cy] of shape) {
const y = game.piece.y + cy;
if (y >= 0) grid[y][game.piece.x + cx] = { type: game.piece.type };
}
}
return grid;
}, [game, playing]);
function start() {
setGame((g) => (g.status === "paused" ? { ...g, status: "playing" } : createGame(cryptoInt, "playing")));
board.current?.focus();
}
const pause = () => setGame((g) => (g.status === "playing" ? { ...g, status: "paused" } : g));
const act = (fn: (g: TetrisGame) => TetrisGame) => setGame((g) => fn(g));
function onKeyDown(e: KeyboardEvent) {
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
const actions: Record<string, () => void> = {
ArrowLeft: () => act((g) => move(g, -1)),
ArrowRight: () => act((g) => move(g, 1)),
ArrowDown: () => act((g) => softDrop(g, cryptoInt)),
ArrowUp: () => act((g) => rotate(g, 1)),
x: () => act((g) => rotate(g, 1)),
z: () => act((g) => rotate(g, -1)),
" ": () => (playing ? act((g) => hardDrop(g, cryptoInt)) : start()),
p: () => (playing ? pause() : start()),
Escape: pause,
Enter: () => !playing && start(),
};
const run = actions[key];
if (!run) return;
e.preventDefault();
run();
}
// Touch: tap rotates, a sideways swipe moves a column per 24px, a downward swipe drops.
function onPointerUp(e: PointerEvent) {
const from = swipe.current;
swipe.current = null;
if (!from || !playing) return;
const dx = e.clientX - from.x;
const dy = e.clientY - from.y;
if (Math.max(Math.abs(dx), Math.abs(dy)) < 12) return act((g) => rotate(g, 1));
if (Math.abs(dy) > Math.abs(dx)) {
if (dy > 40) act((g) => hardDrop(g, cryptoInt));
return;
}
const steps = Math.max(1, Math.round(Math.abs(dx) / 24));
act((g) => {
let next = g;
for (let i = 0; i < steps; i++) next = move(next, Math.sign(dx));
return next;
});
}
const message =
game.status === "over"
? `Game over. Score ${game.score}, ${game.lines} lines.`
: game.status === "paused"
? "Paused."
: game.status === "ready"
? "Press Start, or Enter on the board."
: `Score ${game.score}. Lines ${game.lines}. Level ${game.level}.`;
return (
<div className="flex w-full flex-col items-center gap-4">
<div className="flex items-start gap-3 sm:gap-4">
<div
ref={board}
tabIndex={0}
role="group"
aria-label="Tetris board. Arrows to move and rotate, Space to drop, P to pause."
aria-describedby={statusId}
onKeyDown={onKeyDown}
onPointerDown={(e) => (swipe.current = { x: e.clientX, y: e.clientY })}
onPointerUp={onPointerUp}
onPointerCancel={() => (swipe.current = null)}
className="relative grid w-40 touch-none gap-px border-2 border-ink bg-ink-faint shadow-brutal select-none sm:w-48"
// Square cells set the height; a fixed 1:2 box plus the 1px gaps overflowed by a pixel or two.
style={{ gridTemplateColumns: `repeat(${COLS}, 1fr)` }}
>
{view.flat().map((c, i) => (
<span key={i} className="aspect-square bg-paper-sunken">
<Block type={c.type} ghost={c.ghost} />
</span>
))}
{!playing && (
<div className="absolute inset-0 grid place-items-center bg-paper/70">
<div className="flex flex-col items-center gap-3 border-2 border-ink bg-paper-raised px-4 py-3 text-center shadow-brutal-sm">
<p className="font-mono text-xs font-bold uppercase">
{game.status === "over" ? "Game over" : game.status === "paused" ? "Paused" : "Tetris"}
</p>
<Button size="sm" onClick={start}>
{game.status === "over" ? <RotateCcw aria-hidden /> : <Play aria-hidden />}
{game.status === "paused" ? "Resume" : game.status === "ready" ? "Start" : "Play again"}
</Button>
</div>
</div>
)}
</div>
<div className="flex w-20 flex-col gap-2 sm:w-24">
<div className="flex flex-col items-center gap-2 border-2 border-ink bg-paper-raised p-2 shadow-brutal-sm">
<p className="self-start font-mono text-xs font-bold tracking-wider text-ink-muted uppercase">Next</p>
<Preview type={game.queue[0]} />
<div className="flex w-full items-end justify-around gap-1 border-t-2 border-ink pt-2">
<Preview type={game.queue[1]} small />
<Preview type={game.queue[2]} small />
</div>
</div>
<Stat label="Score" value={game.score} />
<Stat label="Lines" value={game.lines} />
<Stat label="Level" value={game.level} />
<Stat label="Best" value={best} />
</div>
</div>
<p id={statusId} aria-live="polite" className="sr-only">
{message}
</p>
{/* Mouse and touch controls; the keyboard works on the focused board. */}
<div role="group" aria-label="Controls" className="flex flex-wrap justify-center gap-1.5">
<Button size="icon" variant="secondary" className="size-10" aria-label="Move left" onClick={() => act((g) => move(g, -1))}>
<ChevronLeft aria-hidden />
</Button>
<Button size="icon" variant="secondary" className="size-10" aria-label="Rotate" onClick={() => act((g) => rotate(g, 1))}>
<RotateCw aria-hidden />
</Button>
<Button size="icon" variant="secondary" className="size-10" aria-label="Move right" onClick={() => act((g) => move(g, 1))}>
<ChevronRight aria-hidden />
</Button>
<Button size="icon" variant="secondary" className="size-10" aria-label="Soft drop" onClick={() => act((g) => softDrop(g, cryptoInt))}>
<ChevronDown aria-hidden />
</Button>
<Button size="icon" variant="pink" className="size-10" aria-label="Hard drop" onClick={() => act((g) => hardDrop(g, cryptoInt))}>
<ArrowDownToLine aria-hidden />
</Button>
<Button
size="icon"
variant={playing ? "secondary" : "primary"}
className="size-10"
aria-label={playing ? "Pause" : "Start"}
onClick={playing ? pause : start}
>
{playing ? <Pause aria-hidden /> : <Play aria-hidden />}
</Button>
</div>
</div>
);
}