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.
SpirographGameview on GitHub ↗
Plots the hypotrochoid parametric equations to canvas; sliders drive R/r/d and a gcd determines how many turns to draw so the curve closes cleanly.
'use client';
import * as React from 'react';
import { Win98Button } from '@/components/ui/win-98-button';
const ACCENTS = ['#c2453a', '#6f7d4a', '#2f6db0', '#b8772e', '#b65a86', '#4c8c7a', '#9b5cc0'];
const SIZE = 500;
function gcd(a: number, b: number): number {
return b ? gcd(b, a % b) : a;
}
export function SpirographGame() {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [R, setR] = React.useState(120);
const [r, setRr] = React.useState(47);
const [d, setD] = React.useState(70);
const [color, setColor] = React.useState(ACCENTS[0]);
React.useEffect(() => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, SIZE, SIZE);
ctx.fillStyle = '#f3eee1';
ctx.fillRect(0, 0, SIZE, SIZE);
ctx.save();
ctx.translate(SIZE / 2, SIZE / 2);
ctx.strokeStyle = color;
ctx.lineWidth = 1.1;
ctx.globalAlpha = 0.85;
ctx.beginPath();
const turns = r / gcd(R, r); // full loops until the curve closes
const steps = Math.ceil(turns * 360);
const k = (R - r) / r;
for (let i = 0; i <= steps; i++) {
const t = (i / 360) * Math.PI * 2;
const x = (R - r) * Math.cos(t) + d * Math.cos(k * t);
const y = (R - r) * Math.sin(t) - d * Math.sin(k * t);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
}, [R, r, d, color]);
const randomize = () => {
setR(80 + Math.floor(Math.random() * 80));
setRr(20 + Math.floor(Math.random() * 70));
setD(20 + Math.floor(Math.random() * 90));
setColor(ACCENTS[Math.floor(Math.random() * ACCENTS.length)]);
};
return (
<div className="flex w-full max-w-[520px] flex-col items-center gap-5">
<canvas ref={canvasRef} width={SIZE} height={SIZE} className="w-full max-w-full rounded-md border border-foreground/15" />
<div className="grid w-full max-w-sm grid-cols-[2rem_1fr] items-center gap-x-3 gap-y-2 font-mono text-xs">
<Slider label="R" value={R} min={40} max={180} onChange={setR} />
<Slider label="r" value={r} min={10} max={120} onChange={setRr} />
<Slider label="d" value={d} min={5} max={130} onChange={setD} />
</div>
<Win98Button onClick={randomize} className="h-10 min-w-32 px-7 text-sm">
Randomize
</Win98Button>
</div>
);
}
function Slider({
label, value, min, max, onChange,
}: { label: string; value: number; min: number; max: number; onChange: (n: number) => void }) {
return (
<>
<span className="opacity-60">{label}</span>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-[var(--accent-red)]"
/>
</>
);
}