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.
BreakoutGameview on GitHub ↗
Canvas game loop: ball physics with wall/paddle/brick reflection (paddle hit position bends the angle), lives, win/lose, and a pointer-driven paddle. Best score in localStorage.
'use client';
import * as React from 'react';
import { Win98Button } from '@/components/ui/win-98-button';
const W = 480;
const H = 380;
const PADDLE_W = 84;
const PADDLE_H = 12;
const BALL_R = 7;
const COLS = 8;
const ROWS = 5;
const BRICK_H = 18;
const GAP = 6;
const TOP = 40;
const ROW_COLORS = ['#c2453a', '#b8772e', '#c9a23f', '#6f7d4a', '#2f6db0'];
const KEY = 'kaspirius:breakout:best';
export function BreakoutGame() {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [status, setStatus] = React.useState<'ready' | 'playing' | 'over' | 'won'>('ready');
const [score, setScore] = React.useState(0);
const [lives, setLives] = React.useState(3);
const [best, setBest] = React.useState(0);
const state = React.useRef({
px: W / 2 - PADDLE_W / 2,
ball: { x: W / 2, y: H - 60, vx: 3.2, vy: -3.2 },
bricks: [] as { x: number; y: number; alive: boolean; row: number }[],
score: 0,
lives: 3,
});
const initBricks = () => {
const bw = (W - GAP * (COLS + 1)) / COLS;
const bricks = [];
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++)
bricks.push({ x: GAP + c * (bw + GAP), y: TOP + r * (BRICK_H + GAP), alive: true, row: r });
return { bricks, bw };
};
React.useEffect(() => {
setBest(Number(localStorage.getItem(KEY)) || 0);
}, []);
const start = () => {
const { bricks } = initBricks();
state.current = { px: W / 2 - PADDLE_W / 2, ball: { x: W / 2, y: H - 60, vx: 3.2, vy: -3.2 }, bricks, score: 0, lives: 3 };
setScore(0);
setLives(3);
setStatus('playing');
};
React.useEffect(() => {
if (status !== 'playing') return;
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
const bw = (W - GAP * (COLS + 1)) / COLS;
let raf = 0;
const loop = () => {
const s = state.current;
const b = s.ball;
b.x += b.vx;
b.y += b.vy;
if (b.x < BALL_R || b.x > W - BALL_R) b.vx *= -1;
if (b.y < BALL_R) b.vy *= -1;
// paddle
if (b.y > H - 24 - BALL_R && b.y < H - 24 + PADDLE_H && b.x > s.px && b.x < s.px + PADDLE_W && b.vy > 0) {
b.vy *= -1;
b.vx += ((b.x - (s.px + PADDLE_W / 2)) / (PADDLE_W / 2)) * 2;
}
// bricks
for (const br of s.bricks) {
if (!br.alive) continue;
if (b.x > br.x && b.x < br.x + bw && b.y > br.y && b.y < br.y + BRICK_H) {
br.alive = false;
b.vy *= -1;
s.score += 10;
setScore(s.score);
break;
}
}
if (!s.bricks.some((br) => br.alive)) {
setBest((bb) => { const n = Math.max(bb, s.score); localStorage.setItem(KEY, String(n)); return n; });
setStatus('won');
return;
}
// lose
if (b.y > H) {
s.lives -= 1;
setLives(s.lives);
if (s.lives <= 0) {
setBest((bb) => { const n = Math.max(bb, s.score); localStorage.setItem(KEY, String(n)); return n; });
setStatus('over');
return;
}
b.x = W / 2; b.y = H - 60; b.vx = 3.2; b.vy = -3.2;
}
// draw
ctx.fillStyle = '#f3eee1';
ctx.fillRect(0, 0, W, H);
for (const br of s.bricks) {
if (!br.alive) continue;
ctx.fillStyle = ROW_COLORS[br.row];
ctx.fillRect(br.x, br.y, bw, BRICK_H);
}
ctx.fillStyle = '#14110b';
ctx.fillRect(s.px, H - 24, PADDLE_W, PADDLE_H);
ctx.beginPath();
ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [status]);
const movePaddle = (clientX: number) => {
const rect = canvasRef.current!.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * W;
state.current.px = Math.max(0, Math.min(W - PADDLE_W, x - PADDLE_W / 2));
};
return (
<div className="flex flex-col items-center gap-4">
<div className="flex gap-8 font-mono text-sm">
<span>score {score}</span>
<span>lives {lives}</span>
<span className="opacity-60">best {best}</span>
</div>
<div className="relative" style={{ width: 'min(92vw, 480px)' }}>
<canvas
ref={canvasRef}
width={W}
height={H}
onPointerMove={(e) => movePaddle(e.clientX)}
className="w-full max-w-full touch-none rounded-md border border-foreground/20"
style={{ aspectRatio: `${W}/${H}` }}
/>
{status !== 'playing' ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-md bg-[var(--paper)]/80">
<div className="text-xl font-bold">
{status === 'won' ? 'You cleared it!' : status === 'over' ? 'Game over' : 'Breakout'}
</div>
<Win98Button onClick={start} className="h-10 px-6 text-sm">
{status === 'ready' ? 'Start' : 'Play again'}
</Win98Button>
<span className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-50">move the mouse to steer</span>
</div>
) : null}
</div>
</div>
);
}