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:
ToolInputsis generated fromtools.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 againstchangeset.schema.json. Each file operation records the file's base hash; a proposal whose base no longer matches isstaleand 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
Playtestin 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:
- Start with a brief. Ask the person the template's required questions. Keep scope small.
- Pick a template from the catalog and pin it. The game is playable from then on; say so, and give the play command.
- 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.
- Use the vocabulary. When the request can't be expressed with components, rules, state
machines and expressions, file a
KitRequestinstead of approximating. - Look at the previews before proposing presentation changes, and never claim a result the report doesn't show.
- Every feature the person approves gets a
Playtestscenario, so it stays working. - Keep durable preferences in
director/NOTES.md(proposed like any document).
7. Local kits and the vocabulary-gap loop
KitRequestdocuments record gaps: the need, examples, a proposed registration, and which proposal hit the gap.game_summarycounts 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 samekit.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 bundleusesLocalKits, 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_survivaltemplate 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_gameandpreview, gets a proposal id and images, and a human-sideapproveapplies 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 ownPlaytestscenarios.
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 andmark_played(new: checkpoint C2).create_*per kind is rolled up intocreate_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'safteradds four; see §11.) - Checkpoints are facts in the pack. C1 is an applied
brief.jsonandgame.json. C2 is an entry in the brief'scheckpoints, written only bymark_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 aKitRequestmay be filed at any stage. - Edits keep
ReviseInput.id(asLockInputhas it) and gainitemper edit. Revising or varying a template document writes the game's own copy at the same path. varyvaries 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_brieftakes the one template the brief fits and looks each loop verb up in the vocabulary; a verb nothing mentions comes back as abrief.verb_gapwarning.- MCP (
gamewright mcp --pack <dir>,@modelcontextprotocol/sdk1.31, stdio). Read tools carryreadOnlyHint;humanOnlytools carrydestructiveHint. The session ismcp.<client>.<time>unless--sessionnames one. Resources:gamewright://game,gamewright://guide,gamewright://doc/<id>,gamewright://changes/<id>. The playbook is the promptguideand 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_slicemakes one change set of up to 12 documents, each created whole or revised with edits, reusingcreate_document's andrevise'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 inafter. 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_summarygives 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.vocabularymarks each kit and entryenabledfor this game, and a kit it doesn't run carries the onereviseofgame.jsonthat enables it and the kits that come with it.template_cataloggives 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.
afteron 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.jsoncan name it).core/scripts/mcp-probe.mjsdrives 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
afteris gated on the stage its stacked state reaches, so the first playable can be proposed on a pendingcreate_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'smark_played, which no agent change may touch.approveholds the gates again for every agent change, against the pack as approved at that moment (approvalGate, the store'sgate, also ingamewright 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 unlessaftersays otherwise (never on the old one). The old one becomessuperseded(a status of its own, not the person'srejected), withsupersededBy; the new one recordssupersedes;changes listandchanges showkeep 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 achange.replace_dropswarning. - 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
afternames 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 withreplacesnaming 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_summarylists pending proposals in approval order (each after what it builds on), which a moved proposal needs. - Budget.
replaceson the strict propose tools costs one optional parameter each, and would have taken the MCP set to 26 of 24.create_gamewent 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. Foldingreplacesintoafterwould 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.jsonis 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 startsNot 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.mjsdrives a brief replacement, a slice on a pendingcreate_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.
previewtakeschangeIdand 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: truedraws 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 severalhoursare drawn, shrunk to preview's 1920-pixel sheet. A proposal that no longer waits (decided, superseded, stale) is refused (preview.not_pending), andbeforewithout a proposal (preview.before_needs_change). MCP now also sends a report'sbeforeAfterpairs 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_slicetakes (its entries, andafter) 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 aspropose, but no change file, no saved bytes, noonChange), 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 likeplaytestwith the report's digest and the field-level diff, saysTried, not proposed, and records nothing for the person to decide. The playbook: experiment withtry, propose what you mean to keep.#buildused to save the bytes it named as it went; now only#recorddoes, 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 tellsbrain.chasewhen a brain's behaviour turns to hunt or attack with a body to go after, andbrain.chase_overwhen 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'schallengesums 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 toMETRICS. A proposal's (and a try's) scenarios carry the base's numbers asbeforeChallenge, and the digest gives one line per scenario that had a fight, a chase or damage,before → afterwhere 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
tryof 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.previewwent 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.trytakes 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.
levelonpreview: a Level or PlatformLevel id. The player's new--level IDgoes there before anything else is staged, as a level select screen would: one tick pressinglevel:<id>, which kit.grid and kit.platformer already honoured (the tours'levelandenterverbs press it). Theirlevel:<id>latch says it took; nothing latched isno level called ID. A level is chosen in play, so--levelboots past a title.placefinds the level.core/src/game/levels.tslists 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 nolevel, or not in thelevelgiven, 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.--athad never worked in a platformer. kit.platformer keeps each body's place in its body state and wrote it back overe.posevery 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_atnow 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:
checkscarry itsreadablerow for every hour, and the caption says<id> doesn't read: …when it doesn't. That answers "does the crow read at dusk" directly.