Building and testing a three.js game with the harness
Runs, sees, plays, measures and speeds up any three.js, React Three Fiber or WebGL page as you build it - a game, a product viewer or configurator, a simulation, a shader or post-processing demo - in headless Chrome on a paused, seeded clock - what is on screen and what never appears (a model at the wrong scale, out of view or not loaded), frame-exact keyboard, mouse and touch input, the page state as JSON, mesh stats, feel and frame-time measurements. Use it instead of Playwright or Chrome DevTools whenever the user asks to make, fix, test, extend, screenshot or profile a three.js or WebGL page, new or existing, and before saying one is done. For a game with exact rules it can also lock a game contract that is checked after every edit.
The harness runs the game in a headless Chrome on a paused, seeded clock: you send inputs frame by frame and read back the game's state as JSON, so what you see is exact and repeatable. A game contract is optional: keypoints taken from the user's prompt, each a starting state, a few inputs and the values observe() must then return, checked after every edit.
Order of work
- Start from a template for a new game, in
${CLAUDE_PLUGIN_ROOT}/skills/threejs-game/templates/:plain.html(one page, an import map, no build),r3f.tsx(React Three Fiber under vite) orrapier.ts(thewindow.__gamebridge for a Rapier world, with either). Give the game awindow.__gamefollowing the convention below. An existing game works as it is; addwindow.__gamewhen you need its state. - Open the game with
game_open:url: "index.html"for a plain page (the folder is served), orcommand: "bunx vite"for a vite project. Open it once: after each edit the next tool call reloads the page by itself. - Build, playing as you go. After each change, play the inputs that matter with
game_act(andgame_clock), read the state it returns orgame_observe, and look withgame_look(game_actwithlook: truereturns the picture after its steps in the same call;sheetshows several stages, levels or views in one picture).game_evalinspects the scene or tries a change in the page before you write it into the source. - Feel. When the user's request says how the game should look and feel (its feel brief), for each event it names call
game_feelwith the inputs that cause it (set the state up first withgame_act) and read what reacted. Every event the brief says should feel like something must show at least one reaction (a pop, a flash, a shake, particles, a HUD tick, a sound), and motion the brief calls eased or snappy must show eased moves and no teleports. Fix what does not, and rerungame_feel.
Game contract (optional)
Use one when the game has exact rules where one misread breaks it (scoring, collisions, matching, clearing and refilling, turn order, stated numbers), or when the user asks for a locked spec or tests. Otherwise skip it.
- Before writing game code, call
game_contractwith the project folder (cwd) and, asprompt, everything the user wrote about the game, verbatim: the request and any interface, rules or numbers that came with it, since the subagent sees nothing else. It writes a brief and tells you the one-line prompt to start a fresh subagent with: the Agent tool, in the foreground, model sonnet. That subagent drafts and issues the contract, and its reply gives you the keypoints and theobserve()schema. Never read the brief, the draft orgame-contract.jsonyourself: a failinggame_checktells you the state and values a keypoint needs. Makewindow.__gamereturn exactly the schema's keys. game_openthen ends with the first check of the contract, so do not callgame_checkuntil you have changed something.- Call
game_checkafter every change. A hook also checks after each edit, in the background, and tells you only when the failing keypoints change: no message means the result is the same as the last one you saw. What each failure means is inreferences/contract.md. Do the feel step once the contract passes, and rerungame_checkafter its fixes. - Before saying it is done: call
game_checkonce more, so the final result is a full run of the whole contract and nothing is left to a background check that would be lost when the session ends.
Performance
Call game_optimize only when a check or game_perf result says the frame is over budget, or when the user asks about performance (stutter, or a phone target: then measure with game_perf and budget: "mobile" first). How it works, and the runtime import it asks for: references/optimize.md.
Procedural geometry
Build shapes in code, with three's own geometries plus geo.js, which game_open puts in the project: import { geo, scatter } from "./geo.js" ("../geo.js" from src/ in a vite project). It gives booleans, hull, extrude and revolve through Manifold, and a seeded scatter.
After each build step, give the mesh a name and call game_eval with return stats("<name>"): check its triangles, that it is closed, and its volume.
Use game_eval to try a change in the running game before writing it into the source; what it changes vanishes on reload.
Recipes (scatter, arrays, curves, booleans, revolve, hull, level sets, rounding) and pitfalls: references/geometry.md.
The window.__game convention
Once the page has loaded, it exposes:
window.__game = {
describe(), // a short description: name, controls, units
observe(), // the state, exactly the keys of game-contract.json's schema, as plain JSON
act(input), // performs one input object: { key, down? } or { action, ...args }
setState(partial), // sets any of the schema's keys; keys left out keep their values
};
observe, look, perf and optimize work on any three.js page; game_check needs all four functions.
Rules
- The game starts playing as soon as the page loads: no title screen and no click to start.
- Time. The harness steps frames one at a time, 1/60 s each, on a fake clock (
requestAnimationFrame,performance.now,Date, timers). Advance the game by each frame's elapsed time, or a fixed 1/60 s per frame, and never by more than 0.1 s in one frame. Count the first frame as one frame: the clock jumps forward when the page finishes loading, so time read before the first frame is not game time. Speeds are per second of game time. Never read real time some other way. - Randomness. Use
Math.random; the harness seeds it, so a check sees the same game every time. - setState takes effect at once: the next
observe()returns the new values, and the game's own start-up must not undo them, even whensetStateruns before the first frame. Objects and lists are replaced whole, with the same fieldsobserve()returns. - observe() returns plain JSON (numbers, booleans, strings, null, objects, arrays), never three.js objects. Lists keep their order: new items at the end, removed items dropped.
- Keys listen on
windowand matchevent.key. A key can go down and up within one frame, so one-shot controls (turn, jump, fire) act onkeydown; held controls (move, steer) last fromkeydowntokeyup. Route real key events andact({ key })through the same function. - Names. Give meshes a
name(player,enemy3,coin):game_looklists what is on screen by name.
templates/plain.html is a complete small game in this shape. In React, keep the game state in a plain module outside components (as templates/r3f.tsx does), so setState and observe never wait for a render, and set window.__game in the Canvas's onCreated: the harness starts stepping frames once __game exists.
Never
- With a contract, never edit
game-contract.jsonby hand or from a shell: a changed contract is refused. To change what the game must do, rungame_contractagain with the user's new prompt. - Never change a keypoint's expected values by changing the game's
observe()to report something the game does not do.