Version: 0.3.0
Status: PR1–PR12 implemented; overhaul phases P1–P11 applied; the
engine/story separation (Overhaul II) landed
Last updated: 2026-08-14
What this document is now. It was written when there was one game and the engine was that game. There are four stories on disk today and the engine is a separate thing they run on, so read the sections below in two registers: the mechanics chapters describe the engine, and every path under
games/clockwork-dark/is the flagship's content — one story's answer to a question the engine asks every story. Where a mechanism is only true of a story that declares it, the text says so. See AUTHORING.md for the seam from the story author's side.
Reading this document. Sections below describe systems that exist and run. Where something is designed but not wired, it is marked NOT WIRED with the file it lives in. That convention exists because this document previously described several mechanisms that were never called from any production path, and a design doc that describes a mechanism which does not run is how this codebase got into trouble. See DESIGN_REVIEW.md for the full record of what the overhaul found.
The Clockwork Dark is a local-first, AI-driven roleplaying game set on the frontier edge of a dying world. The player wakes in a forest beside Edgewood, the last comfortable village before the deep woods give way to the Marches and, further in, the Heartlands — where something called the Clockwork Dark is winding itself into the bones of civilization.
Unlike scripted RPGs, every scene is narrated in real time by autonomous local LLM agents. Unlike pure AI chat games, mechanical truth lives in a deterministic engine: dice land where the engine says they land, a choice that moves or spends anything is resolved by the engine before the next word is written, and the world's evil clock advances whether the player becomes a baker or a hero.
The game merges two proven architectures:
- Archives of Anubis — hybrid hard engine + narrative council, RAG lore as source of truth, curse-phase escalation, speculative narrative decoding, ComfyUI/TTS integration.
- CosySim —
AgentGovernor,@skilldecorator tools, SSEStreamProcessortag injection, dual-agent scene pattern (realm),WorldSimbackground ticks, interceptor pipeline.
Design pillars:
| Pillar | Meaning |
|---|---|
| Local-first | LM Studio, ComfyUI/Grok, Voxtral TTS + ASR — no cloud dependency for core play, and every generative service off by default |
| Engine is truth | LLMs narrate; they do not adjudicate mechanics |
| Agents have agency | Storyteller and Assistant choose when to help, hinder, or stay silent |
| Player freedom is real | Quiet life is a valid complete experience; the main plot does not require the player |
| The engine is not the story | A story is a directory. Four ship; none of them is compiled in |
Four stories ship, chosen at launch with launcher.py --game <slug>:
| Slug | What it is | Why it is in the repo |
|---|---|---|
clockwork-dark |
The flagship, described by the rest of this document | The story the engine was built for |
wicked-garden |
A fae-court bargain story, deck-shaped, meters and veils | The deck exemplar, and the second implementation of the UI plugin contract |
neon-city |
NEON CITY: THE CROSSING — a graph-shaped cyberpunk expedition on a 21-day timestamp | Proof the seam holds for a story with no doom clock, no recipes and no decks |
dev-story |
The annotated bench | Every declaration commented, for reading rather than playing |
drowned-carillon was deleted. It was the flagship with different nouns, which
made it a poor proof of anything: it could not fail in a way the flagship would
not.
| Term | Definition |
|---|---|
| EvilTicker | Background system that advances evil_progress and evil_phase on world ticks |
| Awareness | Hidden player stat (0–100) gating rumor quality, cutscenes, and spoiler filters |
| plot_involvement | Engine-tracked measure of how entangled the player is in the main story (0–100) |
| story_pressure | Storyteller-internal meter; rises with plot_involvement; unlocks harder events |
| Storyteller | GM agent (clockwork_storyteller) — narrates world, runs NPCs, tunes difficulty |
| Assistant | Companion agent (clockwork_assistant) — speaks to player; may help or withhold |
| Hard Engine | Deterministic Python game logic — sole authority on stats, dice, inventory, travel |
| Soft Layer | LLM agents, interceptors, media queue — probabilistic presentation |
| Skill | @skill-decorated function — the only way a mechanic is resolved |
| Intent | {action, target} a choice declares; the ENGINE executes it before narrating. Enums built per turn from what the engine will accept (engine/game/intents.py) |
| Tag | Inline stream token ([IMAGE:…], [STAT:…], etc.) parsed from LLM output |
Evil phases (canonical): DORMANT → STIRRING → SPREADING → CONSUMING
Core location IDs: forest_clearing, edgewood_square, edgewood_bakery, tinker_caravan, millhaven_gate
The known world is arranged in concentric rings of civilization and danger:
[Deep Forest] → [Edgewood Village] → [The Marches] → [Heartlands] → [The Wound]
↑ ↑ ↑ ↑
Player start Quiet life arc Whisper arc Convergence arc
Edgewood is a scrappy frontier village: timber frames, a communal oven, a shrine to old saints nobody can name, and a road that traders use twice a season. Beyond the tree line, the forest is generous but not tame — mushrooms, game, herbs, and things that watch without moving.
The Marches are market towns, militia garrisons, toll roads. The Heartlands were once the seat of kingdoms. Now refugees speak of crops rotting in patterns, clocks running backward, and men who walk with tick-tick breath.
| Source | What we take |
|---|---|
| The Name of the Wind | Tinkers, oral lore, sympathy/naming as costly magic, mundane craft as dignity |
| Dragonlance | Mounting evil the world tries to ignore; reluctant involvement; companions with their own agendas |
| Archives of Anubis | Phase-based dread escalation; evaluator quality gate; echo of past runs |
| CosySim realm/tavern | Dual-agent banter; dice economy; living world ticks |
Magic is grounded. There are no fireball buttons. Magic costs Focus, material, and often a price in blood or memory. Naming something correctly matters more than shouting a spell name. The Clockwork Dark is not "a demon" — it is a pattern that converts living order into ticking, hungry mechanism.
The evil is a metastasizing logic: gears in wheat, brass filaments in nerves, militias that march in perfect time until they march into the sea. It does not announce itself in Edgewood. It stains the world inward.
The EvilTicker runs continuously:
| Phase | Progress | World signs (player may miss) | Agent behavior |
|---|---|---|---|
| DORMANT | 0.0–0.2 | Odd dreams, one broken clock | Storyteller: pastoral; Assistant: absent or fleeting |
| STIRRING | 0.2–0.5 | Livestock stillborn with brass teeth; tinkers sell "ward charms" | Traders whisper; Assistant may appear as cat |
| SPREADING | 0.5–0.8 | Refugees, militia drafts, sympathy sickness | Forced world events rise; cutscenes unlock |
| CONSUMING | 0.8–1.0 | Heartlands lost; the pattern hunts names | Both agents intervene strongly |
evil_progress advances every world tick by a base rate modified by player inaction (slow) or proximity to Heartlands (fast). The player is never required to stop it.
Arcs and their gates live in games/clockwork-dark/data/quests/arcs.yaml; the gates are ordinary
quest predicates. Arcs are monotonic — they only ever open, never re-lock,
because the fiction cannot un-happen.
| Arc | Gate (as implemented) | Player experience |
|---|---|---|
| Quiet Life | default | Forest, bakery apprenticeship, festivals; Awareness stays <15 |
| Whisper | caravan_arrival seen AND 10 days since AND Awareness ≥15 |
Maps sold, songs with wrong lyrics, child draws gears |
| March | visited millhaven_gate OR Awareness ≥25 |
Militia, tolls, burned farmstead quests |
| Convergence | Awareness ≥50 OR evil_phase ≥ SPREADING | Name the wound, choose sacrifice, world-scale choices |
The caravan comes for everyone on a schedule that has nothing to do with the player, so its arrival alone cannot be the Whisper gate — that would drag every baker into the arc on a dice roll. What separates the two arcs is not that the trader came, it is that the player listened to him.
24 quests, six per arc (games/clockwork-dark/data/quests/<arc>/*.yaml). Each arc is valid. The
game never punishes the baker for baking.
Caveat, measured. Convergence's
min_phase: spreadingterm is a timer, not a choice: it fires around in-game day 50 for every playstyle including the baker who never leaves the bakery. Arcs only open doors, so nothing is taken away — but "awareness-gated" is only half true. See DESIGN_REVIEW.md R-06.
| ID | Name | Role |
|---|---|---|
npc_maris |
Maris Hearth | Baker; quest hub for domestic arcs |
npc_odran |
Odran Cartwright | Caravan master; brings outward goods, inward rumors |
npc_ilya |
Ilya of the Nine Pins | Tinker; sells knowledge maps, sympathy charms |
npc_sera |
Sergeant Sera Venn | Millhaven militia; moral ambiguity |
npc_brindle |
Brindle | Cat that is sometimes the Assistant |
The Assistant is not a UI tutorial. It is an entity (or entities) that the Storyteller also cannot fully control. In Edgewood folklore they are the Grey Wanderer, the Cat Who Knows, the Tinker’s Shadow. They may:
- Offer a true hint disguised as nonsense
- Withhold help to test the player
- Appear in a form suited to trust level and plot_involvement
- Contradict the Storyteller (rare; high drama)
Agency parameters live in engine state and drift over sessions.
Identity is declared, not compiled.
clockwork_storytellerandclockwork_assistantwere Python literals for the project's whole life. They are now rows in the flagship's owngames/clockwork-dark/agents.yaml, and the engine resolves an agent's id throughengine/agents/roster.py::agent_id_for_role. The literals survive only as the fallback for a story that ships no roster — which no shipped story is, so the fallback path is exercised by tests and by nothing else. They remain canon ids and must not be renamed.A roster says more than a name: which voices an agent owns, what it may read, what it may write, what it may write only with a reason, and which model profile it runs on. That is what lets The Wicked Garden run a world agent holding ten voices beside a character agent holding three, each barred from the other's secrets — a cast no string constant could describe. The plan → negotiate → commit turn that arbitrates between them is
engine/agents/pipeline.py.
Role: Game Master, narrator, NPC chorus, difficulty tuner.
Inherited from:
- Anubis: Director + Proposer draft loop, Evaluator retry, RAG lore injection, curse-phase tone
- CosySim:
realm_directorJSON response contract,AgentGovernor, governance context
Capabilities:
- Describe scenes, voice NPCs, present 2–4 choices
- Request skill checks and combat via required tools (cannot fabricate outcomes)
- Adjust
story_pressure— spawn harder encounters, grant mercy, trigger cutscenes - Query full evil state via
query_evil_state(player never sees raw numbers)
Agency knobs (stored in GameState.storyteller_mind):
intervention_willingness: float # 0-1: likelihood of forcing plot events
cruelty_bias: float # 0-1: harsher consequences on failure
reward_generosity: float # 0-1: loot, rep, mercy
patience: float # 0-100: low → more aggressive world eventsOutput contract. A JSON Schema, not a prose instruction — built per turn by
engine/lmstudio/schemas.py::storyteller_turn_schema and sent as
response_format: {"type": "json_schema"}.
{
"narration": "Second-person prose, 220–1400 chars…",
"choices": [
{"id": "a", "text": "Follow the smoke toward Edgewood", "hint": "safe",
"intent": {"action": "travel", "target": "edgewood_square"}},
{"id": "b", "text": "Ask her what she meant by that"}
],
"npc_voices": [{"npc_id": "npc_maris", "line": "…"}],
"ledger_delta": {"facts": [], "names": {}, "npc_disposition": {}, "promises": []}
}npc_id is an enum built per turn from the NPCs actually present, so voicing
someone who is not in the room is unsampleable. minItems: 2 on choices makes
the zero-choice soft-lock unreachable. minLength/maxLength state the length
rubric the evaluator was scoring against silently.
intent is how a narration turn changes the world, and it is the same trick
as npc_id applied to actions. engine/game/intents.py::legal_intents builds a
catalogue every turn from what the engine will actually accept in this exact
state — the roads that leave this location plus any hidden path foraging has
opened, the configured rest kinds, the food actually in the pack, the story's
declared skills, what a vendor standing here will sell and can be afforded, the
quest flags the current stage allows. The schema turns that into one branch per
verb, discriminated by a const action, so an unreachable destination is
unsamplable rather than merely wrong. Branching matters: a flat action enum
beside a flat target enum would make {"action": "travel", "target": "persuasion"} legal grammar.
A choice with no mechanical consequence declares no intent — pure conversation
stays pure — and intent is never required. A story the engine can honour
nothing for gets no intent property at all, so its grammar and its payload
are byte-for-byte what they were.
The order of a turn is what makes "never invent an outcome" achievable:
- The player picks a choice.
resolve_player_intentreads the intent off it. run_turnexecutes it before anything plans or narrates, through the ordinary skills (GameEngine.move_to,checks.resolve,survival.rest, …), so every write still funnels througheffects.apply_effectandclock.advance_time.- The receipt goes into the MECHANICAL RESULTS — AUTHORITATIVE block of the narrator's first prompt. The model is told the outcome and asked to render it.
Legality is re-checked at execution against the live state, because an intent is written on turn N and run on turn N+1 and the world moves in between. A refusal is engine-authored and reaches the prose: it is rendered as an explicit "this did NOT happen, and here is why", never as silence. The one sentence a player must never read is that they walked somewhere they did not.
Gone from the contract, deliberately: stat_changes, items_gained,
items_lost, and skill_check with its dc_mod. The model used to be asked for
all four and the engine threw all four away, so the only thing they did was
create an incentive to try. The parser still tolerates the old keys on input
(engine/agents/storyteller.py::parse_storyteller_response) so a model trained
on the old prompt cannot crash a turn — but nothing reads them.
tool_calls is not in the contract and never was reachable. The schema sets
additionalProperties: False and declares no such property, so with the grammar
on a tool call cannot be sampled; nothing sent a tool manifest either. For most
of the project's life this was the only documented way for a turn to change
anything, which meant travel, dice, rest, food and trade were dead in real play —
a player chose "Follow the smoke toward Edgewood", the model narrated the walk,
and the save still read forest_clearing with the stamina untouched.
scripts/simulate.py never noticed because it calls engine methods directly, and
every mock LLM in the suite emitted a tool_calls key no real model could send.
The dispatcher survives (execute_tool_calls) because the intent path and the
negotiation pipeline share its receipt shape; the channel a player's choice
travels is intent.
structured_output: auto (config) probes the server once and caches the answer;
small quantized models often ignore schemas entirely, so the brace-counting
JSON scanner stays in place as the fallback.
Quality gate: Lightweight Evaluator (Anubis pattern) scores tone, lore fit,
length, choice count, and — weighted heaviest — mechanical claims made without a
matching tool receipt. If score < 0.6, one retry with feedback. The retry rolls
back through engine/game/transaction.py::StateTransaction first: a draft the
player never saw must not keep its side effects.
Role: Player-facing companion; voice/text channel; optional STT listener.
Reframed from CosySim realm_assistant — no fourth-wall. The Assistant exists in-world as ambiguous folklore made real.
Capabilities:
- Short replies (1–3 sentences) via speech bubble UI
grant_hint,reveal_lore,change_formas optional skills- Push-to-talk STT → Assistant processes intent, may relay to Storyteller
- Emit
[VOICE:whisper]/[VOICE:urgent]for TTS styling
Agency knobs (GameState.assistant_mind):
trust_level: float # 0-100: rises with player kindness, honesty
help_probability: float # 0-1: roll each request for help
current_form: str # cat | wanderer | child | tinker | reflection
appearance_schedule: str # hidden | rare | common | desperateInformation asymmetry: Assistant never receives full evil_progress. It receives hint_tier derived from trust and plot_involvement — preserves mystery.
sequenceDiagram
participant P as Player
participant E as Hard Engine
participant S as Storyteller
participant A as Assistant
participant M as Media Queue
P->>E: choice (id + its declared intent)
E->>E: execute the intent — move_to / checks.resolve / rest / trade
E->>S: GameState snapshot + MECHANICAL RESULTS receipts
S-->>S: narrate the outcome it was handed
S->>M: tags IMAGE VOICE CUTSCENE
S-->>P: narration + choices (SSE stream)
E->>A: optional context (trust tier)
A-->>P: companion line (may be empty)
Both agents share the CosySim interceptor pipeline but use different model profiles and skill manifests.
1. SESSION START
└─ ProcGen seed → forest_clearing → character creation (archetype, name)
2. TURN LOOP
├─ Player selects choice / free text / voice
├─ Engine: validate action, apply movement/craft/trade preconditions
├─ Storyteller turn:
│ ├─ PRE interceptors inject lore, evil tone, state
│ ├─ LLM streams narration (speculative draft → refine)
│ ├─ REQUIRED tools resolve checks/combat
│ ├─ POST interceptors: TTS, ComfyUI queue, stat tags
│ └─ Evaluator quality gate
├─ Assistant turn (probability roll, drawn from the seeded ASSISTANT stream):
│ └─ May speak, change form, grant hint — or stay silent
├─ Quest evaluation (every turn, idempotent, engine-only predicates):
│ └─ observe → unlock arcs → start eligible → advance/fail/complete
├─ Ledger: record the turn, meet whoever is present, decay facts,
│ expire promises, summarize anything evicted from the buffer
└─ Autosave + append transcript
The world tick (EvilTicker + schedules) runs at the TOP of the turn, and
only when world.tick_interval_seconds of real time has passed. Everything
else moves the clock through engine/game/clock.py::advance_time as a cost
of the action that spent the hours.
3. MILESTONE EVENTS
└─ evil phase shift, Awareness threshold, location first-visit
→ CUTSCENE tag → ComfyUI video → letterbox UI + captioned TTS
All mechanics live in engine/game/. They are reached only through the
@skill registry — and in real play only through a choice's declared intent
(engine/game/intents.py), which the engine executes before narration. See
The mechanic lives in the choice, above.
| Stat | Range | Notes |
|---|---|---|
| HP | 0–max | Death threshold only — see Death, below. Wounds carry the weight |
| Stamina | 0–cap | Travel and labour spend it; only rest restores it |
| Focus | 0–max | Sympathy, naming, craft precision |
| Reputation | per-faction | Edgewood, merchants, militia |
| Craft | 0–100 | Baking, herbalism, tinker repair |
| Awareness | 0–100 | Hidden until ≥20 (then "something feels wrong") |
| Grit / Agility / Wits / Presence | 3–18 | Core attributes; skill checks derive modifiers from these |
| Hunger | 0–100 | 2/hour. peckish 40, hungry 60 (−20 stamina cap), starving 85 (−1 hp/hour, −2 to checks) |
Stamina has an effective cap below max_stamina while hungry. A player at
100/100 whose real ceiling is 80 has no way to read that off the raw numbers, so
hunger_stage and stamina_cap are both in the client payload.
Awake stamina regeneration is zero, on purpose, and the knob is in
games/clockwork-dark/data/rules/survival.yaml so the decision is visible in data rather than implied
by an absent line of code. Rest is the only thing in the game that raises
stamina, and it costs hours — which is what the evil clock eats. Stopping to
sleep is the most expensive safe thing you can do, and it has to stay that way.
Rest never refuses. A bed you cannot reach downgrades to sleeping rough (a survival check, worse outcome on a failure). Any location gate on rest rebuilds the soft-lock; see DESIGN_REVIEW.md issue R-04.
- Standard:
d20 + itemised modifiers vs DC - The model never supplies a DC. It names a difficulty band; the engine
derives the number from
games/clockwork-dark/data/rules/skills.yaml:trivial 8 · easy 10 · standard 13 · hard 16 · severe 19 · legendary 22 - Skill taxonomy (seven):
persuasion,stealth,sympathy,lore,craft,survival,nerve - Modifiers are itemised in
CheckResult.modifiersand the deltas sum to the number applied to the die: stat modifier, archetype affinity, situational rows (darkness, exhaustion, starvation, evil phase), open wounds, timed effects. The receipt shows the whole arithmetic, which is what makes an engine-resolved failure feel fair rather than arbitrary. - Four degrees by margin:
crit_success(+10),success(0),partial(−4),failure. Partial is not success. - Natural 20 draws a boon, natural 1 a complication, both from
games/clockwork-dark/data/tables/*.yaml— never LLM invention. Advantage reads the kept die, so it genuinely buys a second chance at the boon.
There is no initiative order, no armour class and no per-round turn loop —
those would contradict the "no fireballs, no MMO combat spam" pillar and turn
every roadside argument into a five-minute minigame. A conflict is a scene:
one to three contested checks against a threat's resolve. The player picks an
approach (talk, fight, sneak, pay, walk away) and the engine resolves it
through checks.py like everything else. flee is merged into every encounter
by games/clockwork-dark/data/encounters/rules.yaml, because a scene with no legal exit is a
soft-lock.
Approach availability is engine-authored: anything the player cannot pay for, cannot reach at this hour, or lacks the item for is simply absent from the list, so the narrator is never in a position to offer a choice the engine will refuse.
Consequences are wounds, not hit points: "a knife-line across your forearm, −2 to craft until day 9" survives into the next scene and reads back into narration for free.
Death is a setback, not a game over. You wake in Edgewood Square ten hours
later at 35% hp, half your purse gone, carrying a −2 wound that takes five days
to close — and the evil kept its own hours the whole time you were down.
state.ended is set in exactly one case: a second death while the world is
already consuming, when there is no longer anyone left to come out and fetch
you.
Recipes exist in games/clockwork-dark/data/recipes/*.yaml and items in games/clockwork-dark/data/items/*.yaml. Buying
and selling run through the trade skill against games/clockwork-dark/data/economy.yaml.
craft_item(recipe_id) is a real skill
(engine/skills/builtin/mechanics.py, tests/test_craft.py): refusals —
unknown recipe, wrong station, missing tool, short of inputs — come before
anything is spent; the hours and inputs are then consumed on the attempt, pass
or fail; the outcome is resolved by checks.resolve on the recipe's declared
band with degrees (a crit adds one to the batch, a partial wastes some, a
failure falls back to the recipe's declared salvage). list_recipes tells the
narrator what could be attempted here and what it needs.
{id: str, name: str, qty: int, tags: list[str]}Stored as InventoryItem in GameState.inventory.
- Each location has supply/demand tables
trade(action, item_id, npc_id)— prices from engine, not narration- Caravan events inject rare goods and rumor packets
Location graph (not grid). Each edge has travel_time_hours, danger_dc, awareness_delta.
forest_clearing ──1h── edgewood_square ──4h── millhaven_gate
│ ├── edgewood_bakery (0h)
│ └── tinker_caravan (0h, event-gated)
Each edge carries hours, danger_dc, awareness_delta. Locations carry evil_multiplier (forest 0.5, bakery 0.7, Edgewood 0.8, caravan 0.9, Millhaven 1.2).
move_to(location_id) validates stamina, spends it, advances the clock through
clock.advance_time, applies awareness_delta, and rolls the encounter table
off the edge's danger_dc. Measured encounter rate on the one dangerous edge
in the graph (edgewood_square ↔ millhaven_gate, danger_dc: 12): 0.42–0.89
per leg depending on hour and evil phase (scripts/simulate.py). Every other
edge is danger_dc: 0 and can never produce one.
An unresolved scene is not overwritten by walking away from it. Travel always completes; the road does not get to hand you a second problem while the first is open.
Awareness rises from:
| Source | Delta |
|---|---|
| Hear rumor (verified) | +3 to +8 |
| Witness anomaly event | +5 |
| Travel inward | +2 per ring |
| Assistant hint (true) | +2 |
| Ignore 3 consecutive anomaly hooks | -1 (denial) |
Gates:
- Rumor quality: low Awareness → vague unease; high → names, places, dates
- Cutscenes:
[CUTSCENE:…]only if Awareness ≥ threshold - Assistant forms:
reflectionform locked until Awareness ≥40
One writer. advance_time(state, hours) is the only function permitted to
move world time. world_day, world_hour and time_of_day are derived
read-only properties of a single float, world_clock_hours.
This is the most consequential correction in the project's history. Time used to
live in two independent ints advanced by state.world_day += int(days_elapsed),
and the only production caller passed 0.25. int(0.25) == 0, so the calendar
never moved and the evil ticker was multiplied by an elapsed time of zero for
the entire life of the codebase. Every system downstream of the clock — evil
progression, hunger, timed-effect expiry, wound healing, NPC schedules, quest
deadlines — was dead in the same way, and no balance constant in this document
had ever been observed against a clock that runs.
Everything that consumes elapsed time hangs off advance_time, so it cannot be
bypassed by a caller who forgets a step. Death handling re-enters the clock
(unconsciousness costs hours), which is why there is a thread-local re-entrancy
guard: unguarded, each nested call ran the death check again and the calendar
ran away — measured jumping day 2 to day 123 in a single 8-hour step.
The doom clock is a capability, not a given (
engine/game/evil_ticker.py).doom_enabled()asks the ACTIVE MANIFEST, and a story has one only if it declarespaths.doom_effectsor a nonzeroworld.evil_base_rate_per_day. The flagship declares both; NEON CITY declares neither and pins the phase atdormant, because its pressure is heat, debt, the weather and a date on a file, and a background apocalypse ticking under that would be a second unannounced antagonist. The state fields stay onGameState(zeroed, so the save shape is untouched); what stops is the tick. This existed as a bug first: every payload carried a phase line and every turn measured a doom delta for stories with no doom in them.
# Units: evil_progress is dimensionless 0.0–1.0
# evil_base_rate_per_day from config (0 disables the clock entirely)
evil_progress += evil_base_rate_per_day * days_elapsed * location.evil_multiplier * engagement_factor
evil_phase = phase_from_progress(evil_progress) # [0,0.2) DORMANT, [0.2,0.5) STIRRING, ...
plot_involvement = PlotFormula.compute(state) # engine/game/plot.py
story_pressure = PlotFormula.update_story_pressure(state) # on GameStateengagement_factor is inaction_bonus × (1 − doom_resistance/100 × world.evil_engagement_slowdown_max), hard-floored at 0.25. Two terms, pulling
opposite ways:
inaction_bonusis1 + (1 − plot_involvement/100) * 0.35— the world moves faster around a player who is not pushing back. (It previously comparedworld_daytoturn_number, which once the clock was fixed would have grown without bound simply because days pass faster than turns.)doom_resistance(0–100, hidden, neutral at zero) is earned, only through thedoom_resistanceeffect kind — quest rewards scaled by arc and set-piece victories — and it decays atworld.doom_resistance_decay_per_dayinside the same advance. Pushing back buys time; the reprieve is spent, not kept, and the floor means it never stops the clock.
That second term is the fix for R-06, and the floor is why it is a fix rather than a cheat code.
GameState fields: evil_progress, evil_phase, plot_involvement, story_pressure, awareness, procgen: ProcgenResult.
Storyteller receives full snapshot via query_evil_state; player UI shows only diegetic signs.
Measured pacing — scripts/simulate.py, 200 turns per policy, seed 42, base
rate 0.028. The clock runs at 3.0–5.6 in-game hours per turn depending on
how the player lives. The largest term is the background world tick
(world.tick_hours, 2.0), which is proportional to the real time the player
spends on a turn, capped at world.tick_max_hours.
| Policy | h/turn | Effective rate/day | Evil at turn 200 | Projected CONSUMING | Deaths |
|---|---|---|---|---|---|
| hero — pursues quests and set-pieces | 3.00 | 0.00704 | 0.176 (dormant) | day 114 | 4 |
| cautious — village circuit, road by daylight | 3.21 | 0.00678 | 0.183 (dormant) | day 118 | 3 |
| baker — never leaves Edgewood | 4.16 | 0.01432 | 0.501 (spreading) | day 56 | 0 |
| pauper — no gold ever spent | 3.56 | 0.01931 | 0.560 (spreading) | day 41 | 1 |
| reckless — Millhaven constantly | 5.62 | 0.01487 | 0.699 (spreading) | day 54 | 24 |
Read three things out of that table. First, the mechanism works and the numbers are real. Second, the clock answers to conduct: the disengaged baker's world falls 2.03× faster per in-game day than the engaged hero's, exposure still costs on top of that (reckless ≥ baker), and the median 200-turn run now ends in SPREADING rather than every playstyle parking in DORMANT. That spread was under 13% before the engagement work — see DESIGN_REVIEW.md issues R-03 (the clock) and R-06 (the convergence it was masking); both are closed, and the R-06 block carries the before/after side by side.
Third, it is possible to survive without money: the pauper policy works
no shifts, forages 93 of its meals, and takes zero starvation deaths in 200
turns, ending on 52 gold from a starting 5. It is not literally gold-free — it
makes one 12-gold purchase, through the bakery restock in _common_upkeep that
every policy shares — and the earlier claim that it spent nothing was true of an
older harness. What the canary actually proves is unchanged and is the thing
worth proving: food has a free tier, and starvation is no longer the price of
being broke. It is also the fastest world to fall, which is the doom mechanism
being consistent rather than a regression — a player who never engages is
exactly who the rate is aimed at.
Earlier revisions of this section blamed the 8-hour sleep for the pace. That was measured and found wrong — sleep is 0.7–1.4 h/turn amortised, and was never within a factor of four of the tick.
Reproduce with:
.\.venv\Scripts\python.exe scripts\simulate.py --turns 200 --seed 42 --policy all- 12 buildings, 8 NPCs (5 canon + 3 procedural), 1 festival per season
- Bakery job day varies with the world seed —
ProcgenResult.bakery_job_day, read by theprocgen_dayquest predicate. It was generated on every run since PR7 and read by nothing at all until P7 - Shrine with incomplete mural (lore hook)
- NPCs run hourly routines with activity strings
(
engine/world/npc_sim.py,games/clockwork-dark/data/world/npc_schedules.yaml), so who is in the room depends on the hour rather than on a static location field
- 6 forage nodes, 2 hidden paths, 1 optional barrow dungeon
- Forage nodes are reachable through the
forageskill (engine/game/foraging.py), which is what closed R-05 — a repeatable, gold-free food source; thepaupersimulator policy survives 200 turns spending zero gold - Hidden paths are discoverable while foraging and act as travel
shortcuts:
GameEngine.move_toprices a discovered path's two ends atSHORTCUT_HOURS(engine/game/foraging.py::shortcut_hours,tests/test_hidden_paths.py) - Encounter tables are banded by evil phase (
games/clockwork-dark/data/encounters/*.yaml)
From games/clockwork-dark/data/world/schedules.yaml. Each event draws from its own named RNG
stream, so they are independent of each other and replay from the save seed.
| Event | Probability | Effect |
|---|---|---|
caravan_arrival |
8% per in-game day, from day 5, lasts 2 days | Odran at Edgewood Square + goods + rumor packet |
tinker_camp |
5% per week, lasts 3 days | Ilya at the caravan + knowledge trade |
militia_press |
3% per day, only if Awareness ≥20, lasts 1 day | Sergeant Sera visits the square |
World events expire from state.world_events after their duration, so the quest
engine writes down the day they were first seen (quests["_meta"]) — the
Whisper gate needs "the caravan came on day six" ten days later, by which time
the event itself is long gone from state.
Templates in games/clockwork-dark/data/procgen_templates/.
Images resolve through a four-tier provider chain
(engine/media/providers/), in this order:
- Shipped art pack —
games/clockwork-dark/data/art/manifest.yaml. Instant, and what a shipped example game should do. - Disk cache — keyed by
(subject_id, kind, time_of_day, evil_phase). - Live generation —
media.live_generation, off by default. Grok Imagine or ComfyUI. Off because a Grok still takes 2–3 minutes, which cannot sit inside a real-time turn; ComfyUI is seconds and is the backend worth turning it on for. Usescripts/generate_art.pyto fill gaps ahead of time rather than paying for them mid-turn. - Deterministic procedural SVG — the floor. A missing picture never blocks play and never shows an empty frame.
| Asset type | Trigger |
|---|---|
| Location still | scene change / [IMAGE:location_id_mood] |
| NPC portrait | first meeting |
| Enemy art | encounter art: key |
| Cutscene video | [CUTSCENE:milestone_id] |
CutsceneBudgetInterceptor enforces the phase-shift-only video budget.
| Channel | Engine | Default |
|---|---|---|
| Storyteller narration | Voxtral TTS (:8123) |
off — measured ~21× slower than realtime |
| NPC lines | per-NPC voice profile | off with narration |
| Assistant | form-dependent voice, tts.assistant_enabled |
off, but the only channel worth speaking live: 1–3 sentences |
| Player input | Voxtral ASR — a CLI, not a server; the adapter shells out | available |
Narration is split on sentence boundaries (tts.max_chars: 400 is a hard server
limit, not a preference — longer input returns HTTP 400) and queued on a
background worker. Audio crosses the socket as a URL to a file on disk, never
as bytes; raw bytes in a turn payload take the whole turn down at jsonify.
Core owns the frame; the story fills the slots. The frame is the same for every story, which is what makes a story with no plugin at all a playable client.
┌─────────────────────────────────────────────────────────────┐
│ Mark Location HeaderBadge Day·Time │ ← chrome
├──────────────┬──────────────────────────────┬───────────────┤
│ │ Stage │ │
│ Aside │ Narrative log (streamed) │ Ledger │
│ │ Fade card · Reasoning panel │ │
│ │ Choices / compose + mic │ │
├──────────────┴──────────────────────────────┴───────────────┤
│ Day·Time status [story overlays] [mute][saves][⚙][⏸] │ ← chrome
└─────────────────────────────────────────────────────────────┘
Toast floats over the whole play screen.
Defaults with no plugin: no Aside (the column collapses), no Stage, no Toast,
and Ledger falls back to the generic sheet drawn from the story's declared
meters. The flagship puts its companion, its scene still / encounter panel, its
hand-built character sheet and its dice rail into those slots; NEON CITY puts a
district plate and contact strip into Stage and a heat ladder into Ledger.
The contract, slot by slot, is the header comment of ui/src/core/story.js.
The veiled-meter rule. A hidden value never leaves the server. A veiled
value arrives as a band word with no number, and no client may reconstruct
one — no bar width, no percentage, no tooltip with the integer in it. The band
is the whole truth the player is allowed, and the shape of the payload row is
the permission. Enforced by tests/test_ui_contract.py and by ui/tests/.
| Layer | Choice | Rationale |
|---|---|---|
| Runtime | Python 3.13 | Matches both parent repos |
| Scene server | Flask + Socket.IO (FlaskScene pattern), engine/scenes/default_{scene,state,api}.py |
CosySim skills/MCP/interceptors. The default scene is the ENGINE's: it served every story already (title, opening frames and content all follow the active manifest), so in v0.3.0 it moved out of content/scenes/clockwork/, which kept only the shared client asset tree and shims |
| Client | Vite + React 18 in ui/, built into a committed static/dist |
Real state management for a stateful game; committed build means no Node needed to play |
| Client per story | A plugin at ui/src/stories/<plugin>/, chosen by the manifest's ui.plugin |
Core alone is a playable client; a plugin fills slots. Three ship for four stories — dev-story borrows the Garden's |
| Inference | LM Studio :1234, SSE + json_schema structured output. Two sibling APIs, and the engine is deliberate about which: POST /v1/chat/completions (OpenAI-compat, the only route that takes tools and schemas), POST /api/v1/chat (native, the only one where reasoning: "off" is honoured) and GET /api/v1/models (the one model list, health check included — see engine/lmstudio/routes.py) |
Local-first |
| Speculative | draft model 0.5B–1B → big 8B refine |
Anubis + CosySim profiles |
| Lore | SQLite FTS; Nexus KMS optional | Progressive enhancement |
| Media | Shipped pack → cache → Grok/ComfyUI (off) → procedural SVG | Instant by default, generative by choice |
| Persistence | Atomic JSON saves, backup recovery, forward migration chain | Simple, inspectable, and survivable |
Narrative memory is engine-owned, not a chat transcript.
- StoryLedger — facts with decay and a salience ranking, pinned names, NPC relations and dispositions, promises with expiry, open threads kept in sync with the active quest list, plus a rolling verbatim turn buffer.
- Rolling summary — a record evicted from the turn buffer is folded into a
running summary by a dedicated call on the
smallprofile. Explicitly not the Storyteller's own callable: that one is prompted to produce game turns and answers a summarization request with narration JSON, which then became the summary verbatim. Falls back to deterministic compression when LM Studio is unreachable. - Token budget —
Budget.availableiscontext_tokens − reserve_outputless a 15% safety margin. Blocks are evicted in a fixed order (turns → lore → threads → summary); persona and world state are never evictable. No tiktoken: it is the wrong tokenizer for a llama or qwen GGUF. The budget is asserted in tests, not defended at runtime. - Stable-first block order — persona, standing rules and few-shot examples are byte-identical every turn and come first, so the KV prefix cache survives between turns. The old prompt put HP and the current hour in the middle of the standing rules, so nothing cached and every turn reprocessed the whole prompt.
Fixed defect (R-01):
StorytellerAgent._build_messagesused to run the PRE interceptors over the system blocks afterbuild_storyteller_messageshad fitted them, soLoreInjectInterceptorappended uncounted lore per system block — ~7.5k tokens sent against a 6,198 budget on a seeded lore DB. Lore retrieval now lives inengine/memory/context.py, inside the budget; only the awareness gate runs after fitting. The formerxfailintests/test_vertical_slice.pyis now a regression guard (test_the_prompt_that_is_sent_also_fits_the_budget). See DESIGN_REVIEW.md R-01 for the history.
See CLAUDE_CODE_BRIEF.md for the full scaffold. The one division that matters most is the engine/story seam:
| Tree | Owns | Rule |
|---|---|---|
engine/ |
Every mechanism: clock, dice, effects, quests, encounters, memory, agents, safety, and the default scene server | Must never import from a story |
games/<slug>/ |
One story: game.yaml (the manifest), state.yaml (declared values), agents.yaml (the roster), prompts/, data/ |
Declares what it ships; an undeclared paths.* key resolves to nothing |
ui/src/core/ |
The client shell: socket, reducer, screens, chrome | Must never import a story plugin |
ui/src/stories/<plugin>/ |
One story's look: theme and slots | May import @core; the direction is one-way |
content/scenes/clockwork/ |
The shared client asset tree (static/, templates/) and deprecated import shims |
Pinned by ui/vite.config.js's build output |
The undeclared-key rule is not a style preference; it is a bug that was live for
months. Every story that omitted a paths.* key silently read The Clockwork
Dark's content, and every story that omitted paths.prompts got a narrator who
introduced itself as the Storyteller of The Clockwork Dark. See
AUTHORING.md.
flowchart LR
subgraph hard [Hard Engine]
GS[GameState]
GE[GameEngine]
ET[EvilTicker]
PF[PlotFormula]
end
subgraph soft [Soft Layer]
AG[AgentGovernor]
SP[StreamProcessor]
SK[Skills Registry]
end
subgraph ext [External]
LMS[LM Studio]
CU[ComfyUI]
TTS[TTS]
end
GS --> AG
GE --> AG
PF --> GS
AG --> LMS
LMS --> SP
SP --> CU
SP --> TTS
SK --> GS
What actually runs, in the order a turn hits it:
- The model cannot express a mechanical OUTCOME.
stat_changes,items_gained,items_lostandskill_checkare not in the output schema. What it can express is an intent on a choice — "this option is a walk toedgewood_square" — chosen from an enum the engine built this turn from what it will accept. The engine resolves it, and every resolution funnels throughengine/game/effects.py::apply_effectandclock.advance_time. - Skills are partitioned by an agent allowlist
(
engine/skills/registry.py::SkillDef.agents, enforced inengine/agents/tool_dispatcher.py). Trigger/category metadata used to be decorative — the dispatcher executed anything present in the registry, so the unbounded system-only world tick was fully reachable from narration. - The engine picks the DC. The model names a difficulty band and nothing else; see Dice & Skill Checks.
- The engine decides quest progress. Every
complete_whenpredicate is engine-evaluable. The model's single lever isset_narrative_flag, and only for flags the current stage of an active quest has declared. - Evaluator rejects mechanical prose without a tool receipt — narration claiming "you rolled 18" scores 0.2 on the heaviest-weighted criterion and fails the gate. One retry, with the rejected draft's side effects rolled back.
- RAG lore is canonical for world facts and is scored against the same chunks the model was shown.
- AwarenessGate interceptor strips spoiler phrasing below the threshold.
SceneRulesEngineis wired — viaRulesGovernor(engine/agents/governance.py), which runs R001–R005 over every resolved turn:StorytellerAgent.run_turncallsget_governance().run_post(...)aftertx.commit(), so the audit judges the state the player actually ends the turn in, and violations ride out onStorytellerTurnResult.governance. It spent five PRs as dead code with a passing test suite — see DESIGN_REVIEW.md R-02 for that history. Items 1–4 above remain the primary guarantee, by construction; the governor is the second layer of defence, and R003 additionally counts a model's unearned stat claims throughOracle.record_unearned_claimbecause a model claiming fifty gold on one turn in three is a prompt defect you can only see by counting it.
1. Speculative pass (draft model, ~50ms skeleton)
2. Stream refine (big model, SSE to UI)
3. StreamProcessor extracts tags during stream
4. REQUIRED skills dispatched before JSON epilogue merge
5. Evaluator scores final packaged turn
| # | Decision | Rationale |
|---|---|---|
| 1 | New repo clockwork-dark/ |
Avoid CosySim cyberpunk coupling and Anubis dungeon grid assumptions |
| 2 | Engine-authoritative mechanics | Prevents LLM cheating; enables fair replay |
| 3 | Dual agents (not 5-agent council) | Intimacy + lower latency on 12GB VRAM; Evaluator retained |
| 4 | CosySim @skill over raw AgentScope |
Decorator ergonomics, cooldowns, MCP alignment, proven interceptors |
| 5 | FlaskScene over FastAPI-only | Matches CosySim scene catalog; faster port of realm/tavern patterns |
| 6 | Awareness as hidden stat | Preserves "clock unknown" fantasy for quiet-life players |
| 7 | Optional Nexus KMS | Local vector store sufficient for v0.1; Nexus as upgrade |
| 8 | Echo system deferred | Anubis-style past-run ghosts. Still deferred; permadeath is now settled (see Open Questions) |
| 9 | Video cutscenes milestone-only | GPU budget control — max 1 video per 30 min session default |
| 10 | Assistant in-world | Raistlin/Gandalf tone requires removing fourth-wall meta humor |
| 11 | REVERSED. See below. | |
| 12 | Speculative decode optional | Falls back to single-model stream if draft model unavailable |
| 13 | JSON parse failure | Retry once with repair prompt; then template fallback choices. The scanner counts braces rather than matching a regex — the old [^{}]* fallback forbade nested braces, so it could never match the mandated payload and the player was shown raw JSON as narration |
| 14 | Turn state is transactional | A draft rejected by the Evaluator is rolled back before the retry. It used to keep its side effects: the player was moved and drained by a narration they never saw |
| 15 | One mutation dispatcher | Every state change — quest reward, boon, complication, encounter outcome, death — goes through effects.apply_effect, so it is validated, clamped and receipted identically wherever it came from |
| 16 | Named, replayable RNG streams | world_rng(state, stream). Adding an encounter roll must not shift the caravan schedule. The world sim previously rebuilt random.Random(seed + world_day * 9973) every tick — and since the day never advanced, all three schedule rolls consumed the same frozen draw forever |
"JSON save v1, no migration" was already untrue by the time saves existed. The overhaul changed the state schema several times over — derived world clock, named RNG streams, survival fields, attributes, encounter and quest blocks — and retrofitting a migration chain after three breaking changes costs far more than carrying one from the start.
CURRENT_SAVE_VERSION is 2. engine/persistence/:
- Atomic writes — temp file plus
os.replace, so a crash mid-save cannot leave a truncated JSON where a run used to be. - Backup recovery — the previous save is retained and loaded when the current one will not parse.
- Forward migration chain —
engine/persistence/migrations.py. Each step isv → v+1, must be total, runs in memory on load (nothing is rewritten on disk until the next ordinary save, so a failed load never destroys the original), and is never deleted._v1_to_v2folds the oldworld_day/world_hourint pair intoworld_clock_hours, seedsrng_seedfrom the procgen seed, and backfills the survival and attribute fields. - Unknown keys are ignored on load (
GameState.from_dict) rather than raising, so a save written by an older build still opens after new fields land. The old code splatted raw dicts into constructors, and a single added field made every existing save unloadable with a bareTypeError. - Autosave every turn, plus an append-only transcript beside the save.
- Resume on reconnect —
SessionStore.resume(save_id). The client used to call/api/game/newon every reconnect, silently discarding the run.
Two serializers, never merged: to_save_dict() is complete and lossless and is
the only thing persistence writes; to_client_dict() is a redacted allowlist and
is the only thing the browser sees. The previous single
to_dict(include_hidden=) dropped both AgentMinds, so any round trip reset
evil progress, awareness and trust to defaults.
| Topic | Decision |
|---|---|
| Save format | JSON in data/saves/, save_version: 2, atomic + migrated |
| FlaskScene | Ported in PR10 |
| Skill enforcement | Agent allowlist in the dispatcher + engine-owned DCs + Evaluator receipt check, plus SceneRulesEngine R001–R005 run post-turn by RulesGovernor (engine/agents/governance.py) |
| Lore | LoreInject is a no-op when the DB is empty; seed with scripts/seed_lore.py |
| Frontend | Vite + React in ui/, built into a committed content/scenes/clockwork/static/dist so the game runs without Node |
35 registered skills, partitioned by an agent allowlist
(SkillDef.agents). The allowlist — not the trigger, not the category — is what
the dispatcher enforces. A skill a given agent is not on cannot be called by it,
and the refusal comes back as a legible receipt so the model can correct itself.
Implementations: engine/skills/builtin/mechanics.py (14),
engine/skills/builtin/livelihood.py (10), engine/skills/builtin/items.py
(6), engine/skills/builtin/assistant.py (3),
engine/skills/builtin/quests.py (2). Verify with:
.\.venv\Scripts\python.exe -c "import engine.skills.builtin, engine.skills.builtin.quests; from engine.skills.registry import SKILL_REGISTRY as R; tools = sorted(R.all_tools(), key=lambda s: (s.category, s.name)); print(len(tools)); [print(f'{s.name:20} {s.category:10} {s.trigger:9} {s.agents}') for s in tools]"| Skill | Category | Trigger | Callable by |
|---|---|---|---|
collections |
GAME | optional | storyteller |
craft_item |
GAME | optional | storyteller |
eat |
GAME | optional | storyteller |
encounter_approach |
GAME | required | storyteller |
equip_item |
GAME | optional | storyteller |
flee |
GAME | optional | storyteller |
forage |
GAME | optional | storyteller |
list_recipes |
GAME | optional | storyteller |
move_to |
GAME | required | storyteller |
query_equipment |
GAME | optional | storyteller |
query_forage |
GAME | optional | storyteller |
query_inventory |
GAME | optional | storyteller |
query_work |
GAME | optional | storyteller |
resolve_skill_check |
GAME | required | storyteller |
rest |
GAME | optional | storyteller |
roll_dice |
GAME | required | storyteller |
sleep_until |
GAME | optional | storyteller |
trade |
GAME | optional | storyteller |
trade_browse |
GAME | optional | storyteller |
trade_buy |
GAME | optional | storyteller |
trade_haggle |
GAME | optional | storyteller |
trade_quote |
GAME | optional | storyteller |
trade_sell |
GAME | optional | storyteller |
unequip_item |
GAME | optional | storyteller |
use_item |
GAME | optional | storyteller |
work |
GAME | optional | storyteller |
change_form |
NARRATIVE | optional | assistant |
grant_hint |
NARRATIVE | optional | assistant |
inspect_item |
NARRATIVE | optional | storyteller |
query_encounter |
NARRATIVE | required | storyteller |
query_evil_state |
NARRATIVE | required | storyteller |
query_quests |
NARRATIVE | optional | storyteller |
reveal_lore |
NARRATIVE | optional | assistant |
set_narrative_flag |
NARRATIVE | optional | storyteller |
advance_world_tick |
SYSTEM | system | system only |
advance_world_tick is deliberately unreachable from either agent. Exposed to
the model, an unbounded days argument is a time machine: negative values
rewound the calendar and large ones jumped the world straight to CONSUMING in a
single call. Time advances through play — travel, rest, work — or not at all.
| Tag | Effect | PR |
|---|---|---|
[IMAGE:prompt] |
ComfyUI still | 9 |
[CUTSCENE:id] |
Video cutscene | 9 |
[VOICE:style] |
TTS style | 9 |
[STAT:name±val] |
Stat delta (requires tool receipt) | 5 |
[ACTION:x] |
Game event | 5 |
[MOOD:x] |
Tone metadata | 5 |
The original twelve-PR build plan, with t-shirt sizes. All twelve shipped. It is kept as a record of how the game was assembled; what the code does now is described in the sections above and in The Overhaul (P1–P11) below, which corrected a great deal of it.
- Files:
pyproject.toml,requirements.txt,pytest.ini,config/default.yaml,engine/config.py,launcher.py,scripts/start.ps1,tests/conftest.py - Dependencies: none
- Done when:
pytestruns;python launcher.py --helpworks
- Files:
engine/game/state.py,evil_ticker.py,locations.py,dice.py,plot.py,engine.py, tests - Dependencies: PR1
- Done when: serialize round-trip; evil monotonic; phase boundaries tested
- Files:
engine/skills/registry.py,builtin/mechanics.py,engine/mcp/scene_rules_engine.py,games/clockwork-dark/data/economy.yaml,content/scenes/clockwork/clockwork_skills.py,test_skill_enforcement.py - Dependencies: PR2
- Done when: SceneRulesEngine R001–R005 tests pass; not Evaluator (that's PR5)
- Files:
engine/lmstudio/client.py,events.py,profiles.py,speculative.py,engine/agents/stream_processor.py - Dependencies: PR1
- Done when: Mock SSE tests pass;
infer_processed()extracts[IMAGE:],[CUTSCENE:],[STAT:]; speculative draft→refine fallback
- Files:
engine/agents/storyteller.py,evaluator.py,tool_dispatcher.py,prompts.py - Dependencies: PR3, PR4, PR11 stub OK
- Done when: JSON parse, tool_calls execution, Evaluator retry on hallucinated mechanics, mock LLM tests pass
- Files:
engine/agents/assistant.py,engine/skills/builtin/assistant.py,engine/media/stt.py - Dependencies: PR4 minimum; full integration PR5
- Done when: Agency silent/help branches, form system, grant_hint/reveal_lore/change_form skills, STT stub, mock LLM tests pass
- Files:
engine/game/procgen.py,games/clockwork-dark/data/procgen_templates/edgewood.yaml, extendedProcgenResult - Dependencies: PR2
- Done when: Same seed yields identical NPCs/buildings/forest; 8 NPCs (5 canon + 3 procedural), 12 buildings,
new_game_state()helper, tests pass
- Files:
engine/world/world_sim.py,engine/world/schedules.py,games/clockwork-dark/data/world/schedules.yaml - Dependencies: PR2, PR7
- Done when:
WorldSim.on_tickadvances evil + schedules; forcedcaravan_arrivalstores rumor/event; militia gated by awareness; tests pass
- Files:
engine/media/comfyui.py,tts.py,cutscene.py,pipeline.py,interceptors.py,games/clockwork-dark/data/procgen_templates/comfyui.yaml - Dependencies: PR4
- Done when: ComfyUI queue receives image jobs (mock/placeholder), TTS text fallback, cutscene phase-shift budget, Storyteller turn emits
mediadict, tests pass
- Files:
content/scenes/clockwork/*,engine/scenes/flask_scene.py,launcher.py - Dependencies: PR5, PR6, PR7, PR8, PR9
- Done when: Browser loads
:5573; REST + Socket.IOplayer_choiceemitsturn_update; health/new/choice/state routes work; tests pass
- Files:
engine/lore/manager.py,engine/lore/interceptors.py,scripts/seed_lore.py,games/clockwork-dark/data/lore/*.md - Dependencies: PR1
- Done when:
seed_lore.pyingests markdown; FTS retrieval returns chunks; LoreInject + AwarenessGate wired to Storyteller; tests pass
- Files:
tests/test_vertical_slice.py,scripts/simulate.py - Dependencies: PR1–PR11
- Done when: two ~40-turn scripted playthroughs (baker, Millhaven) against a deterministic mock LLM assert the clock advanced, evil rose with it, a mid-run save resumed identically, the prompt fit the budget, the ledger accumulated and the model saw prior turns, a quest completed, the baker never unlocked the Whisper arc, every payload was JSON-serializable, and stamina never pinned at zero.
The PR plan above describes how the game was built. It is complete. What
followed was a correction pass, numbered P1–P11, which is where most of the
architecture in this document actually came from. The suite went from 97 tests
to 600+ during the overhaul; it stands at 1,707 passing (19 skipped) now,
plus 86 client tests under ui/tests/ run by npm test. Run them rather than
trusting this line — it has been stale before.
| Phase | What it did |
|---|---|
| P1 | State serializer split; clock.py and derived time; atomic saves + migrations; ContextVar session isolation; thread-safe lore; RNG isolation; skill agent allowlist; system-only time skills; session lock + autosave; TTS bytes → disk |
| P2 | Narration streaming; engine/memory/ (ledger, rolling summary, token budget, stable-first block order); LM Studio transaction/gate/JSON schema/tool calls; turn loop; stack config with config/local.yaml deep-merge; Voxtral TTS |
| P3 | Vite + React UI: design tokens, socket store, components and screens; Jinja serves the built app; UI contract tests |
| P4 / P5 | Survival (hunger, rest, the stamina soft-lock fix); the 7-skill taxonomy, difficulty bands, itemised modifiers, degrees; effects.py single mutation dispatcher; archetypes → starting kit; skill_check/dc_mod contract removed |
| P6 | Encounters as contested scenes; edge danger_dc finally read; wounds; death rules |
| P7 | Quest engine — 24 quests across 4 awareness-gated arcs, engine-only stage predicates, set_narrative_flag as the model's one lever |
| P8 | Media providers: shipped art pack → cache → live generation (off by default) → deterministic procedural SVG |
| P9 | Content pass: items registry, economy, recipes, art manifest, content-integrity tests |
| P10 | Cutscene and settings UI; engine/stack.py, launcher.py --stack/--check |
| P11 | Hardening: scripts/doctor.py, tests/test_vertical_slice.py, scripts/simulate.py, and this documentation correction |
Where P1–P11 fixed mechanisms, this pass answered a different question: what is the engine and what is one story's answer? What landed:
| What it did | |
|---|---|
| The manifest | A story is games/<slug>/, declared by game.yaml. An undeclared paths.* key resolves to nothing instead of silently falling through to the flagship's content |
| Declared state | state.yaml — every value a story shows, with bounds, owners and a visibility (public / veiled / hidden). One declaration reaches the prompt, the sheet and the save |
| The roster | agents.yaml — the cast, its voices, and what each agent may read and write. The multi-agent turn (plan → negotiate → commit) is engine/agents/pipeline.py |
| Declared capabilities | Doom, decks, threads, endings, epilogues, recipes, challenges: each runs only for a story that declares it |
| The default scene | Moved from content/scenes/clockwork/ into engine/scenes/; it was serving every story already |
| The UI plugin | ui.plugin in the manifest picks a client plugin, so two stories can share one look. A borrowed plugin lends its look and not its voice |
| The finale chain | Ending eligibility → lock → Speak·Act·Seal → epilogue, all story-declared |
| Three more stories | wicked-garden, neon-city, dev-story — each shaped differently enough to fail where the flagship would not |
| Question | Status |
|---|---|
| Permadeath? | Settled. Respawn in Edgewood Square: 10 hours lost, 35% hp, half the purse, a −2 wound for 5 days. state.ended only on a second death during consuming. See games/clockwork-dark/data/rules/death.yaml |
| Video cutscene budget | Settled. Phase shifts only (media.cutscene_budget: phase_shift_only) |
| Live image generation | Settled: off by default. A Grok Imagine still takes 2–3 minutes, which cannot sit inside a real-time turn. Shipped art pack first, then cache, then procedural SVG |
| Spoken narration | Settled: off by default. Measured at ~21× slower than realtime on the reference machine (73.9 s of compute for 3.44 s of audio) |
| Multiplayer | Deferred |
| Nexus KMS | Optional; local SQLite FTS is sufficient |
| Character creation depth | Archetype only — archetypes now carry stats and a starting kit |
| Repeatable income / food economy | Settled. Foraging (engine/game/foraging.py) is the repeatable, gold-free tier; the pauper simulator policy survives 200 turns spending zero gold. See DESIGN_REVIEW.md R-05 |
- AUTHORING.md — How to write a story: the manifest, the declared state, the roster, the UI plugin. The document that replaces reading engine source for that job
- CLAUDE_DESIGN_BRIEF.md — Art direction, UI, ComfyUI prompts for design agents
- CLAUDE_CODE_BRIEF.md — Implementation spec for coding agents
- DESIGN_REVIEW.md — What the overhaul found, what it fixed, and what is still open
- GOVERNANCE.md — The governance chain, and the NOT WIRED tables
- STATE.md — The declared-state system in full
| Command | What it proves |
|---|---|
.venv\Scripts\python.exe -m pytest tests/ -q |
The whole suite |
.venv\Scripts\python.exe -m pytest tests/test_vertical_slice.py -q |
Two long playthroughs; the properties that only break at length |
.venv\Scripts\python.exe scripts\simulate.py --policy all --turns 200 |
Every balance number quoted above |
.venv\Scripts\python.exe launcher.py --check |
Which local services are up and what you lose without each |
.venv\Scripts\python.exe scripts\doctor.py |
Environment, config, content and data integrity |
npm test --prefix ui |
The client: the plugin contract across all three plugins, the core reducer, and the veiled-meter rule |
npm run build --prefix ui |
Rebuilds the committed static/dist from ui/src, after tools/check-styles.mjs passes. Nothing detects a STALE dist — it is checked in, so a ui/src change that is not rebuilt and committed simply does not reach a player. tests/test_ui_contract.py proves the dist that is there is complete and offline-clean, not that it is current |
End of design document.