An interactive 2D American football playbook designer and play simulator. Next.js (App Router) + TypeScript + Tailwind v4, with all field rendering done through vanilla HTML5 Canvas.
npm install
npm run devOpen http://localhost:3000.
| Command | Purpose |
|---|---|
npm run dev |
Dev server |
npm run build |
Production build |
npm run verify |
Headless checks of the simulation engine (50 assertions) |
npm run lint |
ESLint |
- Click a player to select them.
- Drag a player to move them. Their route moves with them.
- With a receiver selected, drag on open field to draw a route. Or use a preset (Slant / Go / Out / Curl), which mirrors correctly per side of the field.
- Press P (or Set Pass Target in the left panel) to arm the targeting tool — it selects the QB for you — then click a route or open field to place the orange pass target. With the QB already selected you can also click a route directly.
- Simulate Play runs it. The QB releases once the target receiver is ~30% through the route; the ball flies a real parabolic arc and whoever is closest to the landing spot makes the play. Shortcuts: Space play/pause · R reset · P set pass target · D draw routes · Esc deselect · Ctrl+Z undo · Ctrl+Y / Ctrl+Shift+Z redo.
Picking this up cold? Read
HANDOFF.mdfirst — architecture, what's verified, what isn't, and the traps.
.env.local already holds the connection settings:
NEXT_PUBLIC_SUPABASE_URL=https://tzoibhhxcbrncxdonrvd.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...The plays table does not exist yet. Until it does, Share Play falls back to
localStorage: the play is saved and the link works, but only in the browser that
created it. Nothing is lost either way.
To enable real sharing, run supabase/schema.sql in the
Supabase dashboard under SQL Editor → New query.
Security note. That schema grants
anonboth select and insert, so the table is world-writable by anyone holding the publishable key — and that key ships in the client bundle, as it is designed to. This is acceptable for disposable play diagrams and nothing else. Before storing anything you care about, add auth and scope the policies toauth.uid().
src/
app/
page.tsx Board
play/[id]/page.tsx Shared play, fetched server-side (params is async in Next 16)
actions.ts 'use server' — sharePlay
components/
PlotBoard.tsx State owner: play, selection, playback, history
FieldCanvas.tsx Canvas, pointer interaction, animation loop
LeftPanel/RightPanel Controls
lib/
field.ts Geometry constants, world<->screen transforms, palette
geometry.ts Quadratic Bezier smoothing, arc-length sampling
formations.ts Personnel, alignments, coverage landmarks
routePresets.ts Route shapes, mirrored per side
simulation.ts Movement, throw physics, defensive AI (DOM-free)
render.ts All canvas drawing
history.ts Undo/redo over snapshots
playSchema.ts Validation at the trust boundary
gif.ts Offscreen replay + gifshot encoding
supabase/ Browser + server clients (@supabase/ssr)
World units are yards. The sim works in a 120 × 53.33 yard space with the origin at the back of the left endzone; rendering scales to pixels. Speeds are real (a receiver sustains ~8.5 yd/s), so the timing of a throw against a route is meaningful rather than tuned by eye.
One curve, two consumers. geometry.ts builds the same quadratic Bezier
chain that render.ts strokes and that simulation.ts samples for movement, so
the route a receiver runs is exactly the one drawn.
The sim is DOM-free and deterministic, which is what lets npm run verify
exercise the physics and AI headlessly, and what makes GIF export reproducible:
export replays the play offscreen at a fixed timestep rather than screen-scraping,
so a slow machine produces the same GIF as a fast one.
GIF weight lands around 500 KB for a typical play (460px, 12fps, 60 frames
max). The knobs are at the top of lib/gif.ts. The turf speckle is deliberately
disabled for export — per-pixel noise is close to worst case for LZW and eats the
256-colour palette, for texture invisible at that size. Leaving it on roughly
quadruples the file.
Defensive tracking latency (250ms) is why a sharp break creates separation; defenders chase where the receiver was.
The field is cached to an offscreen canvas and blitted, rather than re-stroking every hash mark each frame. The animation loop only runs during playback — an idle board holds no animation frame.