SKILL.md
Build a 3D Game (multi-mechanic, persistent)
React renders the HUD. The game lives outside React: a headless simulation stepped at fixed timestep, read imperatively by the renderer. Get that split wrong and every mechanic you add makes the game slower and flakier.
Non-negotiables
Ranked by how often violating them breaks builds.
- Game state never lives in React state. A
useStatethat changes every frame or player action invalidates the component that owns the loop, tearing the loop down mid-game. Store gameplay state in an ECS world or a zustand store read imperatively (useStore.getState()) insideuseFrame. React renders the HUD only. - Match SSR strategy to the mount. Full-screen game route: disable SSR on the route. 3D embedded in a server-rendered page: hydration-gated Canvas wrapper. Picking the wrong one is the top white-screen cause.
- Fixed-timestep simulation, interpolated render. Clamp raw delta (
Math.min(raw, 0.05)), accumulate, step at a fixed dt. Frame-rate-dependent gameplay is a bug, not a tuning issue. - Damping is exponential:
v *= Math.exp(-k * dt). Neverv *= 0.9. - Instance everything repeated. Grass, rocks, trees: one
InstancedMeshper species, never N meshes. Set a draw-call budget and treat it as a hard ceiling. - Persist through a server function, debounced. Never write to the database from the frame loop. Saves are event-driven (mechanic completed, area changed, manual save) or throttled to 5s minimum.
- Screenshot before claiming anything works. A compiling black screen is a failed build.
Memoization gate
useMemo / memo / useCallback are disallowed in game code by default. Before proposing one, answer all three precisely:
- Which exact piece of state changes?
- Which computation or object identity does that change invalidate?
- Which invariant makes that invalidation structurally required?
Any vague answer means the real defect is state-graph partitioning, not caching. "Reduces re-renders" is not an argument: in a correctly built game the frame loop reads state imperatively, React does not re-render on gameplay ticks, and there is nothing to memoize. The one legitimate case is an expensive pure derivation that changes rarely (terrain heightfield from a seed), and even then prefer a ref or lazy initializer outside the render path.
Workflow
- Clarify sparingly, two questions max: art direction and the core mechanic. Decide everything else. Never default to neon/synthwave.
- Lay down structure first: a
src/game/tree separating systems (pure simulation) from views (R3F components that read simulation state). Structure first prevents the rewrite. - World before player, player before mechanics, mechanics before persistence. Each stage ends with a screenshot.
- One mechanic at a time, shipped whole: its systems, its view, its HUD, its inventory or journal entry. Half-built mechanics compound.
- Persistence last. Do not enable cloud storage before the loop plays locally. Every table ships with grants, RLS, and an owner-scoped policy in the same migration.
- Perf pass: draw calls under budget, reduced-motion and touch fallbacks in place.
Good vs bad
Bad: const [health, setHealth] = useState(100) in the component that owns the Canvas, updated on every hit. Every hit re-renders the scene root.
Good: health lives in the zustand store; combat system writes it; useFrame reads useStore.getState().health for gameplay; the HUD component subscribes with a selector and re-renders alone.
Verification
Run the game and capture a screenshot of the lit, playable scene. Expect visible world, player, and HUD, plus a clean console (no errors, no R3F warnings). If the screen is black or the console is dirty, the build has failed regardless of compilation: fix before proceeding.
Then throttle the tab (CPU 4x). Expect movement speed and physics outcomes to be unchanged, only frame rate drops. If gameplay speed changes, the loop is not fixed-timestep: clamp, accumulate, and step at fixed dt.
Completion checklist
- [ ] No gameplay value in
useState; no memoization that fails the gate - [ ] Loop fixed-step with clamped delta; all damping exponential
- [ ] Route SSR strategy matches the mount type
- [ ] Repeated props instanced; draw calls under budget
- [ ] Every mechanic has HUD feedback and a persisted record
- [ ] Every table has GRANT plus RLS plus owner-scoped policy in the same migration
- [ ] Screenshot shows the lit, playable scene; console clean
- [ ] Title screen replaces any template placeholder; every route has its own head metadata
Any box unchecked: not done. Fix or say so.
Footguns
- *`v = 0.9
damping.** Frame-rate dependent, feels different on every device. Fix:v = Math.exp(-k dt)`. - Saving from the frame loop. Hammers the database, races with itself. Fix: event-driven saves through a debounced server function.
- N meshes for N trees. Draw calls explode past 100 instances. Fix: one
InstancedMeshper species. - Enabling persistence before the loop is fun locally. You end up migrating schemas for mechanics that get cut. Fix: persistence is the last stage.
Red flags
Stop if you catch yourself saying:
- "I'll just use useState for now and refactor later"
- "useMemo here can't hurt"
- "It compiles, so the scene works"
- "I'll batch these three mechanics in one pass"
- "The save can go straight in useFrame temporarily"
These are the exact rationalizations that produced past broken builds. There is no "temporarily": follow the non-negotiables or say explicitly which one you are breaking and why.