Marks M4-c done (the Main coordinator wires new game -> creation -> shell, the shell consumes the real created character, the saga fourth-path seam is kept addable) and flips M4 -> done / M5 -> next in the milestone table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
28 KiB
Roadmap
Source of truth for scope is CLAUDE.md. This tracks sequencing.
The game — building the tactical CRPG (charter §17)
The POC's one question — does bounded AI dialogue feel alive? — is answered. Bounded NPC dialogue, validated moves, and the DM loop are built and live-proven (M0–M2). We are now building the game the mockups describe (§16,
/mockups): a tactical CRPG across 11 screens.
The engine is done and proven. What's left is the game on top of it — recreated screen by screen against the mockups' shared visual system, on the canon log / DM–NPC pipeline / bounded-moves / Luck / character-creation model we already built.
Reconcile as we build (charter §17)
- Damage determinism — §7/§10 (deterministic) vs the Combat HUD mock's damage ranges → combat brainstorm (M5).
- Class names — §8 names vs the mock's callings (same archetypes) → character-creation brainstorm (M4).
Guarded direction — the seven-campaign saga (spec)
One character, seven campaigns (one per demon king), one saga — then it ends. The character, companions, deeds and level carry between campaigns; the world does not. Difficulty is chosen once, at world-construction time, from the campaign index (tier 1..7) — never during play.
The spec adds no milestone and no content. It exists because M5 and M9 are about to be built blank and would foreclose the shape. What it constrains is marked ⛨ saga on the milestones below. World generation itself stays v2 — the spec only guarantees the Saga is the object that will feed it.
Out of scope (never / later)
Multiplayer (never) · mobile (later) · AI-generated story skeletons (v2) · credits/payments/auth (retrofit onto the proxy, §4).
Sequencing
Status: ✅ done · ▶ next · ○ planned. Each item is its own spec → plan → implementation cycle. Each carries its §2 side (state = code owns it · text = AI owns it · both = it is two systems), what it depends on, and the milestone goal it advances.
M0–M2 built the engine (the contract, the client, the server loop, bounded dialogue) and answered the aliveness question. M3+ build the game — the CRPG screens/systems of the mockups, sequenced toward a playable vertical slice first, then breadth:
| Milestone | Goal it advances |
|---|---|
| M0 — Foundations ✅ | The contract and the client-side engine exist and are trustworthy. |
| M1 — Prove the server loop ✅ | A real model sits behind /dm/narrate and the pipeline is reusable. |
| M2 — Prove aliveness ✅ | Does bounded AI dialogue feel alive? — answered; bounded NPC dialogue + DM loop live-proven. |
| M3 — Visual foundation & shell ✅ | The Theme + the exploration HUD; every screen inherits the look and lives in the shell. |
| M4 — Character creation ✅ | The player enters the world; the proven stat/Luck model gets its UI. |
| M5 — Tactical combat (gridless) ◀ here | Real stakes: the Combat HUD turn manager. |
| M6 — Dialogue screen | The proven NPC free-text loop inside the mock's conversation UI. |
| M7 — Inventory & character sheet | State the player can see and manage. |
| M8 — World systems | World-map travel, a shop economy, the quest log. |
| M9 — Framing, AI-flavor & persistence | Title/pause, save/load, and the Improviser/Banter/Luck-drift roles + fallback sweep. |
Landed out of band
Not every item is a milestone. These shipped on their own because a later milestone would have been more expensive to build on top of the old shape.
- ✅ Currency — copper with real denominations (spec).
1g = 100s = 10,000c, stored entirely in copper as oneGameState.purse_copperint, formatted only at the display edge (◈ 3s 47c).coin.json→copper/silver/gold. Money is not an inventory item:give_item/accept_itemcarry no quantity andgifts_givenis a global one-shot, so a lonecopperid would have been givable once, for one copper, per campaign — the denominations are move tokens instead (give_item(gold)= +10,000c), routed to the purse and never intoinventory, so money cannot surface in an inventory grid by construction. Taken before M7 whilecontent/world/items/held two files. Prices remain M8's content BOM — nothing is priced yet. §2: state · goal: money that means something. - ✅ A move applies at most once per reply — found by the review of the above.
TagExtractorreturns every tag occurrence andMoveValidatoris a membership test, so a model repeating a tag N times picked an amount in unary (§2 breach: the AI choosing a number). Now deduped by (name, id) across a reply, andadjust_disposition'sMAX_DELTAcaps the reply's net swing rather than each tag — it was per-tag, so three+15s moved standing by 45, the exact "wholesale swing" the constant forbids. §2: state · goal: the bounded vocabulary stays bounded.
M0 — Foundations ✅
- ✅ Canon log + origin JSON-Schema contract — api validation, unified 422 envelope (Plan A). §2: state · foundation.
- ✅ Client canon log engine — typed GDScript model (invariants in the model), new-game construction (origin + world + creation → canon log), turn-to-turn maintenance mutators, pure
[FACT]/[MOVE]/[ADJUST_DISPOSITION]tag extractor (Plan B). §2: state · depends on the contract.
M1 — Prove the server loop ✅
- ✅ Narrator / Ollama pipeline (server) — the shared server-side model-call pipeline (Ollama httpx client, role→model routing config, prompt loader + canon-log digest renderer, §10 call logging, single-retry → typed 502) wired through
/dm/narrate. Non-streaming. Both phases landed: (1) pipeline foundation (config/routing/prompts/ollama_client/call_log), (2) Narrator online (authorednarrator.mdbody +narrateservice + gated live smoke). Server returns raw prose; the client extracts tags. The other four roles become thin drop-ins on this pipeline. Live wire proven against qwen3.5; 49 tests. Merged todev(ddc00d4). §2: text · depends on the contract.
M2 — Prove aliveness ✅ (the POC's one question — build complete + live-proven; the feel is confirmed in playtest)
- ✅ Client HTTP loop — client posts its canon log to
/dm/narrate, displays the prose, harvests[FACT]via itsTagExtractorinto its own log, and shows the degraded-DM authored fallback (§13) on any non-200. First end-to-end vertical slice: state → HTTP → model → prose → screen → fact-harvest. PureDmServicecore behind an injectable transport seam; throwaway harness scene for the first on-screen prose. Live-proven against qwen3.5 through Docker (grounded scene prose, no numbers per §7). 67/67 client tests; merged todev(b949cd2). Surfaced + fixed two Docker-dev gaps in M1 (prompts not packaged, no container→host-Ollama route) — M1's live proof had been venv-on-host only. §2: state (client owns the loop, consumes text) · goal: put the Narrator on screen. - ✅ Bounded NPC conversation —
/npc/speakwith server-owned persona +knowledge[](spoilers stay off the client) and client-computedavailable_moves[]; the client validates each emitted move against live state, applies the valid, silently drops the invalid, and always keeps the prose (§6). Server role is a thin drop-in on the M1 pipeline (npc.runmirrorsnarrate.py); the client added a pureMoveValidator(membership is the whole test) + aMoveApplierthat writes town-NPC disposition toGameState.npc_dispositions(not the party log), plusNpcContent.available_movesas the single legality home. Free text goes straight to the NPC prompt (no Adjudicator needed — the moves are the NPC's). Live-proven against qwen3.5: Fenn answered in-voice, grounded strictly in his authored knowledge, no Luck leak (§7), withreveal/offer_questmoves landing. The live smoke also caught two real tag-format drifts a unit test can't — paren-less zero-arg moves and a droppedMOVE:prefix leaking into prose — both fixed by makingTagExtractortolerant/scrub-safe (backward-compatible; narrator path unaffected). Built onfeature/npc-conversation; awaiting the human's--no-ffmerge todev(§18). §2: both (text = the NPC's voice · state = the validated moves) · goal: answer the one question. - ✅ Considering-state + transport timeout — while a model call is in flight the client rotates authored in-character "the Master considers…" lines (§13 spirit — even loading is content;
ConsideringPhrases+ConsideringIndicator, 3.0s cadence) instead of a frozen surface, andHttpTransportsets a request timeout (35s, rejects ≤0) so a hung call degrades to the authored fallback instead of spinning forever. Meets §14's anti-freeze goal by reframing the wait, on the proven non-streaming loop, with no new failure surface. Human-confirmed live including the degrade path (API down → fallback). Merged todev(099714d); 110/110 client tests. §2: text/UI · goal: the wait is framed (in-character) and bounded (never hangs). Streaming (§14)— SHELVED for the POC. Real token streaming would genuinely hide the ~2s call, but it is the POC's largest transport rework (clientHTTPRequest→ poll-basedHTTPClient) and adds mid-stream error/tag-boundary failure surface not worth the risk. The considering-state above reframes the wait instead and is tested and live. Design kept as a record (specs/2026-07-10-narrator-streaming-design.md, banner SHELVED); revive only if playtest proves the wait genuinely hurts. Conscious deviation from §14's "stream everything," recorded per §18.
Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (§16); each .dc.html's Component class is the behavior spec. Sequenced toward a playable vertical slice (shell → creation → combat → dialogue) first, then breadth.
M3 — Visual foundation & shell ✅
- ✅ Shared
Theme— palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a GodotThemeresource. Every screen pulls from it; build it first so the look is one system. Merged todev. §2: n/a (presentation) · depends on nothing · goal: one consistent visual identity. - ✅ Main Window shell (2a) — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven
/dm/narrateloop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party/Spellbook toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. Established the editor-first UI-scene convention (ADR 0001; how-to inclient/docs/README.md) and added Button-baseDockButton/ParchmentButton+ a baseRichTextLabelprose style to the theme. §2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen. - ✅ Title screen — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). Built and committed (
a77bf03,f071392); this entry was stale — the roadmap had not caught up and M4-c would have been misled into thinking it still had to build one. ⛨ saga: a fourth entry path (begin the next campaign of an existing saga) lands later — don't build it, just don't hardcode the flow asnew game → creation → worldso it can't be added. §2: n/a · depends on the Theme · goal: first impression, into the world.
M4 — Character creation
Content it consumes → BOM: calling blurbs + the Bloodsworn's patron choice (fixed); origins (variable). ~4 pieces.
- ✅ Creation model (M4-a) — the rules a character is made of, no UI (spec). Race/calling/skill mechanics are static tables in code (
client/scripts/rules/); their blurbs are hand-written content (content/world/{callings,races}/, loaded byContentDB, parity-tested), so the content BOM does not block the engine.CharacterSheetstores only what was rolled or chosen (attributes, race, calling, skills, current hp/mp) and derives the rest (max_hp,ac,save,skill_bonus,spell_dc) — so no derived number can drift from its inputs, and M5's level curve is a function change rather than a stored-field migration.NewGame.constructcarries a seed, not a stat block: it rebuilds the RNG and rolls the attributes itself, so it never trusts a number handed to it (§2), and the same seed yields an identical character, hidden Luck included (§10) — pinned by a golden-vector test. The roll is 3d6 with a hard floor of 8 (straight 3d6 puts ~9% on a primary the +3 pool cannot rescue; a character bad at the one thing he is for is the "fine" §7 forbids). LCK is unspendable, not merely hidden. Open seams marked honestly:ac()is10 + DEXuntil armor exists (M7),max_mp()is aTUNABLEplaceholder (M5 owns the curve), L1 talents are id stubs (M5). Live-proven end-to-end (2026-07-12): a beastfolk cutpurse built by the pipeline → canon log → schema validation →Player: Vexcca, a beastfolk cutpursein the digest (no numeric Luck, no stats, no snake_case id) → real narration from qwen3.5. 250 client tests, 76 api (live layer included). §2: state · goal: the character exists in code. - ✅ Contract migration — the two contract migrations this milestone owed have landed: the canon log's
playerblock is now{name, race_id, calling_id, luck_descriptor}(the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is nowallowed_callings(content/origins/deserter.jsonlists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree. - ✅ Character creation UI (M4-b) — the mock's screen over M4-a's model: four race cards and seven nameplate calling cards (blurb + mechanics shown only for the chosen calling), the +3 additive spend pool with
−flooring at the rolled base, proficiency chips gated by the chosen calling's pool (plus the Human bonus-skill row), five ability cards — never six, and the word "luck" appears nowhere on the screen (§7) — and the DM origin panel composing race + calling fragments. Five mock/model reconciles settled in the spec's §4 (skill picking added, seven callings not five, blurb-on-chosen-only,+/−both present, the portrait‹ ›arrows cut for lacking art). Architecture: a pure, node-freeCreationDraft(RefCounted) holds every rule — changing calling clears stale skill picks, changing race drops a now-granted pick, re-roll clears the spend and only the spend,errors()delegates toNewGame.validaterather than keeping a second copy of the rules;CreationCopyis a pure static formatter that derives every display string (hit die, armour word, saves, skill count) from theRaces/Callings/Skillstables, so retuning a calling retunes its card without touching a string literal; the.tscnowns the layout per ADR 0001 andcharacter_creation.gdonly binds. The §2 property holds without the screen having to be trustworthy: the screen shows its five numbers by calling the same newly-publicNewGame.roll_attributes(seed)thatconstructcalls internally — the screen never hands a number to anything, only a seed and four choices;constructrolls the identical attributes again, itself, off the same seed. Re-roll is unlimited (the floor of 8 already prevents a dead primary), and every re-roll silently re-rolls the hidden LCK draw riding the same RNG stream — the screen says nothing about this, ever. Emitscreation_confirmed(creation: Dictionary); does not callconstructitself, so a saga can synthesize the same Dictionary later and skip the scene entirely. Test count: 250 → 302. The honest seams: three rounds of adversarial review found ten guard-shaped assertions across this milestone that could not fail against the bug they named (substring collisions, a BBCode wrapper masking an empty ContentDB, a test asserting the script's own loop bound, a trivial origin fixture, assertions already true before the action under test ran, a theme-drift guard checking three hardcoded variations instead of all of them, aLabelsweep that missedRichTextLabel, and tests that called handlers directly instead of pressing nodes) — all rewritten against the actual artifact and reverted-and-reconfirmed-red perdocs/traps.md's standard; the new species are folded into that file. §2: state · depends on the creation model (M0) + Theme · goal: build a character. - ✅ Title → creation → shell flow (M4-c) — the new game path is wired end-to-end (spec). A script-first
Maincoordinator node (scenes/Main.tscn, now themain_scene) owns the Title → Creation → Shell sequence in one file, swapping screens via instantiate → inject → add_child — the injection seamchange_scene_to_filecannot provide, and the reason it, not a linear chain, holds the flow. The Title became emit-only (it announcesmenu_activated; the coordinator routes it — it no longer knows what comes next), and the shell stopped faking a character: newinjected_log/injected_stateseams + aShellState.for_character(state, log)factory mean the created character's HP/MP/purse, location, and opening narration are real (seed()/_build_seed_log()stay as the F6/standalone fallback, exactly likeservice == null). The flow carries only a plaincreationDictionary;NewGame.constructre-rolls the character from its seed itself (§2). ⛨ saga honored structurally:_show_creation()and_start_campaign(creation)are separate methods, so the fourth entry path ("begin the next campaign of an existing saga") is an addedmenu_activatedarm that synthesizes a Dictionary and calls_start_campaigndirectly — no existing edge changes. §13: aconstructthat returns{ok:false}(unreachable via the CTA gate, but the coordinator distrusts its caller anyway) logs and stays on creation — never a crash, never a half-built shell. Built subagent-per-task with a per-task + final whole-branch review; every new guard reverted-and-reconfirmed-red perdocs/traps.md. Test count: 302 → 327. The coordinator was namedMain(notGameFlow) so the boot scene is self-evident;TitleScreenis unchanged and shown byMain. §2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.
M5 — Tactical combat (gridless)
Content it consumes → BOM: abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, tier 1 (variable per tier). ~15 pieces.
- ○ Combat turn manager + HUD — the Combat HUD
Componentstate machine as GDScript: initiative order, the action economy (Action/Bonus/Move), ability resolution, click-to-target, victory/defeat; tokens on the iso board as flavor only, no movement grid (§10). Seeded per encounter (§10). Reconcile damage determinism here (§7/§10 vs the mock's ranges). Combat flavor is async optional Narrator calls (reuses M1). ⛨ saga — the level curve is no longer open: design it to a ceiling of L20 across 7 tiers (tier N ≈ levels 3N−2 … 3N; exact bands tunable, the ceiling is not). Enemy stat blocks carrytier: 1..7; there is no scaling multiplier, and there never will be — a tier-6 world has different monsters (proximity to a king is the power scale), not a rat with 40× HP. Class abilities are planned to L20. The M5 content BOM (1 enemy family + boss) is tier 1. §2: state (flavor text a thin AI layer) · depends on the pipeline for flavor · goal: real stakes.
M6 — Dialogue screen
Content it consumes → BOM: 6 NPCs — persona + knowledge[] + gated topics (§6), variable. ~6 pieces.
- ○ Dialogue UI + reshaped Adjudicator — wrap the proven NPC free-text loop (
/npc/speak, M2) in the mock's conversation screen: NPC portrait, live disposition meter, the NPC's spoken line + DM narration beat, and the hybrid input (choice chips with skill/approach tags plus the free-text line, §15). The Adjudicator (/dm/adjudicate, strict JSON §12, one-retry) lands here as the interpreter for the free-text "describe your own action" escape hatch — most turns use buttons. §2: both (text = NPC voice · state = validated moves/actions) · depends on the NPC role (M2) · goal: conversation inside the game's UI.
M7 — Inventory & character sheet
Content it consumes → BOM: 6 weapons, 4 armour, 3 consumables, 1 cursed item (+STR / −LCK — §7 says day-one). All fixed. ~14 pieces. Prerequisite: the build tool gains item + quest namespaces (and tier: 1..7).
- ○ Inventory & equipment — the paper-doll (12 slots) + Satchel grid, category tabs, item detail, encumbrance, derived stats; item seed-data from the mock's
items()map asResources. §2: state · depends on the Theme + item model · goal: gear the player manages. - ○ Character sheet — attributes (modifier-forward, LCK still hidden), saving throws, the "in a fight" derived grid, skills, talents, afflictions/conditions, the DM's-notes bio. §2: state (display) · depends on the stat model · goal: the character, legible.
M8 — World systems
Content it consumes → BOM: the price table in copper (fixed); 3 map locations + the road, shop stock, the quest chain (variable). ~8 pieces. The currency value type already landed (see Landed out of band, above) — everything is stored in copper and formatted at the display edge, so the Shop economy inherits a real purse. What M8 still owes is the numbers: nothing is priced yet.
- ○ World-map travel — region map, location markers (settlement/dungeon/fog), roads + planned route, danger ratings, travel time, set-out. §2: state · depends on the shell · goal: move between places.
- ○ Shop economy — buy/sell with a fence: purses, stock/owned, the ~40%-sell loop, gritty disabled-CTA reasons, merchant reaction lines. §2: state (merchant reaction = a thin authored/AI layer) · depends on inventory · goal: an economy loop.
- ○ Quest log — active/completed/failed tabs, objectives checklists, rewards, tracked quest; reads the canon log's
active_quests(§11). §2: state · depends on the quest model · goal: track the story.
M9 — Framing, AI-flavor & persistence
Content it consumes → BOM: authored fallback per AI surface (§13); shrine + Luck-drift events; humiliation seeds. All fixed. ~10 pieces.
- ○ Save / load + pause / settings — the pause overlay + data-driven settings; save/load over the canon log + game state (seeds preserved, §10). ⛨ saga — the save is two objects, not one: a durable
Saga(sheet, level, companions + approval, humiliations, deeds,kings_slain,campaigns_completed, signature item) and a per-campaignCampaign(CanonLog+GameState+ world + seed). Humiliations move to theSagaand are projected into the canon log — today they live in the log and would die with the campaign. This is the §10 seed argument applied to persistence: cheap now, a rewrite once saves exist in the wild. §2: state · depends on the systems existing · goal: persistence + framing. - ○ Luck fully wired — generation exists (Plan B); this adds drift events (real bed, shrine blessing, killing something praying), one shrine, one cursed item (+STR / −LCK). Visible only in prose (§7). Gates the Improviser. §2: state · depends on the item + event systems · goal: the signature system, live.
- ○ Improviser —
/dm/improvise: minor off-script events, §7-sandboxed by construction — a validator that only applies whitelisted state changes (dignity, never progress; structurally incapable of ending a quest). Fires on Luck; hooks Cadwyn. §2: both · depends on Luck being wired · goal: bad luck as a story, not a fine. - ○ Banter —
/party/banter: companions comment on recent events; low stakes, high charm, aggressively cacheable; reads Brannoc's humiliation log (§9), stacking not overwriting. Prose only, no tags. §2: text · depends on the humiliation log (M0) + events · goal: the comedy loop closes. - ○ Authored-fallback sweep — every AI-dependent surface has authored fallback content (§13), written as writing; the final pass so no surface can show the player an error. §2: text · depends on every surface existing · goal: a degraded DM still has a voice.
Deferred (later / never)
Replicate provider (prod) · auth · metering · credits/payments (§4) — retrofit onto the pipeline later, the game doesn't notice. AI-generated story skeletons (v2 — the seeded story is authored for now). Streaming (§14) — SHELVED (specs/2026-07-10-narrator-streaming-design.md); the considering-state reframes the wait instead.
NPC & relationship depth (v2)
Three ideas mined from NarrativeEngine-P (MIT) — design borrowed, not code. All three are §2: state (code owns them; the AI only voices from what code decides), so they strengthen the §2 boundary rather than loosen it. They fold into the NPC role (§6) and canon log (§11); sequence them with or after the Dialogue screen (M6), since they enrich the same conversation surface.
- ○ Directional dispositions — split the single per-pair disposition into two directed fields (A→B and B→A), so liking is asymmetric. Unrequited regard, undisclosed betrayal (player still trusts the captain who already sold them out), and the Cadwyn↔Brannoc mutual-but-differently-flavored dislike (§9) all become expressible. Player→NPC gates dialogue tone; NPC→player gates offered moves (§6
available_moves) — two arrows, two levers. Extendsparty[]in the canon log (§11) andnpc_dispositionson the client; keep the −100..100 range, just double the field. §2: state · depends on the disposition model existing (M2, done) · goal: relationships that aren't symmetric. - ○ Witness tracking — every logged event carries a
witnesses: [...]list; a fact enters an NPC'sknowledge[](§6) only if they witnessed it or a witness told them. This is the missing input-side rule for the knowledge list — CLAUDE.md §6 says the list is "the entire content the NPC has to draw on" but never says how a fact gets in. Structurally prevents the blacksmith spoiling a twist he never saw, gives shady acts real "were you seen?" stakes without a stealth system, and turns rumor spread into a code operation (witness meets NPC → code copies the fact, optionally degraded) rather than AI invention. Adds a field torecent_events[]/established_facts[](§11) and a propagation rule on character encounters. §2: state · depends on the canon log event model (M0, done) + knowledge lists (§6) · goal: NPCs know only what they could plausibly know. - ○ Personality Hexagon persona — a richer NPC persona schema: six axes (Drive, Diligence, Boldness, Warmth, Empathy, Composure) supplied to the NPC prompt (§6) as structured voice-shaping input, replacing free-form persona blurbs. Server-owned (like
knowledge[]), so it never ships to the client. Purely additive to the §6 prompt contract — it steers how an NPC speaks, not what it may do (the move vocabulary is unchanged). §2: state (the schema) feeding text (the voice) · depends on the NPC role (M2, done) · goal: consistent, distinct NPC voices from data, not prose.