Skip to main content

Story-Graph Kind — Content Model

Document status: Revision 1 — first build deliverable

Kind: story-graph

Implementation language: TypeScript (shared core with the simulation kind)

Scope of this document

The concrete content types for the flagship kind: the campaign, its typed variables, nodes, choices, requirements, consequences, endings, and achievements — plus the runtime state, how a turn resolves, and the projection. Ends with a worked example of the MVP's Bureaucracy arc.

  • The architecture this obeys: 02-architecture.md
  • Reused verbatim from the core: the Condition tree, LocKey and ReasonCode are defined in 04-core.md. RngState is the exception — 04 §8 states the contract and deliberately does not restate the algorithm, which lives in src/engine/src/core/determinism/ (Engine Package) and originated in games/04-engine-specification.md §3
  • The game this builds: games/bulgaria-adventure.md
  • What ships first: MVP.md

This kind reuses the core wherever it can. Types marked (core) are defined in the engine specification and not re-derived here.


1. The Campaign

A story-graph campaign is data (§1 of the architecture). It declares everything the engine needs to run it; the engine never recompiles to load one.

interface StoryGraphCampaign {
// The RUNTIME form: LocKeys only. Authors write `StoryGraphCampaignSource`, whose
// player-facing fields are `AuthoredText`; a pure builder lifts the strings out and
// produces this plus a string table (04 §10.1). §12 below is written in source form.
//
// This is the `content` inside the core's `Campaign` envelope (04 §10.1).
// Envelope-owned identity — id, kindId, version, titleKey — lives on `Campaign`,
// NOT here, so it cannot drift (the same rule as kindState, §8.1).
descriptionKey: LocKey;

variables: VariableSchema; // §2 — every variable, typed, declared up front
nodes: Record<string, Node>; // §3 — keyed by node id
startNodeId: string;

achievements: AchievementDefinition[]; // §7
}

id, version, kind, and titleKey are not fields here — they belong to the core Campaign envelope (04 §10.1), which wraps this content. Authors still write strings inline in the authoring form; the build lifts them into the registry's shared strings map (04 §10.1), so no per-campaign string table travels at runtime.

Load-time validation (§11) checks that startNodeId exists, every goto resolves, every variable referenced is declared, and every LocKey is present.


2. Variable Schema — Fully Typed (N6)

Every variable a campaign uses is declared here with a type and an initial value. Reading or writing an undeclared variable is a load-time error. Writing a value of the wrong type is a load-time error. This is the discipline decided in the architecture's §3.2 — the loose bag is banned.

type VarType = "bool" | "int" | "enum";

interface VariableDecl {
type: VarType;
initial: boolean | number | string;

values?: string[]; // enum only — the allowed values
min?: number; // int only — clamp floor
max?: number; // int only — clamp ceiling

visible?: boolean; // surfaced to the player as a stat (§9)
labelKey?: LocKey; // required when visible
}

type VariableSchema = Record<string, VariableDecl>;

type VarValue = boolean | number | string;

⚑ Judgement call — no string free-type. The architecture listed string as a variable type. Free strings are a determinism and validation hazard (unbounded, no load-time check on values) and no story-graph mechanic needs them — narrative text is LocKeys, not variables. enum covers "one of a fixed set." Dropped string for the MVP; add it back only if a campaign genuinely needs free text in state.

Player statistics are not a separate system. A variable marked visible: true is a stat — it appears in the projection (§9) and the client's stats panel. That is the whole of the "Player Stats" requirement.

Relationships and money are ordinary variables. A campaign that tracks the landlord's opinion declares int landlord_affinity; one that tracks cash declares int money. The story-graph kind imposes no relationship or currency model (architecture §6.3).


3. Nodes — The Single Content Type (N7)

A node is a scene: display text, plus what happens after it. The "what happens" is a discriminated union — the only content type in this kind.

type Node = ChoiceNode | RandomNode | AutoNode | EndingNode;

interface NodeBase {
id: string;
textKey: LocKey; // may interpolate visible variables — see §3.1
}

interface ChoiceNode extends NodeBase {
kind: "choice";
choices: Choice[]; // the player picks one
}

interface RandomNode extends NodeBase {
kind: "random"; // engine picks, seeded — the only place RNG enters
transitions: RandomTransition[];
}

interface AutoNode extends NodeBase {
kind: "auto"; // no player input; one transition, taken immediately
effects?: Consequence[];
goto: string;
}

interface EndingNode extends NodeBase {
kind: "ending"; // terminal — the game ends here
endingId: string;
outcome?: "win" | "loss" | "neutral"; // default "neutral"
}

Random and auto nodes are pass-through: the player never sits on one. After any transition the engine settles — resolving auto/random nodes in turn — until it lands on a choice or an ending (§8). So "a random event" is a random node the engine resolves and moves past; "an event not reached by a choice" is an auto/choice node a goto sends you to.

3.1 Text Interpolation

A node's textKey string may reference visible variables: "Your bank account contains {money}." The engine substitutes the current value at render time from the visible-variable set (§9). Referencing a non-visible or undeclared variable in text is a load-time error — a hidden variable must not leak through prose.


4. Choices and Transitions

interface Choice {
id: string;
labelKey: LocKey;

showWhen?: Condition; // omit the choice entirely if unmet (secret paths)
requirements?: Condition; // show but disable, with a reason, if unmet
requirementFailKey?: LocKey;

effects?: Consequence[]; // §5 — typed operations, applied on selection
goto: string; // target node id — required, validated
}

interface RandomTransition {
weight: number; // relative; positive integer — seeded weightedPick (04 §8)
effects?: Consequence[];
goto: string;
}

Two gates, deliberately distinct:

  • showWhen decides whether the choice appears at all. Use it for secrets — an option that shouldn't exist until the player has the key. Default: always shown.
  • requirements decides whether a shown choice is selectable. If unmet, the client renders it disabled with requirementFailKey as the reason — the Transparent Consequences principle. This is the common case.

A goto may target the choice's own node — that is how the Bureaucracy loop works (§12). Cycles are legal here and are a Tier 2 warning, not an error (architecture §9).


5. Consequences — Typed Effects

A choice or transition mutates state only through typed operations on declared variables. There is no arbitrary path write — the audit-record discipline from the simulation kind's §10.4, carried over.

type Consequence =
| { op: "set"; var: string; value: VarValue }
| { op: "increment"; var: string; by: number } // int only
| { op: "decrement"; var: string; by: number }; // int only

Validation checks: var is declared; the op suits its type (increment/decrement require int; set value matches the declared type / enum values). int writes clamp to the variable's min/max after applying. Clamping happens once, after all of a transition's consequences apply — the same rule as the simulation kind's needs (§3.3 there), so a +5 then -5 nets to zero rather than clipping.

Turn advance is automatic, not a consequence. The kind increments the built-in turn by 1 on every transition, including settle pass-throughs (§8.2). It cannot be the core's job: turn lives inside kindState, which the core treats as opaque (unknown, 04-core.md §2). A campaign wanting a time skip declares its own int and advances it — the built-in turn stays a faithful transition count.

Achievements have no unlock consequence. They are conditions (§7), evaluated after every turn. To fire one at a narrative moment, set a variable there and let the achievement's condition read it. One mechanism, uniform with the simulation kind. (⚑ If authors find this verbose, a direct unlock op can be added later — noted, not built.)


6. Requirements and Conditions

Requirements reuse the core's Condition tree verbatimall / any / not / comparisons / exists / count (games/04-engine-specification.md §13.1). That operator set is frozen (04-core.md §18) — this kind adds no operators, only a field namespace. A condition's field resolves against this kind's state:

var.<name> a declared variable's current value
turn the built-in transition counter
visited.<nodeId> how many times a node has become current — counts every entry,
including the start node and settle pass-throughs (0 if never; §8.2)
achieved.<id> whether an achievement is unlocked (bool)
ending the endingId once ended (else absent)

Every field is checked at load time against the schema and node set (§11). This is the only stringly-typed surface left in the kind, so it is the one that gets rigorous path validation — exactly as the simulation kind found (§4.3 there).

Example — the "certificate expired again" gate:

requirements:
all:
- { field: var.documents_collected, operator: equals, value: true }
- { field: var.certificate_fresh, operator: equals, value: true }

7. Achievements

Ported from the simulation kind, scoped to conditions over this kind's state.

interface AchievementDefinition {
id: string;
nameKey: LocKey; // "It Builds Character", not "First Ending"
descriptionKey: LocKey;
condition: Condition; // over var.* / achieved.* / ending
hidden: boolean; // if true, not listed until unlocked
}

Evaluated after every turn (§8). Each fires exactly once, and the unlock lands in two places with different jobs:

  • In-game, authoritative: StoryGraphKindState.unlockedAchievements (§8.1). This is deterministic state — it must be, because achieved.<id> is a readable condition field (§6), so an unlock can gate a later choice. It replays from seed + action log like everything else in kindState.
  • Cross-session, non-authoritative: a durable PlayerProfile mirror in the core's ProfileStore (04-core.md §7.1), upserted by the session store after a successful action — never by advance, which is pure and does no I/O. Nothing in resolution ever reads it, so it cannot perturb determinism. A missing or corrupt profile degrades to "no achievements," never a broken game; a failed write is a warning that does not roll back the game action.

The kind's part of the bargain is small: unlock into kindState and emit an achievement_unlocked StateChange (04 §12). The store does the rest. Records are keyed campaignId + achievementId, because an achievement id is only unique within its campaign (04 §17).


8. Runtime State and the Turn

8.1 State

The story-graph kind's state is the kind-specific subset only — it is the kindState inside the core's GameState envelope (04-core.md §2). Everything kind-agnostic — gameId, seed, campaignId, campaignVersion, status, and the action log — lives on the envelope, not here. Duplicating them (as an earlier draft of this section did) would put the same field in two places and drift.

interface StoryGraphKindState {
currentNodeId: string;
variables: Record<string, VarValue>;
turn: number; // kind-maintained; settle advances it (§8.2)
visitedCounts: Record<string, number>; // nodeId → times entered (every entry; §8.2)
unlockedAchievements: string[];
endingId?: string; // set when an EndingNode is reached
}
  • status (active / ended) is the envelope's, reported by advance's AdvanceResult.status (04 §3). The kind sets endingId here; the core flips status to ended.
  • The choice log is the envelope's generic actionLog (04 §2): each LoggedAction carries the choiceId as its actionId. There is no separate LoggedChoice.
  • turn stays here because a "turn" is kind-specific — a node transition in this kind, a week in the simulation kind.

variables and visitedCounts are subject to the core's sorted-iteration rule (04-core.md §8 / games/04 §2.2) — a Record iterated in a state-affecting way is sorted first, or a save/load round trip can diverge.

8.2 The Turn: submitChoice → Settle

The story-graph kind has exactly one player action — submit a choice — with no plan and no multi-action week (the model that led the simulation kind to drop executeAction, 05 §6).

Throughout, enter(nodeId) sets currentNodeId = nodeId and does visitedCounts[nodeId] += 1 — so every entry counts, including settle pass-throughs and the initial start node (§8.1).

submitChoice(state, choiceId, params):
0. reject if params is non-empty → unexpected_params (this kind takes none, 04 §3)
1. resolve the current node (must be a ChoiceNode) and the named choice
2. reject if the choice is unavailable: showWhen false, or requirements unmet
→ return ValidationError with the reason (§8.3), no state change, and a
player-facing `messages` entry built from the same messageKey (§3)
3. apply the choice's effects (typed consequences, §5), then clamp
4. the core appends `{ actionId: choiceId }` to the envelope's actionLog
5. transition: turn += 1, enter(choice.goto)
6. SETTLE (below)
7. evaluate achievements; append any newly-satisfied to unlockedAchievements
and emit a StateChange for each (durable profile writes happen outside
`advance`, which is pure — §7)
8. return the new scene (§9), or the ending if status === "ended"

Settle — the pass-through resolution of non-choice nodes:

settle(state):
loop (guard: max SETTLE_STEPS, default 64):
node = current node
if node.kind == "choice" → stop; the player acts next
if node.kind == "ending" → status = "ended", endingId = node.endingId; stop
if node.kind == "auto" → apply effects, clamp; turn += 1; enter(node.goto)
if node.kind == "random" → weightedPick a transition from the current RNG handle
(the triggering action's stream, or system:"start" at
createGame — 04 §4/§8);
apply its effects, clamp; turn += 1; enter(its goto)
if the guard trips → engine error (a content cycle of auto/random nodes with no exit;
Tier 2 validation warns on such cycles, the guard is the runtime backstop)

createGame enters startNodeId (so visitedCounts[startNodeId] becomes 1) and runs settle once — drawing any start random transitions from the system:"start" RNG stream (04 §4, §8) — so the first scene the player sees is already a choice or an ending. initialState reports which: it returns an InitialStateResult (04 §3) whose status is "ended" when the start settled onto an EndingNode. That is a valid campaign — a vignette or a test fixture — and validation flags it no_reachable_choice at Tier 2 (§11), not as an error.

8.3 Reason Codes

The codes this kind adds to the base set (Kind.reasonCodes, 04 §3, §12). Each needs a localized message or registry validation fails (04 §12). They divide by when they are checked, and the division matters because the two halves reach different audiences:

Resolution codes — checked at advance time, reported to the player. These ride out on a rejected AdvanceResult.error during a turn.

CodeWhen
not_a_choice_nodean action arrived while the current node is not a ChoiceNode — should be unreachable after settle
unexpected_paramsa non-empty params object; this kind declares none
settle_guard_trippedSETTLE_STEPS exceeded — an auto/random cycle with no exit (§8.2)

Validation codes — checked at registry build time, reported to the author. These are this kind's own validateCampaign findings (§11), and a player never sees one: a campaign carrying any Tier-1 code among them never reaches a frozen registry at all (04 §11). They are registered on Kind.reasonCodes alongside the resolution codes because the completeness rule is the same one — every registered code owes a localized message (04 §12).

CodeTierWhen
dangling_reference1a goto, transition.goto or startNodeId names no such node
undeclared_variable1a consequence, condition or interpolation names an undeclared variable
invalid_consequence_value1a consequence op or set value does not suit the variable's type or range
duplicate_id1a node, choice, achievement or variable id is used twice
missing_label_key1a visible: true variable has no labelKey
non_visible_variable_in_text1text interpolates a hidden or undeclared variable
invalid_transition_weight1a RandomTransition.weight is not a positive integer, or a random node has no transitions
unknown_condition_field1a Condition reads a field this kind does not define (04 §18)
unreachable_node2no path from startNodeId reaches it
unreachable_cycle2a choice/auto/random cycle with no exit to a choice or ending
no_reachable_choice2no ChoiceNode is reachable from the start — valid but non-interactive (04 §11)
no_reachable_ending2no reachable ending

An audit code — carried on a StateChange, reported to nobody in particular. It is neither a rejection nor a validation finding, and it is registered here for the same single reason both halves above are: StateChange.reason is typed ReasonCode and visible gates client display (04 §12), so a code a client can be handed owes a resolvable message.

CodeWhen
consequence_appliedthe coalesced variable-write StateChange a resolved consequence emits (§5, 04 §12) — one per touched variable per batch, visible mirroring the variable's own declaration

Its sibling achievement_unlocked (§7) is not here: it is base vocabulary (04 §12), because the session store's profile upsert (04 §7.1) switches on it without knowing which kind emitted it, whereas a consequence is this kind's own concept.

unknown_condition_field sits in the validation half because that is where it is found — but it is also the one code here a resolution could in principle raise, if a condition reached advance unvalidated. It cannot, on a frozen registry; the code is single, and which half it is listed under is a statement about the checked path, not two different codes.

A LocKey that fails to resolve reuses the base missing_string_key (04 §12) rather than adding a kind-owned code — it is the identical failure the core's own titleKey check already names.

Reused from the base set: unknown_action (no such choice id on the current node), requirement_unmet (shown but gated — carries requirementFailKey as its message), session_ended (an action against an ended game).

A hidden choice is unknown_action, not action_not_available. Submitting a choice whose showWhen fails returns exactly what a nonexistent choice id returns. The two cases are deliberately indistinguishable: a distinct code would let a client probe ids and confirm that a secret path exists, which is the one thing showWhen is for (§4, §9).

Determinism. Every random transition draws from the seeded RNG (core §3). Given the same seed and the same action log, settle makes the same picks — so the whole game replays byte-for-byte (§10). This is the concrete meaning of "deterministic" for this kind.

8.4 Events

The operational events this kind emits, declared as Kind.eventNames (04-core.md §3) and namespaced kind.story-graph.* (05-observability.md §9). They are emitted through ctx.emit (04 §3.1), never returned, and never localized — a StateChange (§5) is what the player is owed, and these are what a developer or a content author needs instead.

Name (after kind.story-graph.)SeverityEmitted atdataStatus
settle.steptraceeach iteration of the settle loopstep, nodeId, nodeKinddelivered
node.entereddebugevery enter(nodeId) — §8.2nodeId, nodeKind, visitCountdelivered
random.pickeddebuga random node chose a transitionnodeId, goto, weightdelivered
settle.guard_trippederrorSETTLE_STEPS exceedednodeId; reason setdelivered
choice.submitteddebug§8.2 step 1, on submission — before the id is resolvednodeId, choiceIddelivered
choice.rejectedinfo§8.2 step 2choiceId; reason set (§8.3)delivered
requirement.evaluatedtrace§8.2 step 2, once per evaluated requirement leafchoiceId, satisfieddelivered
consequence.applieddebug§8.2 step 3, and every settle pass-through's own effects (§8.2's settle procedure)variable, op, clampeddelivered
achievement.unlockedinfo§8.2 step 7achievementIddelivered
ending.reachedinfosettle landed on an EndingNodeendingIddelivered

All ten rows are delivered. Kind.eventNames (04 §3) declares what a kind may emit, and the shipped storyGraphKind now declares and emits all ten. Adding an eleventh follows the same shape: declare it in eventNames, emit it at the step named above, and it needs no further redeciding — event names are a published identifier a sink filters on (05 §9), fixing the name before the emit site exists costs nothing, and this is safe precisely because of 05 §2: dropping every event changes nothing, so a not-yet-emitted event was never load-bearing.

Two of these carry most of the value, for the two audiences the events exist to serve:

  • requirement.evaluated is the author's answer to why was my choice greyed out. It fires per requirement leaf rather than per choice, so a compound condition (§6) reports which clause failed — something the single requirement_unmet reason code (§8.3) deliberately cannot say, because the player is not owed the campaign's internals.

The walk short-circuits, and a leaf reports its effective contribution. all/any stop exactly where evaluateCondition (04 §18) stops, so this walk decides the same trees the same way showWhen and availableActions decide them — and that parity is load-bearing rather than incidental. A comparison against a type-mismatched operand throws, so the guard-then-typed-compare idiom (all: [x is set, x > 3]) only stays a clean requirement_unmet rejection while the guard can stop the walk; evaluating every leaf eagerly would buy one extra trace event and turn that rejection into a thrown engine error on a campaign availableActions had already greyed out. Under all, the short-circuit lands on exactly the clause this event exists to name. Note this is the opposite of the world-graph's rule in 12-world-graph-kind.md §9.1, deliberately: there the leaves are pure and an identical trace is the whole point, here they are not.

satisfied carries the parity of the enclosing nots, not the leaf's raw result. not: { achieved.bribed == true } against a player who holds it is a requirement that failed; reporting satisfied: true because the leaf alone was true tells the author the opposite of what happened, and it is the only event that requirement produces. Only the reported value is negated — the tree still decides on raw results. Under not: { all: […] } De Morgan makes per-leaf negation a convention rather than a truth; emitting once for the whole not subtree would always be truthful but drops the one-event-per-leaf property this section leans on, so parity is the deliberate trade.

choice.submitted fires on submission, before the id has been resolved, so it carries whatever choiceId the caller sent — including one naming no choice, or one whose showWhen fails. That is the point: an unknown or hidden id then shows as a choice.submitted/choice.rejected pair rather than as silence, and silence is the hardest thing to debug in a stream. It is a deliberate exception to 05 §8's rule that core.action.rejected omits an unresolved actionId — the core's rule protects a hosted operator's log from arbitrary caller text, and this event is namespaced to one kind and emitted at debug, where a host running nullEmitter (05 §2) never sees it at all. A host that does raise the level and does not want caller-supplied ids should filter this name.

  • random.picked is the developer's answer to why did this replay diverge. Paired with node.entered and visitCount, a stream diff localizes a determinism failure to one transition, instead of to a serialize() byte offset.

visitCount on node.entered, and why it is worth logging. Every entry counts, including settle pass-throughs and the initial start node (§8.2) — a rule that is easy to state and easy to get subtly wrong, since the Bureaucracy loop's office_visits ≥ 3 gate (§12) depends on it. Emitting the count at each entry makes an off-by-one visible at the step that caused it rather than several turns later, when a gate fails to open.

8.5 Terminal Identity

Kind.outcome (04-core.md §3) returns this kind's terminal identity for the replay oracle (07-replay.md §3.3):

outcome(state: StoryGraphKindState): { terminal: boolean; terminalId: string | null; endingId: string | null }

terminalCount(campaign: Campaign): number // distinct `endingId`s across the campaign's EndingNodes

endingId when the game has settled onto an EndingNode (§8.2), null while it is still active. Nothing else — not turn, not variable values, not visitedCounts.

terminalId is endingId, and the two fields are the same value under two names on purpose: terminalId is the cross-kind floor (04-core.md §3.2) a host reads without knowing which kind it holds, and endingId is this kind's own name for it, kept so nothing that already reads it has to change. terminal is status === "ended", which for this kind is exactly "endingId is set" — stated as its own field because §3.2 requires the two facts to stay separable for kinds where they are not the same.

terminalCount counts distinct endingIds, not EndingNodes. Two nodes may legitimately publish one ending — the same conclusion reached by different routes — and a progress denominator that counted nodes would tell a player there are more endings than the campaign can produce. It is pure over campaign.content: it reads the node map, never state, never a profile. This is the denominator in CampaignProgress.total (04-core.md §7.3); the numerator is the profile's, and this kind never sees it.

Why so little. The oracle compares games across engine versions, and anything that a content rebalance may legitimately change would report as a regression. An ending id is a published id, stable by the same rule that governs every other id (04 §17), so it survives a rebalance and a serialization change alike. turn and variables do not, which is why they are excluded here even though they are more informative in a debugger.


9. Projection — What a Client Sees

Clients receive a projection, never raw state (architecture §7). For the story-graph kind:

StoryGraphView is the kindView inside the core's PlayerView (04-core.md §9), and it carries only what the generic surface does not. The scene text is Scene.body, the choice list is Scene.actions, and gameId/status are on Scene / PlayerView already (04 §6, §9) — repeating any of them here would put one value in two places, the drift this kind has already been bitten by twice (§8.1, §1).

interface StoryGraphView {
turn: number;
stats: VisibleStat[]; // visible: true variables, with their labels
unlockedAchievements: string[]; // unlocked — including hidden ones, from the moment they unlock (§7)
ending?: { endingId: string; outcome: "win" | "loss" | "neutral" };
}

interface VisibleStat {
var: string; // the declared variable name
labelKey: LocKey; // required by §2 when visible
value: VarValue;
min?: number; // the declared clamp floor (§2), when the declaration has one
max?: number; // the declared clamp ceiling (§2), when the declaration has one
}

min and max mirror VariableDecl (§2) exactly, including its optionality. They are present when the declaration declares them and absent otherwise — which for the shipped campaigns means every visible stat carries both, since every one is a bounded int. A bool or enum stat carries neither, because §2 gives it neither.

They are here because without them a client rendering "3 / 12" had to reach into Campaign.content for the bound, and content is unknown to everything above the kind (04-core.md §10.1). A downstream host did exactly that, structurally and defensively. That is this ledger's failure mode running in the other direction: CLAUDE.md's envelope-duplication entries track a kind carrying a field something else owns; this was a projection omitting a field, which forces every client past the boundary to recover it. Both end with the boundary no longer load-bearing, and the fix for the omission is the same as for the duplication — put the field in exactly one place, here, and let clients read it there.

Copying a declared bound into the view is not duplication. The declaration in §2 is the sole authority and nothing writes back to it; the view narrows content for a reader that has no route to content, which is what a projection is for. The value copied is authored data a rebalance may change freely — unlike a terminal id, it is never compared across engine versions.

The enum analogue is a real gap and is deliberately not closed here. A visible enum stat's values set is unreachable by the same argument that made min/max unreachable. No campaign declares a visible enum — every visible stat in the shipped set is a bounded int — so adding the field now would ship public surface with no consumer and no test that could fail. Registered rather than fixed; the day a campaign declares one, this is the paragraph that says what to do.

The choices a player may pick are the core's AvailableAction[] (04 §6), produced by this kind's availableActions: showWhen-failing choices are omitted entirely, and a shown-but-ungated choice carries available: false with reasonKey = requirementFailKey (§4). There is no separate story-graph choice type.

Excluded from the projection: non-visible variables, visitedCounts, the action log, achievement conditions, and any hidden achievement not yet unlocked. A showWhen-hidden choice is omitted entirely — the client cannot know a secret path exists. This is what stops a client (or an AI agent over MCP) from seeing state the player shouldn't.


10. Determinism, Save, Versioning

All three are core mechanisms; the story-graph kind only supplies its state shape.

  • Save = the serialized core GameState envelope (which carries campaignVersion, seed, actionLog, and this kind's kindState), in a SaveEnvelope (04-core.md §10.2).
  • Determinism harness — a { config, actionLog } fixture (04-core.md §14) replays to a byte-identical serialize(), via the golden-file + property tests of §18.4 there.
  • Versioning / migration — a save records the campaignVersion it was made under. Loading against a different published version runs migration, which must map old node ids forward or fail loudly rather than strand the player on a node that no longer exists (architecture §8). A migrated save is marked not-replay-compatible.

11. Validation, Story-Graph-Specific

Tiered as in the architecture §9.

Tier 1 — load-time, hard fail:

  • startNodeId exists; every goto and every random transition.goto resolves to a real node.
  • Every variable in a consequence, condition, or text interpolation is declared.
  • Every consequence op suits its variable's type; every set value is in range / a valid enum member.
  • Every LocKey is present in strings.
  • No node id, choice id, achievement id, or variable name is duplicated.
  • A visible: true variable has a labelKey; text interpolates only visible variables.
  • Every RandomTransition.weight is a positive integer, and every random node has at least one transition — weightedPick throws otherwise (04 §8), so this is a load-time rule, not a runtime crash.

Tier 2 — load-time, warning:

  • Unreachable nodes — no path from startNodeId reaches them (the source's "detect dead branches").
  • A choice/auto/random cycle with no exit to a choice or ending (would trip the settle guard at runtime).
  • A campaign with no reachable ending.
  • no_reachable_choice — no ChoiceNode is reachable from startNodeId, so the campaign settles straight to an ending and the player never acts (§8.2). Valid but non-interactive (04 §11).

Tier 3 — simulation-time (§18.5 there): a choice whose requirements no reachable state can satisfy; an ending no path reaches.


12. Worked Example — The MVP Bureaucracy Arc

This is the concrete MVP content (MVP.md): ~6 nodes, typed variables, a requirement-gated retry, a loop with visit counts, a seeded random node, and the "It Builds Character" achievement. Authoring form (the build step derives the string table — 04-core.md §10.1).

id: bulgaria-bureaucracy
version: "0.1.0"
kind: story-graph
title: "Bulgaria — The Bureaucracy Arc"
startNodeId: municipality

variables:
documents_collected: { type: bool, initial: false }
certificate_fresh: { type: bool, initial: true }
patience: { type: int, initial: 10, min: 0, max: 10, visible: true, labelKey: stat.patience }
office_visits: { type: int, initial: 0, min: 0 }
builds_character: { type: bool, initial: false }

achievements:
- id: it_builds_character
nameKey: ach.builds_character.name # "It Builds Character"
descriptionKey: ach.builds_character.desc
condition: { field: var.builds_character, operator: equals, value: true }
hidden: true

nodes:
municipality:
kind: choice
textKey: node.municipality.text # arrive 08:03; "Closed until 11:30"
choices:
- id: wait
labelKey: choice.wait
effects: [ { op: decrement, var: patience, by: 2 } ]
goto: clerk_review
- id: coffee
labelKey: choice.coffee # meet the mayor's cousin
effects: [ { op: set, var: documents_collected, value: true } ]
goto: clerk_review

clerk_review:
kind: random # she smiles... or she doesn't
textKey: node.clerk_review.text
transitions:
- weight: 3
effects: [ { op: set, var: certificate_fresh, value: false } ]
goto: expired # a certificate is now over three months old
- weight: 1
goto: room_14 # you are sent onward

expired:
kind: choice
textKey: node.expired.text
choices:
- id: begin_again
labelKey: choice.begin_again
effects: [ { op: decrement, var: patience, by: 3 } ]
goto: municipality # the loop
- id: question_reality
labelKey: choice.question_reality
requirements: { field: var.patience, operator: less_or_equal, value: 3 }
requirementFailKey: req.too_much_patience
goto: reward # only the truly worn-down may pass

room_14:
kind: auto
textKey: node.room_14.text # Room 14 sends you to Room 6
effects: [ { op: increment, var: office_visits, by: 1 } ]
goto: room_6

room_6:
kind: choice
textKey: node.room_6.text # everything happens in Room 14
choices:
- id: continue_cycle
labelKey: choice.continue_cycle
effects: [ { op: increment, var: office_visits, by: 1 } ]
goto: room_14 # the other loop
- id: go_home
labelKey: choice.go_home
requirements: { field: var.office_visits, operator: greater_or_equal, value: 3 }
requirementFailKey: req.not_yet_broken
goto: reward

reward:
kind: auto
textKey: node.reward.text # €300 and 28 years of legal responsibility
effects: [ { op: set, var: builds_character, value: true } ]
goto: ending_character

ending_character:
kind: ending
textKey: node.ending.text
endingId: it_builds_character
outcome: neutral

What this exercises, one-to-one against the MVP Definition of Done:

  • Typed variables (bool/int), visible stat (patience), clamping (min/max).
  • Requirement-gated choices with reasons (question_reality, go_home).
  • A loop via self-referential goto and visit counts (office_visits >= 3).
  • A seeded random node (clerk_review) — reproducible from the seed.
  • An achievement firing once from a variable set at the reward.
  • Two clients run this identically; projection hides certificate_fresh, office_visits, builds_character, the visit counts, and the seed.

13. Judgement Calls

§CallRevisit when
§2Dropped string variable type; enum covers fixed setsA campaign needs free text in state
§5No unlock consequence; achievements are conditions over a set variableAuthoring proves it verbose
§5Built-in turn is a pure transition count; time skips are author variablesA kind-level clock is wanted
§8.2SETTLE_STEPS guard default 64Profiling or a legitimately deep auto-chain
§3Four node kinds (choice/random/auto/ending); auto is arguably a one-transition randomSimplification pass finds auto redundant
§6/§8.2visited counts every entry (settle pass-throughs + start node), so it works on auto/random nodesAuthors want "times rested here" only