Docs Design

Contents

M2 design: the Game Director, from Claude Code

Status: draft for building, 2026-09-30. Read with ARCHITECTURE §6–§8, ADR-0003, ADR-0005 and ADR-0008, research/ai-godot-tooling.md (recommendations 1–10), and Reelwright's docs/design/m3-showrunner.md, whose registry and change-store design this adapts.

M2 makes Gamewright something you direct:

"A night-coast survival game where broken machines hunt you."

In one Claude Code conversation that becomes a Game Pack. A brief is proposed and approved, a template is pinned, then systems, entities, world and look arrive as proposals. Each proposal carries a diff, previews and a verification report. The person approves, tweaks, locks or undoes any of it. Then "make the sentry faster and give it a second attack" becomes one more proposal, with a before and after report. No API key is needed: Claude Code is the agent, and Gamewright supplies the tools, the checks and the memory.

1. Layers

Claude Code / any MCP client        gamewright CLI           web studio (M8)
        │ MCP (stdio)                    │ in-process          │ HTTP (later)
        └────────────────┬──────────────┴──────────────────────┘
                   ToolRegistry  (one registry, every surface)
                         │
   Pack I/O · ChangeStore · Graph · Vocabulary · Verify ladder · Previews · Session log
                         │
            Game Pack on disk (+ git)          engine jobs (sim, shot, tour) via heavy.sh slots

There's no Director agent loop in M2. Over MCP the client is the agent. The in-app agent (Anthropic SDK, record and replay fixtures, budgets) comes with the web studio in M8, on the same registry.

2. The tool registry (core/src/director/registry.ts)

A tool is a name, a kind (read, propose, run, decide), a description written as a prompt, an input schema (a $def in schemas/v1/tools.schema.json) and a pure handler over (pack state, input) → result. It's Reelwright's registry, with these changes:

  • ToolInputs is generated from tools.schema.json, not written by hand.
  • Strict only for small inputs. Ids, id-addressed edits, generation options and decisions are strict: true. Tools that author whole documents are non-strict: the full document schema goes with them, and validation plus the repair loop catch mistakes. A test checks that every tool set fits Claude's budget (20 strict tools, 24 optional parameters, 16 unions per request).
  • Tool sets per stage. The registry exposes the tools a pipeline stage needs (§4). Over MCP all read, run and decide tools are always listed, and propose tools are grouped so the client isn't flooded (godot-ai's lesson: a few core tools, the rest in domain rollups).
  • Every call carries a session id, recorded with the proposals it makes.

3. Tools

Kind Tool Input Returns
read game_summary — brief, template, stage, gates passed and pending, counts per kind, open proposals, locks, the next recommended step
read get_document / list_documents id / kind filter canonical document(s) with their hashes
read vocabulary optional kit or kind what the engine can do: components, actions, events, functions, passes, builders and tour verbs with their field schemas and prompt descriptions, generated from kit registrations
read template_catalog — templates with "use when / not for", required questions, knobs and acceptance
read impact / status id / — what re-bakes or re-verifies if this changes; what's stale
propose propose_brief pitch, pillars, core loop, scope caps, template candidates a proposal adding brief.json; the gate maps every loop verb to vocabulary or flags a gap
propose create_game template id + version, game id and name, seed a proposal adding game.json pinned to the template; the game is playable from here on
propose create_document a whole document (non-strict) a proposal, after schema, semantics and analysis; diagnostics come back for repair
propose propose_slice title, entries of {create: document} or {revise: id + edits} (at most 12, non-strict) one proposal for one idea that spans documents, checked as one pack
propose revise {doc, edits: [{item?, pointer, value}]} (strict) a proposal with a field-level diff
propose vary doc, pointers, count, seed N proposals in one variation group; approving one rejects the rest
propose restyle style id, or style edits a proposal plus before and after frames
propose file_kit_request need, examples, proposed registration a KitRequest document; the gap is logged
run validate — L0 report
run bake — bundle path and hash
run playtest rungs (L0–L3), seeds, scenarios the ladder report as JSON, with trace metrics and failing frames
run preview what: world (overview), place (name + hour), model (contact sheet), screen images, returned as MCP image content, downscaled
run play_command — the exact command a person runs to play it (tools/play.sh --bundle …)
decide approve, reject, lock, unlock, undo, redo proposal id or doc + pointers the applied change and the git commit

Decide tools belong to the human. Over MCP they carry destructiveHint, and their descriptions say to call them only when the person explicitly asks in this conversation. In the CLI they are gamewright changes approve|reject|undo|redo. A proposal can't approve itself, and the Director can't mark its own work passed: reports come from validators and the engine.

4. The pipeline, enforced by gates

The stages of ARCHITECTURE §7. game_summary reports the current stage and which gates are open, so the client always knows the next step. A stage's propose tools refuse to run while an earlier checkpoint is unapproved: no entities or world before the brief and template are approved (C1), and no presentation work before the first playable has been played (C2).

Stage Gate that must pass before the next
Brief approved by the person (C1)
Template the game bakes and L1 passes on the template defaults
Systems, entities, world L0 (refs, Expr types, obtainability), L2 bots on the template acceptance, N seeds generate with witnesses and objectives are reachable; the person plays the first playable (C2)
Presentation L3 frame checks; the person approves the look (C3)
Tuning knobs within declared ranges; bot metrics on target

Revised after the pilot (docs/demos/m2-pilot.md): an action-survival first playable needs a place (the shells to gather and where they lie are world content), so WorldGen, Biome and Level open at checkpoint C1 with the systems; only presentation (theme, style, models, sounds, screens) waits for C2. A proposal may carry a whole slice (several documents validated together as one change set) and may build on pending proposals it names, so a first playable doesn't take one approval round per dependency.

5. The change store (core/src/changes/)

Adapted from Reelwright's m3/studio store (unmerged there; the design, not the code, is the source):

  • Proposals live in <pack>/.gamewright/changes/<chg.id>.json, validated against changeset.schema.json. Each file operation records the file's base hash; a proposal whose base no longer matches is stale and never applies.
  • Approve validates the whole resulting pack (schema, semantics, analysis) before writing anything, writes atomically (temp files, then rename, rollback on failure), and commits to git with the proposal's title and rationale and a Gamewright-Change: <id> trailer, as the author the pack repository is configured with.
  • Undo proposes and applies the inverse; history is never rewritten. Redo re-applies an undone change when its bases still match. Replaced bytes are kept by hash so undo restores them exactly.
  • Locks are change sets too, addressed by item id and pointer. A proposal that would modify a locked part is refused at proposal time, naming the lock.
  • Every proposal carries a verification report. L0 always. L1 and L2 when it touches simulation documents. Every Playtest in the pack re-runs, and a proposal that turns an existing scenario red says so at the top of its report (the answer to "fix one, break another"). A proposal that touches only the look also runs L1 and L2, and every state hash must equal the pack's before it (M6 §1, docs/design/m6-presentation.md).

6. The Director's playbook

gamewright mcp serves a prompt (gamewright://guide) and CLAUDE.md-style notes for the client:

  1. Start with a brief. Ask the person the template's required questions. Keep scope small.
  2. Pick a template from the catalog and pin it. The game is playable from then on; say so, and give the play command.
  3. Build in thin slices. Each proposal is small, and each carries its report. Read the report, repair until it's green, and only then ask the person to decide.
  4. Use the vocabulary. When the request can't be expressed with components, rules, state machines and expressions, file a KitRequest instead of approximating.
  5. Look at the previews before proposing presentation changes, and never claim a result the report doesn't show.
  6. Every feature the person approves gets a Playtest scenario, so it stays working.
  7. Keep durable preferences in director/NOTES.md (proposed like any document).

7. Local kits and the vocabulary-gap loop

  • KitRequest documents record gaps: the need, examples, a proposed registration, and which proposal hit the gap. game_summary counts them; the counts across games are the engine's roadmap signal (ADR-0003).
  • In the owner's workspace, a coding agent can implement a request as a local kit in the pack's kits/ folder: the same kit.json + GDScript interface as an engine kit. The player loads local kits only when started with an explicit trust flag in a desktop or dev build. Bake marks the bundle usesLocalKits, and the web platform refuses such bundles (ADR-0003 rule 5).
  • Promoting a local kit to an engine kit is an ordinary reviewed change to engine/.

8. Tests

  • Registry: every input schema passes the structured-output lint; every tool set fits the strict budget; handlers are pure (same state and input give the same proposal bytes).
  • Change store: atomic apply with rollback; stale detection; undo and redo round-trip to identical bytes; variation groups; locks refused with the lock named; git commits carry the trailer and the right author.
  • A scripted direction test with no model: a fixed sequence of tool calls (brief, create_game, documents, revise, playtest, approve) builds a small game from the action_survival template and bakes it, and the engine plays it. This proves the tools compose before any conversation.
  • MCP: an in-process client lists tools, calls create_game and preview, gets a proposal id and images, and a human-side approve applies it.
  • The M2 acceptance run is a real Claude Code conversation (ROADMAP M2 "Watch it"), recorded as a transcript in docs/demos/, whose resulting pack passes its own Playtest scenarios.

9. Tracks

Track Scope
D1 tool registry (port and generalize Reelwright's), tools.schema.json inputs, the change store, locks, git commits, the CLI changes subcommand
D2 read and propose tools, the vocabulary digest, template_catalog, stage gates
D3 run tools: playtest over the ladder (L0–L3) with trace metrics, preview images, acceptance scenarios re-run on proposals
D4 gamewright mcp (stdio, @modelcontextprotocol/sdk), the playbook prompt, the scripted direction test, and the recorded Claude Code acceptance run

10. As built: D2 and D4

What the read, propose and MCP tracks built, where it differs from or adds to the sections above.

  • Tools (core/src/director/). Read: game_summary, get_document, list_documents, document_schema (new: one kind's full schema, its file suffix, stage and an example), vocabulary, template_catalog, impact, status. Propose: propose_brief, create_game, create_document, revise, vary, restyle, file_kit_request. Decide: D1's six and mark_played (new: checkpoint C2). create_* per kind is rolled up into create_document.
  • Strict budget. Ids, edits, generation options and decisions are strict; tools with no input and whole-document tools (propose_brief, create_document, file_kit_request) are not. The read, propose and decide tools cost 16 strict tools, 11 optional parameters and 0 unions, leaving 4, 13 and 16 for the run tools. A test checks the whole MCP set. (D5's after adds four; see §11.)
  • Checkpoints are facts in the pack. C1 is an applied brief.json and game.json. C2 is an entry in the brief's checkpoints, written only by mark_played; no proposal may change it. Each document kind belongs to a stage (stages.ts, which a test keeps complete); systems and the world wait for C1, the look for C2, and a KitRequest may be filed at any stage.
  • Edits keep ReviseInput.id (as LockInput has it) and gain item per edit. Revising or varying a template document writes the game's own copy at the same path.
  • vary varies numbers (stratified around the current value, within the field's and a knob's range), enums, booleans and hex colours, seeded by name path.
  • propose_brief takes the one template the brief fits and looks each loop verb up in the vocabulary; a verb nothing mentions comes back as a brief.verb_gap warning.
  • MCP (gamewright mcp --pack <dir>, @modelcontextprotocol/sdk 1.31, stdio). Read tools carry readOnlyHint; humanOnly tools carry destructiveHint. The session is mcp.<client>.<time> unless --session names one. Resources: gamewright://game, gamewright://guide, gamewright://doc/<id>, gamewright://changes/<id>. The playbook is the prompt guide and the server's instructions. Whole documents are listed by reference (every kind's schema is ~100 KB); the registry still checks them against the full union. Claude Code config: docs/director/.mcp.json.

11. As built: D5, after the pilot

What the pilot (docs/demos/m2-pilot.md) asked for before the acceptance run.

  • Slices. propose_slice makes one change set of up to 12 documents, each created whole or revised with edits, reusing create_document's and revise's checks; every entry's kind must be open, each document appears once, and problems point at /entries/<i>/…. More than 12 is refused (slice.too_big): the next idea goes in its own slice.
  • Stacked proposals. Every propose tool takes after: pending proposals it builds on. The store reads the pack with them applied in order, each after its own bases (ChangeStore.read( after)), so the new proposal's base hashes are their files and it applies exactly once they land. The change set records the whole chain in after. Approving it first is refused (change.waits_on_base, naming the bases in the order to approve them); a base rejected, undone or gone stale makes it stale (change.base_gone), and every listing says why (ChangeStore.standing). Its verification runs on the pack with its bases and compares its Playtests with that pack, so its report is about what it adds. Stage gates still read only what the person approved: a pending brief or game never opens a stage (D6 changed this: a stacked proposal is gated on its stack, §12). Decide tools stay the person's; nothing approves a chain.
  • Read answers. game_summary gives the kits the game runs, the kinds each open stage allows and what each closed stage waits for, and every pending proposal, oldest first, with what it waits for (the person, its bases, or nothing because it is stale) and its report's headline. vocabulary marks each kit and entry enabled for this game, and a kit it doesn't run carries the one revise of game.json that enables it and the kits that come with it. template_catalog gives each template's kits with what they require, and the documents it provides by kind. A proposal's answer carries its report's digest, not the whole report.
  • Budget. after on the four strict propose tools brings them to 15 optional parameters; with the run tools the MCP set is 18 strict tools, 22 optional parameters and 0 unions.
  • Rounds. The scripted direction test reaches a first playable with a Playtest in three approval rounds: the brief, the game, then one slice of eight documents and a Playtest stacked on it, approved in one sitting. The same documents one per proposal, proposing everything that can be proposed each round, take five on a copy of the pack (the kit before the keeper that needs it, the keeper before game.json can name it). core/scripts/mcp-probe.mjs drives a slice and a stacked proposal through the real MCP client and the real engine.

12. As built: D6, after the acceptance run

What the recorded acceptance run (a real Claude Code Director) found, and what changed.

  • Stage gates read the stack. A proposal stacked with after is gated on the stage its stacked state reaches, so the first playable can be proposed on a pending create_game (or a game on a pending brief), and it sees the template's documents to revise them. It is safe because nothing in the stack lands out of order (change.waits_on_base), C1 is the applied brief and game, and C2 is written only by the person's mark_played, which no agent change may touch. approve holds the gates again for every agent change, against the pack as approved at that moment (approvalGate, the store's gate, also in gamewright changes approve): an agent change can't land before its stage, even after the person undoes C2, and can't change the brief's checkpoints, even from a change file made by hand.
  • Replacing a pending proposal. Every propose tool takes replaces: the agent's own proposal that still waits for the person. The new one takes its place, built on what the old one was built on unless after says otherwise (never on the old one). The old one becomes superseded (a status of its own, not the person's rejected), with supersededBy; the new one records supersedes; changes list and changes show keep both, linked. Replacing one option of a variation group supersedes the whole group: the group is one question to the person. Applied, rejected, undone and already-replaced proposals, and the person's own changes, can't be replaced (change.not_pending, change.not_yours). It decides nothing: it is not a decide tool. A replacement that leaves out a document the old one wrote carries a change.replace_drops warning.
  • What was stacked on a replaced proposal. Each pending proposal built on it moves onto the replacement when it still fits there: every file it writes is what it was made against, and the whole game validates with it on top. Then its after names the replacement, its old report (made on the old base) is dropped and run again, and its history says it moved. One that doesn't fit goes stale, saying why (… which was replaced by …, and … leaves <file> differently, or the first error it would cause), and the agent proposes it again with replaces naming it. Options are a choice the person hasn't made, so nothing moves onto a group. Moving rather than always going stale saves the agent a round of re-proposing what the fix didn't touch, and the person a reject; refusing the replacement while anything is stacked on it would push the agent back to stacking fixes. game_summary lists pending proposals in approval order (each after what it builds on), which a moved proposal needs.
  • Budget. replaces on the strict propose tools costs one optional parameter each, and would have taken the MCP set to 26 of 24. create_game went loose instead (its input is a template pin, an id, a name and a seed; the registry still validates it before the handler runs), so the set is 17 strict tools, 24 optional parameters and 0 unions: within the limits, with none to spare. Folding replaces into after would have meant one list whose ids mean opposite things (build on this, or take its place), so it stays its own field.
  • Not a game yet. A pack with a brief and no game.json is the template stage, not a red report: L0 checks the brief (its own problems stay red), the engine rungs have nothing to run (none), and the headline starts Not a game yet.
  • Rounds and decisions. The scripted direction test now fixes a slip in the brief and in the first slice with replaces, and builds the slice on the pending game: two rounds and four approvals to the first playable with its Playtest. The same game made the way the acceptance run had to (a second brief and the first rejected, the game in a round of its own, a fix stacked on the slice) takes three rounds and six decisions; one document per proposal takes five rounds and eleven. core/scripts/mcp-probe.mjs drives a brief replacement, a slice on a pending create_game (on an empty pack), and a slice replaced under a stacked revise through the real MCP client and the real engine.

13. As built: D7, the rest of the acceptance run's frictions

Frictions 6, 9 and 10 of the acceptance run (docs/demos/m2-acceptance.md): the Director couldn't look at its own pending look, couldn't show that "harder" was harder, and could only run an experiment by proposing it.

  • Preview of a pending proposal. preview takes changeId and draws the game as that proposal would leave it, on the proposals it builds on: a copy of the pack with them applied (materialize, as its verification makes one) under .gamewright/previews/changes/<id>/, baked and drawn by the same player run. before: true draws the game before it too (the pack as approved, or its bases applied) and returns one image: before on the left, after on the right, or before above after when several hours are drawn, shrunk to preview's 1920-pixel sheet. A proposal that no longer waits (decided, superseded, stale) is refused (preview.not_pending), and before without a proposal (preview.before_needs_change). MCP now also sends a report's beforeAfter pairs as images: they were listed in the answer but never sent, so the Director could see only the failing frames.
  • try. A run tool that takes what propose_slice takes (its entries, and after) plus the rungs, scenarios and seeds to run. It plans the entries with the slice's code (stage gates, ids, paths, edits), checks the draft with the store's own checks (ChangeStore.trial: locks, base hashes and the whole game validating, exactly as propose, but no change file, no saved bytes, no onChange), and verifies it with the proposal verifier, compared with the pack it builds on; the runs go under .gamewright/verify/tries/<id>/ and the report under .gamewright/verify/reports/. It answers like playtest with the report's digest and the field-level diff, says Tried, not proposed, and records nothing for the person to decide. The playbook: experiment with try, propose what you mean to keep. #build used to save the bytes it named as it went; now only #record does, so a refused proposal saves none either.
  • How hard it was. Bots that play well win against a harder foe too, so the report now says what a fight or a chase cost the player (core/src/verify/challenge.ts). Fights come from kit.melee's existing events, with no new one: a fight with a foe runs from its first tell, swing or whiff (or the first blow between them) to its kill (won), the player's downing (lost) or the end of the run (open), with damage and blows each way; a hazard's hit is damage but no one's blow. Chases needed the engine: how close a chaser came is a distance every tick, which no event carried. kit.brain_fsm now tells brain.chase when a brain's behaviour turns to hunt or attack with a body to go after, and brain.chase_over when it turns to anything else or the chaser is killed, with how long it lasted, the closest it came, whether it came within its strike range (caught; false is an escape) and how long the body kept out of it (keptAwaySec). The chase is measured each tick in the brain's own state, so it is replayable and survives saves and the explorer's loads; the events are generic (any game's brains, any body), and rules can react to them too (an escape is worth a line). A run's challenge sums them, each number the worst for the player over several fights or chases: damage_taken, blows_landed, blows_taken, time_to_kill_sec (the longest kill), chases, escapes, closest_chase_m, kept_away_sec, all Playtest metric ids with ranges. Counts are 0 when nothing happened; a time to kill or a distance is unmeasured (and a range that needs it fails) when nothing could say. Every metric id is now listed in the Playtest schema, where the Director reads them, and a test keeps that list equal to METRICS. A proposal's (and a try's) scenarios carry the base's numbers as beforeChallenge, and the digest gives one line per scenario that had a fight, a chase or damage, before → after where a number moved.
  • Measured on Tideline (the real engine, seed 7). Tactics: damage taken 0, blows landed 5 and taken 0, the fight won in 10.633 s, one chase, closest 1.024 m, caught after 1.2 s. Trading: damage taken 6 from 3 bites, 0 blows landed (all rang off the plate), lost, closest 0.94 m, caught after 0.533 s. A try of a sentry that runs 5.6 m/s instead of 4.6 and bites for 4 instead of 2, on both: tactics still takes nothing and kills in the same 10.633 s, but is caught after 0.983 s instead of 1.2; trading takes 6 → 8 in 2 bites instead of 3, and is caught after 0.467 s instead of 0.533. The store was the same before and after.
  • Budget. preview's two fields would have taken the strict tools to 26 optional parameters of 24, with none to spare after D6. preview went loose instead (the registry still validates its input, and it is small): the MCP set is 16 strict tools, 21 optional parameters and 0 unions, so three optional parameters and four tools are free again. try takes whole documents, so it is loose too and costs nothing. The 29 tools list in about 65 KB; the listing test's guard went from 64 KB to 72 KB, still far below the ~100 KB a whole document's schema would add, which is what it guards against.

14. As built: previews of any level (2026-10-01)

A Director making a platformer of three PlatformLevels and a grid puzzle of five Levels could preview only what was near the game's start. It moved the fox's start in a throwaway proposal to frame level 3's crows, and couldn't see a crow in level 3's dusk light at all.

  • level on preview: a Level or PlatformLevel id. The player's new --level ID goes there before anything else is staged, as a level select screen would: one tick pressing level:<id>, which kit.grid and kit.platformer already honoured (the tours' level and enter verbs press it). Their level:<id> latch says it took; nothing latched is no level called ID. A level is chosen in play, so --level boots past a title.
  • place finds the level. core/src/game/levels.ts lists a game's levels in play order with the things each spawns (a generated level's as its bake laid them). A place named by a thing (caves/door.23.2) or an archetype (arch.crawler: its first thing there) goes to the level that has it. A name in several levels with no level, or not in the level given, is refused before anything renders, naming the levels that have it (preview.which_level, preview.not_in_level); an unknown level lists them in play order (preview.unknown_level). Other names go to the player as before, and when it finds nothing the answer suggests a level's things (preview.unknown_place). A model is shown on its first thing in a level too.
  • --at had never worked in a platformer. kit.platformer keeps each body's place in its body state and wrote it back over e.pos every tick, so a staged hero was on the level's start again before the first shot: the root of the Director's moved start. GwSystem.staged_at now tells every system where the host put an entity; kit.platformer seats the hero in the nearest column at that height where its box meets no solid tile, spike or thing, and makes it the checkpoint.
  • Whether it reads. A thing preview stands beside is its shots' subject, measured as L3 measures one: checks carry its readable row for every hour, and the caption says <id> doesn't read: … when it doesn't. That answers "does the crow read at dusk" directly.

From docs/design/m2-director.md in the Gamewright repository.