Core Specification
Document status: Revision 1 — the platform core, as types
Reading order: logically the core underlies the kinds; the filename is 04 only to
avoid renumbering. Read after 02-architecture.md, before or
alongside 03-story-graph-kind.md — which is the order the
sidebar presents, stated in docs/sidebar.ts rather than taken from the filename prefix.
Scope of this document
The game-agnostic core, defined as types: the
GameStateenvelope, the Kind interface (the seam every kind implements), the platform engine API, the session store, generic scenes/actions, projection, the content registry, tiered validation, reason codes, randomness, serialization/save/migration, the determinism harness, and the MCP tool schemas.
02-architecturemade the decisions; this turns each into a type. Named ≠ defined ≠ buildable — that lesson, fromgames/, applied to the platform.
Reused, not re-derived. The seeded RNG (RngState, PCG32, deriveStream) and
canonical serialization are already built and verified in src/engine/src/core/
(Engine Package), and were
first specified in games/04-engine-specification.md §3, §2.1. This document references
them and does not restate the algorithms.
What
games/04-engine-specification.mdis, and is not. It is a 104 KB engine specification in the companion game project, SubZeroDev.GameOfLife — the document this one was derived from. It is cited throughout these specs, and every such citation is provenance, not authority.For anything the core owns — the engine API, randomness, save and serialization, testing, package layout, conditions, projections — this document supersedes it. Where the two disagree, this one is correct, and the older text should be read as the draft that led here rather than as a second opinion.
It is no longer authoritative for anything. It was, for one thing: the
simulationkind's own content and resolution model (its §5, §7–§10, §12, §14), held upstream until a contract existed in this repository against the Kind seam (§3). That contract exists —10-simulation-kind.md, whole as of its Revision 2, expressed against §3 the way03-story-graph-kind.mdis, with every typeSimulationKindStatenames and every resolution mechanic that dispatches on them specified here. Its §15 records what was ported and what each pass found.What remains upstream is provisional balance, not contract: drift rates, scenario economics,
demandBandthresholds, and the housing-quality formula, indexed inOPEN-QUESTIONS.md§2 as needing a balancing pass. Numbers, not shape — and a number this repository has not yet chosen is not a rule living somewhere else.
1. The Two Layers of "Engine"
Two things get called "the engine." They are different, and the split is load-bearing.
- The pure engine — a set of pure functions.
f(state, action) → new state. No I/O, no session, no clock. Testable, replayable, deterministic. This is what the Kind interface and the reducers live in. - The session store — a thin stateful layer above the pure engine that holds
serialized state blobs by id, so a client can
resume(architecture §2). It does I/O; it holds no game logic.
The platform API (§7) is the session store's surface. Clients talk to it; it calls the pure engine.
1.1 Internal Modules
The core is one public surface but several internal modules, each a single
responsibility. This is code organization, not new API — a peer-review recommendation to
keep the growing core maintainable. The src/engine/src/core/ layout mirrors it.
| Module | Owns | Section |
|---|---|---|
kernel | the GameState envelope, the Engine, submitAction | §2, §4 |
session | the session store, save/load handles, the profile store | §7, §7.1 |
persistence | canonical serialize/deserialize, SaveEnvelope, migration | §10 |
projection | the project mechanism, audiences | §9 |
validation | the tiered validator, ValidationResult | §11 |
registry | the content registry, campaign resolution | §10.1 |
localization | LocKey resolution against string tables | §12, §17 |
determinism | the RNG handle, streams, the harness | §8, §14 |
observability | the Emitter, EngineEvent, sinks | 05-observability.md |
composition | the host roots and the port interfaces | 06-extensibility.md |
Kinds (kinds/) and clients (clients/, mcp/) sit above; the dependency arrow points
only downward — a core module never imports a kind or client.
2. The GameState Envelope
The core owns a kind-agnostic envelope and treats each kind's own state as an
opaque payload inside it. This is the single most important type in the platform: it is
what advance, serialize, and the session store operate on.
type KindId = "story-graph" | "simulation" | "world-graph";
interface GameState {
formatVersion: number; // the shape of THIS envelope — see §10.2
gameId: string; // from the IdSource port (06 §5.1); opaque to the core
kindId: KindId;
campaignId: string;
campaignVersion: string; // the published version this game runs (§10)
seed: string; // the only randomness state — streams derive from it (§8)
status: GameStatus; // active | ended | abandoned
kindState: unknown; // the kind's own state — opaque to the core
actionLog: LoggedAction[]; // ordered player actions — the replay spine (§9)
}
type GameStatus = "active" | "ended" | "abandoned";
interface LoggedAction {
seq: number; // 0-based, monotonic
actionId: string; // the action the player submitted
params?: Readonly<Record<string, string | number | boolean>>;
}
What lives here vs in kindState. The envelope holds everything a game has
regardless of kind: identity, campaign reference, seed, status, and the action
log. A kind's own concepts — current node, variables, turn counter, week number,
needs — live in kindState, opaque to the core.
No persisted RNG state. Randomness is derived, not carried: every stream is a pure function of
(seed, streamId)(§8), so the envelope stores the seed and nothing else. A persisted generator state would be written every action and read by nothing — a serialized field free to drift from the derivable truth, taking byte-identical replay with it.{ seed, actionLog }is the complete replay input.
Why
kindState: unknown. The core must not depend on any kind. Typing the field asunknown(not a union of kind states) keeps the dependency arrow pointing the right way — kinds depend on the core, never the reverse. Each kind casts its ownkindStateinternally, guarded bykindId. This is the platform equivalent of the simulation kind's "engine imports no client" rule (games/04-engine-specification.md §20.1).
Determinism note. No wall-clock (
createdAt/updatedAt) lives inGameState— that would make byte-identical replay impossible. Timestamps, if a host wants them, live in the session-store record (§7), outside the replayable state. The determinism guard insrc/engine/eslint.config.jsenforces noDate.now.
3. The Kind Interface — The Seam
A kind is engine-owned code that teaches the core how one category of game plays. Every kind implements this interface; the core drives it without knowing which kind it is.
interface Kind<KState> {
readonly id: KindId;
readonly version: string; // manually maintained semver (§10.2, W31)
readonly reasonCodes: readonly ReasonCode[]; // codes this kind adds to the base set (§12)
/** The kind-owned half of the string table: registry assembly merges it alongside the
* core's protected `core.reason.*` set (§10.1), and validation checks it for completeness
* before assembly runs (§11, §12). A kind ships its own messages for the same reason the
* core ships the base set's — the codes are useless to a client that cannot render them.
*
* It must carry a `${id}.reason.<code>` entry for **every** member of `reasonCodes`; the
* completeness check is `registered → has a message` and nothing more. It may carry
* others, and this is a channel rather than a leak: a kind's own engine-created content
* can reference a `LocKey` no campaign collection exists to author — `simulation`'s
* `simulation.finance.investment.label`, on the fixed investment account its `invest`
* resolver creates — and `reasonMessages` is a `Kind`'s only route into the merged
* registry. Such a key is namespaced under the kind like any other and is not a reason
* code; nothing resolves it as one. */
readonly reasonMessages: ReadonlyMap<LocKey, string>;
readonly eventNames: readonly EventName[]; // events this kind may emit (05 §9)
/** Build the starting kind-state for a fresh game of this campaign. `profileData` is this
* kind's own cross-game slice, already resolved and migrated by the session store before
* the pure engine ran (§5, §7.1). It is absent for an anonymous session, for a kind that
* declares no `profileData` member, and for a replay whose `NewGameConfig` carries none —
* a kind that reads it must treat absence as "no cross-game history", never as an error. */
initialState(campaign: Campaign, ctx: KindContext, profileData?: unknown): InitialStateResult<KState>;
/** What the player can do right now — generic actions for the current scene (§6). */
availableActions(state: KState, ctx: KindContext): AvailableAction[];
/** Render the current situation into a generic scene body (§6). */
scene(state: KState, ctx: KindContext): SceneBody;
/** Resolve one player action. Pure: same (state, action, params, ctx) → same result. */
advance(
state: KState,
actionId: string,
params: ActionParams | undefined,
ctx: KindContext,
): AdvanceResult<KState>;
/** Narrow kind-state to the visible projection for an audience (§9). May return values
* that alias `state`; the core copies at the boundary (§9.1). The one requirement is
* that the result be plain, structurally cloneable data. */
project(state: KState, audience: ProjectionAudience, ctx: KindContext): unknown;
/**
* Tiered content validation of a campaign of this kind (§11). `strings` is the
* registry's built string table — checking a `LocKey` resolves, or that rendered text
* interpolates only a declared, visible variable, needs the table itself, not just the
* kind's opaque `content`.
*/
validateCampaign(campaign: Campaign, strings: ReadonlyMap<LocKey, string>): ValidationResult;
/**
* A minimal, cross-version-stable terminal identity — published ids only, never
* values ([`07-replay.md`](07-replay.md) §3.3). Every kind returns at least
* `KindOutcome` (§3.2) and may widen it with its own published ids.
*/
outcome(state: KState): KindOutcome;
/**
* How many distinct `terminalId`s (§3.2) a campaign of this kind declares, for progress
* display (§7.3). Optional: a kind whose terminal set is not a bounded, countable
* property of its content omits it, and the core reports no total rather than a wrong
* one. Pure over content — it reads `campaign`, never state, and never a profile.
*/
terminalCount?(campaign: Campaign): number;
/**
* Migrates a `KState` produced under an older `version` forward, when this kind's own
* state shape changed (§10.2). Optional — most version bumps don't change the shape a
* save references. Invoked only at the save-load boundary (`SessionStore`), never by
* `advance`; a missing function on a version mismatch fails the load rather than
* silently handing this version a state it wasn't written to read.
*/
migrateState?(oldState: unknown, fromVersion: string): CommandResult<KState>;
/**
* This kind's cross-game profile slice (§7.1). **Absent means the kind owns no profile
* data at all** — no `KindProfileRecord` is ever written for it, `initialState` never
* receives a `profileData` argument, and nothing about profiles reaches it. That is the
* state every kind is in until it declares this member, and it is the reason the whole
* mechanism is one optional property rather than three correlated ones: a kind cannot
* declare a version without a fold, or a fold without a version.
*/
readonly profileData?: KindProfileData;
}
/**
* The kind-owned half of §7.1's cross-game data. Engine-owned code, never a host-supplied
* port: it decides what a profile records and how a recorded value reads back, and 06 §2's
* rule admits a host only where it cannot change `serialize()` output. A seeded slice reaches
* `initialState`, so this one can.
*/
interface KindProfileData {
/** The shape version of this kind's `KindProfileRecord.data`. A positive integer, moving
* only when that shape changes. **Deliberately not `Kind.version`**: a kind's code moves
* far more often than its profile slice's shape does, and sharing the stamp would demand a
* profile migration for every unrelated bump. */
readonly version: number;
/**
* Fold one successful action's audit records into this kind's slice. Pure and total — same
* `(current, campaign, changes)` gives the same result, it performs no I/O, and it must not
* throw; the store treats a throw as a refused write (§7.1) rather than letting it reach the
* player, exactly as `resolveSaveEnvelope` already treats a throwing `migrateState`.
*
* **It must be idempotent**: folding the same `changes` twice must equal folding them once.
* That is what makes a transition survive a reload, a branch, or a re-submitted action
* without a duplicate entry, and it is why the recorded shapes below are maxima and sets
* rather than sums.
*
* `current` is `undefined` the first time this profile records anything for this kind.
* Returning a value whose canonical serialization equals `current`'s means "nothing to
* write", and the store skips the write — which is how idempotence becomes observable
* rather than merely claimed.
*/
fold(current: unknown, campaign: Campaign, changes: readonly StateChange[]): unknown;
/**
* Migrate a slice written under an older `version` (§7.1). Optional, and its absence is a
* *degradation* rather than a failure: a version mismatch with no migration drops the slice
* and warns, because a profile is a mirror of games already played and the game is the
* system of record. This is the one place a migration may be missing without failing —
* §10.2's save-side mismatch fails loudly, because there the payload *is* the record.
*/
migrate?(data: unknown, fromVersion: number): CommandResult<unknown>;
}
interface AdvanceResult<KState> {
state: KState; // the new kind-state
status: "active" | "ended"; // advance never yields "abandoned" — that is session-only (§7)
changes: StateChange[]; // audit records (§12) — for history and transparency
messages: OutcomeMessage[]; // player-facing, localized (§12)
error?: ValidationError; // set iff the action was rejected; state is unchanged
}
interface InitialStateResult<KState> {
state: KState; // the starting kind-state
status: "active" | "ended"; // a kind that settles at start may already be ended
changes: StateChange[];
messages: OutcomeMessage[];
}
Why
initialStatereturns a result, not a bareKState. A kind that settles at start (story-graph, 03 §8.2) can land on an ending before the player acts — a valid campaign (§11, Tier 2 warns). The core cannot discover this by inspectingkindState, which isunknownto it by design (§2), so the kind must say so.InitialStateResultis deliberatelyAdvanceResultminuserror: a campaign is pre-validated before the registry is frozen, so starting a game cannot fail the way an action can.
Why
advancereceivesparams.submitActionwritesparamsinto the replay log (§2), so anything they affect must be reachable from the kind — otherwise the log carries data that provably cannot change replay. The story-graph kind declares no parameters (an action is a choice id) and returns aValidationErrorif a non-emptyparamsobject arrives. Undocumented parameters are never silently ignored.
A rejection also returns a message.
errortells the core and client that the action failed and why (ReasonCode,messageKey);messagesis what tells the player — a rejectedAdvanceResultattaches oneOutcomeMessagebuilt from the samemessageKeythe error already carries ({ key: messageKey, visible: true }), so a rejection is never silently swallowed by a client that only rendersmessages. Every kind's rejection path follows this convention (world-graph'sactions/common.tsrejectedhelper and the story-graph kind's own, §8.3).
advance is where a kind's whole ruleset lives. For the story-graph kind it is
submitChoice → settle (03-story-graph-kind.md §8.2); for
the simulation kind it is the weekly resolution (games/04-engine-specification.md). The core calls it and
never looks inside.
One action model, three kinds. The core's action is a string
actionIdplus optional params. For the story-graph kind an action is a choice id, and it declares no params at all. For the simulation kind, actions map to its richer verbs (submit a plan, end the week). For the world-graph kind they are richer still —buildcarries a definition, a position and a rotation;advance_tickscarries a tick count (12-world-graph-kind.md§6). The core does not care — it forwards theactionIdand the kind interprets it. This is what lets one API (§7) and one MCP surface (§13) serve all three, and the spread from no params to four is the evidence that the model scales rather than merely fitting the two it was drawn from.
3.1 KindContext
Everything a kind needs to resolve, supplied by the core:
interface KindContext {
readonly registry: ContentRegistry; // §10 — the campaign and shared content
readonly campaign: Campaign; // this game's campaign, resolved
readonly rng: RngHandle; // handle on this resolution's own stream (§8)
readonly derive: (streamId: StreamId) => RngHandle; // any other stream, same seed (§8)
readonly seq: number; // current action sequence number
readonly emit: ResolutionEmitter; // this resolution's event handle (05 §4)
}
The kind draws randomness only from ctx.rng — a handle on the stream derived for
this resolution from (seed, streamId) — or from ctx.derive, for the streams that are
keyed by something other than the action. Either way the handle is discarded when advance
returns; nothing is written back, because the next resolution derives its own stream
from the seed again (§8). The kind stays pure and every draw stays reproducible.
Why
deriveexists. §8 defines fourStreamIdvariants, but only a kind is ever in a position to use three of them — the core cannot know that a draw belongs to this guest's fifth decision rather than to the action in flight. Withoutderive,ctx.rngwas the only reachable stream and those variants were unreachable by construction.derivecloses over the game'sseedand nothing else: it is pure, it persists nothing, and{ seed, actionLog }remains the complete replay input. The kind that forced this isworld-graph, whose correctness depends on draws keyed by simulated time rather than by how a client batched its requests (12-world-graph-kind.md§5).
ctx.emit is the same shape for the same reasons: a handle scoped to this resolution,
used and discarded, carrying nothing back into state. It reports what the kind is doing to
whatever sink the host attached, and emit returns void precisely so that nothing about
the sink can reach the game (05-observability.md §2). Removing
every event must leave serialize() byte-identical — the determinism harness asserts it
(§14).
3.2 KindOutcome — Terminal Identity a Host Can Read
Kind.outcome returned unknown, so a host holding a finished session could learn nothing
from it without knowing which kind produced it. That is the one place the seam leaked its own
purpose: terminal identity exists to be the cross-version-stable vocabulary
(07-replay.md §3.3), and a vocabulary only one reader can parse is not one.
The floor is now typed.
interface KindOutcome {
readonly terminal: boolean; // false while the game is still active
readonly terminalId: string | null; // the published id naming how it ended; null while active
}
terminalId is a published id, and terminal is not derivable from it being non-null —
they are two facts, and a kind that ever ends without naming a terminal keeps them
distinguishable rather than forcing null to mean two things. terminal is the field a host
branches on; terminalId is the field it indexes, stores and compares.
Each shipped kind widens this rather than replacing it, so nothing already in a kind's outcome moves or is lost:
| Kind | terminalId is | Widened with |
|---|---|---|
story-graph | endingId (03 §8.5) | endingId |
simulation | resolution — goals_met / failed / week_limit_reached (10 §12) | resolution, goalsMet, goalsFailed |
world-graph | resolution — objectives_met / failed (12 §8) | resolution, objectivesMet, failureId |
The two columns are not one vocabulary, deliberately. A story-graph terminal is an
authored id drawn from campaign content; a simulation or world-graph terminal is a declared
id drawn from a closed set this contract fixes. Flattening them would mean either inventing
authored resolutions the two mechanical kinds do not have, or collapsing every ending a
campaign ships into three buckets. What a host actually needs is weaker and is what is
guaranteed here: terminalId is stable across engine versions, unique within its campaign,
and safe to persist and compare — which is enough to index a finished session, count distinct
terminals reached (§7.3), and detect a replay divergence, none of which require knowing what
the id means.
A host still may not read a widened field without knowing the kind. That is not a
concession; it is the same rule as kindState (§2). The base is a floor, not a lowest common
denominator that kinds are then discouraged from exceeding.
Balance-sensitive values remain excluded, unchanged. Nothing here loosens
07-replay.md §3.4: no money, no needs, no tick or week counts, no satisfaction
score. KindOutcome adds a boolean and an id, both of which a rebalance leaves alone.
Why not a win/loss disposition on the base. It is the field a host wants next and it was deliberately left out.
Kind.outcomereceivesKStateand no campaign — the same constraint 10 §12 already records forweekLimit— and story-graph's win/loss lives onEndingNode(03-story-graph-kind.md§3), in content the function cannot reach. Supplying it would mean persisting the disposition intoStoryGraphKindState, which is a kind- state shape change: akindVersionbump and aKind.migrateState(§10.2) for every existing save. That is a defensible unit of work and it is not this one. Recorded rather than dropped — a host that needs win/loss today readsStoryGraphView.ending.outcome(03 §9), which has carried it all along.
4. Registration and the Pure Engine
Kinds are registered at engine construction — a fixed, engine-owned set (architecture §1). A missing kind is a construction error, not a runtime surprise.
type KindRegistry = Readonly<Record<KindId, Kind<unknown>>>;
function createEngine(host: EngineHost): Engine; // EngineHost — 06 §4
What this replaced, and why the shape changed. A three-positional-argument form —
createEngine(registry, kinds, emitter?)— stood here until theIdSourceport (06-extensibility.md§5.1) closed a real gap it left open:gameIdandseedwere consumed bycreateGamebelow with no named source.EngineHost { kinds, registry, ids?, emitter? }(06 §4) supplies all four in one shape, and each optional port has a working default —idsa random source,emitternullEmitter(05-observability.md§4). The older form is recorded rather than deleted, the same "provenance, not authority" relationship this document has withgames/04-engine-specification.mdabove, just one level down.
The pure engine exposes kind-agnostic operations over the envelope. It resolves the
kind by state.kindId, derives the RNG handle, delegates, and reassembles the envelope:
interface Engine {
/** The same `KindRegistry` this engine resolves `state.kindId` against, exposed so a
* caller needing kind metadata outside gameplay — `SessionStore`'s `SaveEnvelope`
* stamping and migration dispatch (§10.2) — reads it off the one engine it already
* holds rather than taking a second, independently-suppliable registry that could
* silently disagree with what this engine actually plays against. */
readonly kinds: KindRegistry;
createGame(config: NewGameConfig): CommandResult<GameState>;
scene(state: GameState): Scene; // §6
view(state: GameState, audience: ProjectionAudience): PlayerView; // §9
availableActions(state: GameState): AvailableAction[];
submitAction(state: GameState, actionId: string, params?: ActionParams): ActionResult;
previewAction(state: GameState, actionId: string, params?: ActionParams): ActionResult;
serialize(state: GameState): string; // §10 (canonical)
deserialize(data: string): CommandResult<GameState>;
migrate(data: string): CommandResult<GameState>; // §10
/** The same engine, with every event stamped for one command
* ([`05-observability.md`](05-observability.md) §6.1). The session store builds a
* short-lived decorator per command and swaps it in here, rather than the pure engine
* ever holding a clock or per-command context of its own. Listed here because this is
* the canonical `Engine` block; 05 §6.1 owns the reasoning. */
withEmitter(emitter: Emitter): Engine;
}
submitAction is the whole loop, in the core:
submitAction(state, actionId, params):
1. kind = kinds[state.kindId]; seq = state.actionLog.length // 0-based, monotonic
2. handle = rngHandleFor(state.seed, { kind:"action", seq }) // §8 — derived, not carried
3. emit = resolutionEmitter(emitter, state.gameId, seq) // 05 §4 — ordinal starts at 0
4. result = kind.advance(state.kindState, actionId, params,
{ registry, campaign, rng: handle, derive, seq, emit }) // §3.1 — `derive` closes over the seed
5. if result.error → return { ok:false, errors:[result.error] }, state unchanged // ActionResult.errors is a list (§12)
6. newState = {
...state,
kindState: result.state,
status: result.status,
actionLog: [...state.actionLog, { seq, actionId, params }],
}
7. return { ok:true, value:newState, errors:[], warnings:[],
changes:result.changes, messages:result.messages }
previewAction runs that same path against a null emitter. Its successful value is a
prospective state for rendering only: the caller must project it and discard it, never
persist it. The input state remains unchanged and preview emits no action lifecycle event,
so it cannot masquerade as a committed command.
A rejected action does not advance
seq. Step 5 returns without appending, so the next attempt computes the sameseqfrom the same log length. That is deliberate — the log is the replay spine and a refused action is not part of it — but it means two rejected attempts emit events with identical(gameId, seq, ordinal). Observability states that limit rather than papering over it, and disambiguates at the boundary (05-observability.md§5, §6).
Immutability is unconditional (games/04-engine-specification.md §11.3): every operation returns a new envelope.
createGame assembles the envelope and delegates the start to the kind:
createGame(config):
0. gameId = ids.newGameId() // 06 §5.1 — the IdSource port
1. campaign = registry.campaigns[config.campaignId] // kind = campaign.kindId
2. seed = config.seed ?? ids.newSeed() // 06 §5.1 — recorded in the envelope
3. startHandle = rngHandleFor(seed, { kind:"system", system:"start", seq:0 }) // §8
4. startEmit = resolutionEmitter(emitter, gameId, 0) // 05 §4 — seq 0, ordinal 0
5. init = kind.initialState(campaign, { registry, campaign, rng: startHandle, derive, seq: 0, emit: startEmit })
// a kind that settles at start (story-graph, 03 §8.2) draws its initial
// random transitions from startHandle, and reports "ended" if it settled to one
6. return the envelope { kindId: campaign.kindId, campaignId: campaign.id,
campaignVersion: campaign.version, seed,
status: init.status, kindState: init.state, actionLog: [] }
// init.changes / init.messages ride out on the CommandResult
The start resolution uses seq: 0 for both the RNG stream and the emitter, matching the
first action's numbering; the two never collide because the stream is system:"start"
rather than action (below), and because the emitter's ordinal restarts per resolution.
The start stream (system:"start") is deliberately distinct from the per-action
streams submitAction uses ({ kind:"action", seq }), so a start-of-game random draw
can never collide with an action's — the initial settle is reproducible on its own stream.
5. Configuration
interface NewGameConfig {
campaignId: string;
seed?: string; // omitted → the store generates one and records it
audience?: ProjectionAudience; // default "player"
kindProfileData?: unknown; // §7.1 — the resolved cross-game slice, not the profile
}
The kind is not named here — it is a property of the campaign (Campaign.kindId),
resolved from the registry. A client starts a game by campaign; whether that campaign
is a story graph or a simulation is invisible to it.
kindProfileDatais an input, and that is the whole of why it sits here. §7.1's standing rule is that nothing in resolution reads a profile, and §7's is thatprofileIdnever appears on this type or inGameState. Both survive: the session store reads the profile, extracts and migrates the slice for this campaign's kind, and writes the resolved value into the config it hands the pure engine. No identity travels, and no ambient read happens during resolution — by the timeadvanceruns, whatever the kind kept from the slice is ordinarykindState.Putting it anywhere else breaks the replay oracle.
ReplayFixture.configis aNewGameConfig(07 §2), so a value carried here is captured with the fixture and reproduces; a value reachinginitialStatethroughKindContextinstead would not be, and a captured session that started from a profile would silently stop being reproducible — the oracle would still pass while asserting something weaker than it claims. That is the same objection90-decisions.mdrecords against feeding a profile intoKindContextfor projection, applied to initialization, and it points the same way.08-session-capture.md§2's privacy rule is met by construction rather than by exception: what travels is kind-declared content ids and integers, the same category asActionParams, and neverprofileId.The type is
unknownfor the same reasonGameState.kindStateis (§2): the core must not depend on a kind. It is opaque here, opaque on the profile record (§7.1), and typed only inside the kind that declared it.
6. Scenes and Actions (Generic)
The unified surface every client renders. A kind projects its current situation into
this shape; a story graph and a simulation both produce a Scene.
interface Scene {
gameId: string;
status: GameStatus;
body: SceneBody; // kind-rendered
actions: AvailableAction[];
view: PlayerView; // the projection (§9), bundled for convenience
}
interface SceneBody {
textKey: LocKey;
text: string; // rendered, with visible-state params substituted
}
interface AvailableAction {
id: string; // the actionId to submit
labelKey: LocKey;
available: boolean; // requirements met
reasonKey?: LocKey; // present iff not available — Transparent Consequences
}
type ActionParams = Readonly<Record<string, string | number | boolean>>;
AvailableAction describes a verb, not its parameter space. A kind uses it to expose
a gated choice list — one entry per thing the player may currently do, available and
reasonKey saying whether and why not. That is a pattern a kind may use, not the default
with exceptions: it is the right shape exactly when the set of distinct submissions is small
enough to enumerate, and the wrong one as soon as an action carries parameters, because
enumerating a verb × its parameter domain is combinatorial.
How each of the three kinds actually lands, since the spread is the point:
story-graphuses it as a gated choice list — anAvailableActionis a node choice,available/reasonKeycome straight from its requirement gate (03 §4), and it declares no params at all. This is the pattern at its cleanest, and it is why the type looks the way it does.simulationreturns its four verbs (plan.add/plan.remove/plan.clear,end_week) and pushes the whole parameter domain — whichActionTypes are offerable, and the plan itself, so a client can compute a validplan.removeindex — intoSimulationView.plan(10 §9).world-graphdoes the same for spatial verbs, and is the kind that forced the rule to be stated: the build catalogue, staff roster and price bands are projection (12 §7, §10), becausebuild× every definition × every cell × four rotations is not a list.
The invariant across all three is only this: whatever a client can submit, it can discover
— from availableActions, from the projection, or from both. AvailableAction is one of
the two places that discovery may live, not the place it must.
7. The Session Store and the Platform API
The pure engine is stateless. The session store is the thin stateful layer clients
actually call. It maps the architecture's §10 API onto the pure engine, keyed by
sessionId.
The surface splits cleanly into queries (read-only, no persisted state change) and commands (advance or persist). This is a documentation convention for clarity — not CQRS the pattern: there is one state model, no separate read store, no event bus. Just a useful line between "look" and "change."
interface SessionStore {
// ── Queries (read-only) ──────────────────────────────
listCampaigns(profileId?: string): Promise<CampaignCatalog>; // session-free — §7.3
getScene(sessionId: string): Promise<Scene>;
getView(sessionId: string): Promise<PlayerView>;
getStrings(sessionId: string): Promise<StringTable>; // resolve LocKeys — below
listSaves(profileId: string): Promise<readonly SaveSummary[]>; // player-keyed, totally ordered — §7.4
previewAction(sessionId: string, actionId: string, params?: ActionParams): Promise<SessionActionResult>; // resolves prospectively, then discards
// ── Commands (advance or persist) ────────────────────
createSession(config: CreateSessionConfig): Promise<SessionHandle>; // profileId lives here
resumeSession(sessionId: string): Promise<Scene>;
submitAction(sessionId: string, actionId: string, params?: ActionParams): Promise<SessionActionResult>;
saveGame(sessionId: string): Promise<SaveHandle>; // named/manual save
loadGame(saveId: string): Promise<SessionHandle>;
deleteSave(profileId: string, saveId: string, expectedSavedAt: string): Promise<void>; // §7.4
branchSession(sessionId: string, atActionCount: number): Promise<SessionHandle>; // §7.4
}
interface SessionHandle { sessionId: string; scene: Scene; }
interface SaveHandle { saveId: string; savedAt: string; savedAtSeq: number; }
/** §7.4. Save-list metadata only — never a blob, never an envelope, never kind content. */
interface SaveSummary {
saveId: string;
campaignId: string;
savedAt: string; // Clock-stamped (06 §5.4); the primary sort key
savedAtSeq: number; // actions logged at the moment the save was taken
}
/** §7.3. `strings` resolves every `LocKey` the summaries carry, and nothing else. */
interface CampaignCatalog {
readonly campaigns: readonly CampaignSummary[];
readonly strings: StringTable;
}
interface CampaignSummary {
campaignId: string;
kindId: KindId;
titleKey: LocKey;
progress?: CampaignProgress; // present iff `listCampaigns` was given a `profileId` — §7.3
}
interface CampaignProgress {
discovered: number; // distinct `terminalId`s this profile has reached here
total: number; // what `Kind.terminalCount` reports for this campaign
}
interface CreateSessionConfig extends NewGameConfig {
profileId?: string; // omitted → anonymous session; see §7.1
}
/** What a client gets back from an action. Never the envelope. */
interface SessionActionResult {
ok: boolean;
scene?: Scene; // the new scene, on success — a projection (§9)
errors: ValidationError[];
warnings: ValidationWarning[];
changes: StateChange[]; // audit records, `visible`-gated (§12)
messages: OutcomeMessage[];
}
type StringTable = Readonly<Record<LocKey, string>>;
submitActionreturnsSessionActionResult, notActionResult.ActionResultextendsCommandResult<GameState>(§12) — its success value is the envelope, seed and action log and opaquekindStateincluded. That type is correct for the pure engine (§4), whose caller is the store; handing it to a client would put raw state on the other side of the projection boundary and make §9 a convention rather than a guarantee. The store unwraps it and returns aScene.
createSessiontakesCreateSessionConfig. It previously tookNewGameConfig, which carries noprofileId— leavingCreateSessionConfigdefined and unreachable, and no way for a client to start the profiled session MVP §5 requires for cross-session achievements.profileIdstays offNewGameConfigand out ofGameState(§7.1); it is a session input, which is exactly what this type is for.
Why
getStringsis a store operation. Every client-facing type carriesLocKeys —Scene.actions[].labelKey,CampaignSummary.titleKey,OutcomeMessage.key,ValidationError— and a client that may call nothing but this store (09 §2) otherwise has no way to render any of them. Resolving them inside the DTOs was the alternative and is worse: it would bake a locale into the projection and lose the property that clients never string-match English (§12). The table is keyed by the campaign and locale the session was created with; a locale switch is a new session, which is all the MVP's single locale needs.
The store persists the envelope (§2) and nothing else about play. Wall-clock
timestamps, owner ids, and other host metadata live on the store's record, outside the
replayable GameState. This is the boundary that keeps determinism intact while still
supporting "resume on another device" (architecture §2).
createSession generates and records a seed when the config omits one, so a resumed or
replayed session is always reproducible.
Two independent lock domains, and they are part of this contract. The store holds a serialized blob per session and mutates it in place, so "read the blob, resolve, write it back" is a read-modify-write and needs saying who may run concurrently with whom:
- Per
sessionId— every operation that touches one session's blob queues behind its predecessor for that session. Two submissions against the same session therefore resolve in the order they acquire the lock, never interleaved, so neither can read a blob the other is about to overwrite. - Per
profileId— the profile upsert (§7.1) is its own load-merge-save, and two different sessions may legitimately share oneprofileId; that is what a profile is for. Session locking alone does not serialize it, so profile upserts queue on a second, independent domain keyed byprofileId. - Per
saveId— a save record is written bysaveGameand removed bydeleteSave(§7.4), and neither is reachable from the session lock: a save outlives the session that wrote it, anddeleteSaveis addressed bysaveIdalone. A third domain keyed bysaveIdis what makes §7.4's compare-and-delete a read-modify-write rather than a race.
Different sessions interleave freely, which is the property that matters for a host
serving many players: the domains are keyed, not global, and the two never couple. Nothing
here is visible in serialize() output — locking orders commands, it does not change what any
one command computes — so this is a store-layer concurrency contract, not a determinism one.
previewActiontakes the session lock but is not a command. It shares the per-session queue, so it cannot evaluate one version of a session while a neighbouring submission persists another. Everything else that makes an operation a command, it deliberately skips: it does not increment the attempt counter, does not write the blob, does not touch profile persistence, and emits no action lifecycle event (§4). That is the query/command split above taken literally — a preview is a read that happens to run the write path, so it must be ordered like a write and recorded like a read.
7.1 The Profile Store
Achievements must outlive a game (MVP §5, 03 §7), but nothing durable may sit inside
GameState. So the profile is a second store beside the session store, at the same
layer — stateful, I/O-doing, and invisible to the pure engine.
interface PlayerProfile {
formatVersion: 3;
profileId: string;
achievements: readonly AchievementRecord[];
terminals: readonly TerminalRecord[]; // §7.3 — the cross-session half of campaign progress
kindData: readonly KindProfileRecord[]; // below — the kind-owned cross-game slice
}
interface AchievementRecord {
campaignId: string; // achievement ids are only unique within a campaign
achievementId: string;
}
interface TerminalRecord {
campaignId: string; // a terminalId is only unique within a campaign (§3.2, §17)
terminalId: string; // `KindOutcome.terminalId` — a published id, never a value
}
/** At most one per kind. Ordered by `kindId` ascending, so a profile has one serialization. */
interface KindProfileRecord {
kindId: string; // a `KindId` for every kind this build knows; see below
dataVersion: number; // the `Kind.profileData.version` this `data` was written under
data: unknown; // opaque to the core, exactly as `GameState.kindState` is (§2)
}
type ProfileWarningCode =
| "profile_missing" | "profile_corrupt" | "profile_write_failed"
| "profile_kind_data_unreadable" | "profile_kind_data_rejected";
interface ProfileWarning {
code: ProfileWarningCode;
profileId: string;
kindId?: string; // present iff the warning is about one `KindProfileRecord`
}
interface ProfileLoadResult { profile: PlayerProfile; warnings: readonly ProfileWarning[]; }
interface ProfileSaveResult { ok: boolean; warnings: readonly ProfileWarning[]; }
interface ProfileStore {
load(profileId: string): Promise<ProfileLoadResult>;
save(profile: PlayerProfile): Promise<ProfileSaveResult>;
}
Rules, all of them determinism-preserving:
- Profile identity is a session concern.
profileIdlives onCreateSessionConfigand the store's record — never onNewGameConfig, never onGameState. The pure engine has no idea profiles exist. A manual save preserves that association the same way:profileIdround-trips through the store's own save record, never through the serializedSaveEnvelope, soloadGamerestores the same profile asaveGame'd session had. - Nothing in resolution reads a profile. A kind unlocks into its own
kindState(03 §7) and emits anachievement_unlockedStateChange(§12). After a successful action, the session store idempotently upserts those records through theProfileStore. Profile contents and write outcomes never feed back intoadvance. - Anonymous by default. No
profileId→ no read, no write; achievements persist only for that game. Cross-session persistence is opt-in. - Degradation is a warning, never a failure. Missing or corrupt loads return an empty
formatVersion: 3profile plusprofile_missing/profile_corrupt. A failed write returnsprofile_write_failedand does not roll back the completed game action — the game is authoritative, the profile is a mirror. - Terminals mirror exactly as achievements do. After an action whose
AdvanceResult.statusisended, the session store readsKind.outcomeand idempotently upserts oneTerminalRecordfor theterminalIdit names, on the same write as the achievement upsert. AnullterminalIdon a terminal outcome records nothing. Nothing inadvanceorprojectever reads one back, so this cannot perturb determinism any more than the achievement mirror can. - Kind-owned data mirrors the same way, and reads back as an input. A kind that declares
Kind.profileData(§3) gets oneKindProfileRecord; the store folds it forward after a successful action, and hands it back throughNewGameConfig.kindProfileData(§5) when a new session for that profile starts. Both halves keep the two rules above intact — the write is after resolution, and the read is before it. Kind-owned cross-game data, below, is the whole of it.
formatVersion has moved twice, and both migrations are total, which is why both are stated
here rather than seamed as functions. A version-1 profile reads as
{ ...profile, formatVersion: 3, terminals: [], kindData: [] }; a version-2 profile reads as
{ ...profile, formatVersion: 3, kindData: [] }. No field is renamed, removed or re-typed at
either step, so neither can fail. The ProfileStore implementation owns this migration —
it is the one part a host adapter must reproduce, because the adapter is what reads the stored
document, and it needs no kind registry to do it. The per-kind dataVersion migration below is
the opposite, and is core-owned for the same reason inverted.
A player who finished games under version 1 starts at discovered: 0 for those campaigns and
re-earns the count by finishing again. That loss is accepted deliberately: the alternative is
reconstructing terminals from stored session blobs, which would mean deserializing every save a
profile ever touched to recover a number that exists only to decorate a shelf. The 2 → 3 step
loses nothing, because no version-2 profile ever held kind data.
formatVersion: 3is a literal type, and the break is deliberate. A host constructing aPlayerProfilefails to compile rather than writing a2document this build then migrates on every load. This is durable player data; a loud break at the one place that constructs it is cheaper than a silent per-load rewrite nobody notices.
Kind-owned cross-game data
A kind's own state cannot outlive a game — that is GameState's whole shape (§2). Some of it
is meant to: simulation's "profile"-scoped event chains
(10-simulation-kind.md §2.2) exist precisely to advance across games
a player has already finished. This is where that lives, and the core never learns what it is.
Ownership. KindProfileRecord.data is owned end to end by the kind whose kindId it names.
The core stores it, sizes it, versions it and hands it back; it never reads inside it, never
merges it, never validates its contents, and never declares a type for it. The only code that
interprets a slice is that kind's own Kind.profileData (§3), engine-owned per architecture N2
and 06-extensibility.md §7. No kind's type appears in the core as a
result — not simulation's, and not any later kind's.
Writing — the third mirror. After a successful submitAction on a profiled session, and on
the same profile-keyed write as the achievement and terminal upserts (§7's profileId lock
domain), the store calls kind.profileData.fold(current, campaign, changes) with the record's
current data (or undefined) and that action's AdvanceResult.changes. The returned value
replaces data, and dataVersion is stamped to kind.profileData.version. Three outcomes,
and only the first writes:
- A different value — written, in
kindIdorder, on the sameProfileStore.saveas the other two mirrors. - A canonically equal value — no write. This is what makes
fold's idempotence observable rather than merely claimed: reapplying a transition the profile already holds produces the same bytes, the store skips the save, and no duplicate entry, event, achievement or reward can follow from it. - A throw, or a result the canonical serializer rejects (
canonicalStringify,src/engine/src/core/persistence/canonical.ts— non-finite numbers,bigint, anundefinedin a value position) — refused. The previousdatais retained untouched and oneprofile_kind_data_rejectedwarning names the kind.foldis content-adjacent code and is invoked defensively for exactly the reasonresolveSaveEnvelopealready invokesmigrateStatedefensively.
Size is validated, and the limit is on the slice. The canonical serialization of a folded
data must not exceed 65 536 bytes. Over it, the write is refused exactly as a throw is:
prior data retained, profile_kind_data_rejected warned. The cap is per record rather than
per profile, so one kind cannot starve another; and it is checked on the write rather than the
read, so a profile can never become unloadable because a limit tightened. A kind whose slice
approaches this has designed an unbounded accumulator and needs a different shape, not a larger
number.
Version is validated, and a mismatch degrades. On load, for each record whose kindId is
registered:
dataVersion === kind.profileData.version— used as-is.dataVersion < …—kind.profileData.migrateruns and its result is used. A missingmigrate, a failed one, or one that throws drops the slice for this session and warnsprofile_kind_data_unreadable.dataVersion > …— an older build reading a newer profile. The slice is not read, and the same warning is raised.
A dropped slice is not a deleted one. The record stays in the profile byte for byte, so the build that can read it still can; the kind simply starts that session with no cross-game history, which is the same degradation a missing profile already gets.
Migration order, and why it is this way round. The profile's own formatVersion migration
runs first, then the per-record dataVersion migration. Same reasoning as §10.2's
kind-then-campaign order: the envelope's shape is a precondition for addressing the payload
inside it. The split of who runs each is the load-bearing part — formatVersion is the
adapter's, because the adapter is what reads the stored document and it needs nothing else;
dataVersion is the core session store's, because it needs the kind registry, and a host port
must never hold one.
Unknown kinds are preserved, never dropped. A KindProfileRecord whose kindId names no
kind in this build's registry is carried through load and save unchanged: not migrated, not
sized, not handed to any kind, not counted against anything. kindId is typed string rather
than KindId for exactly this reason — a closed union would make such a record fail shape
validation and condemn the whole profile as profile_corrupt. The case is real and
one-directional: a host running a subset of kinds, or an older build, must not erase a player's
progress in a kind it happens not to have.
Reading — once, at createSession, and nowhere else. When createSession is given a
profileId and the campaign's kind declares profileData, the store loads the profile,
resolves the record for that kindId, migrates it, and passes the result as
NewGameConfig.kindProfileData (§5) — before the pure engine runs. resumeSession, loadGame
and branchSession do not re-seed: the kind already put whatever it kept into kindState
at initialState, and re-reading a profile that has moved on since would make a resumed session
differ from the one that was saved. An anonymous session reads nothing, so it seeds nothing.
Invariants
Each is written to become an assertion. The session store maintains P1–P6; P7 and P8 are
obligations on a kind that declares profileData, and the store cannot enforce them for it —
which is why they are stated here rather than assumed, and why the kind conformance tests are
where they are checked.
- P1. Nothing a profile holds is readable from
advance,project,scene,availableActionsoroutcome. The only route in isinitialState's third argument. Enforced by the seam's own shape: no other member is given one. - P2.
serialize()output for a session created withprofileIdset andkindProfileDataabsent is byte-identical to one created anonymously with the sameNewGameConfig. Enforced by code, and by the determinism harness (§14). - P3. A
KindProfileRecordwhosekindIdis unregistered in this build survives a load-modify-save round trip byte-identically. Enforced by code. - P4. No
ProfileStorefailure, refused fold, dropped slice, or size rejection changesAdvanceResult,GameState, or whethersubmitActionsucceeded. Every one of them surfaces as aValidationWarningand nothing else. Enforced by code — the same guaranteeprofile_write_failedhas held since W8. - P5.
PlayerProfile.kindDataholds at most one record perkindId, ordered bykindIdascending, so one profile has exactly one canonical serialization. Enforced by code. - P6. A profile write happens only when the folded slice's canonical serialization differs from the stored one. Enforced by code — this is what makes P8 observable.
- P7.
foldis pure: no I/O, no ambient clock, no randomness, and the same(current, campaign, changes)always returns the same value. Enforced bysrc/engine/eslint.config.js's determinism guard for the mechanical half, by instruction for the rest. - P8.
foldis idempotent:fold(fold(c, k, ch), k, ch)is canonically equal tofold(c, k, ch). Enforced by a kind conformance test, and made observable in the store by P6 — a non-idempotent fold shows up as a second write with no second action.
7.2 Host Persistence — The Record Store Beneath the Session Store
§7 says the store keeps host metadata "on the store's record" without naming that record as a type. It is one now, because a host supplies it: the session store is core-owned — locking, stamping, save-envelope assembly and profile upsert all live here — and what a host may replace is the narrower job of reading and writing the records underneath.
/** Host-owned. Deliberately outside GameState, and never replayed. */
interface StoredSessionRecord {
sessionId: string;
blob: string; // the canonical serialization (§2), never a live object
audience: ProjectionAudience;
attemptCounter: number;
replayCompatible: boolean;
createdAt: string; // Clock (06 §5.4), never Date.now
updatedAt: string;
profileId?: string; // §7.1 — round-trips here, never through SaveEnvelope
}
interface StoredSaveRecord {
saveId: string;
campaignId: string; // host-side routing only; the authority is the embedded GameState
blob: string; // a serialized SaveEnvelope (§10.2)
savedAt: string; // Clock (06 §5.4), never Date.now — §7.4's sort key and delete precondition
savedAtSeq: number;
audience: ProjectionAudience;
profileId?: string;
}
interface SessionRecordStore {
get(sessionId: string): Promise<StoredSessionRecord | undefined>;
put(record: StoredSessionRecord): Promise<void>;
}
interface SaveRecordStore {
get(saveId: string): Promise<StoredSaveRecord | undefined>;
put(record: StoredSaveRecord): Promise<void>;
/** §7.4. Every record this adapter holds for one profile, in any order — the store sorts. */
listByProfile(profileId: string): Promise<readonly StoredSaveRecord[]>;
/** §7.4. Conditional: remove `saveId` only while its stored `savedAt` still matches. */
delete(saveId: string, expectedSavedAt: string): Promise<void>;
}
interface SessionPersistence {
sessions: SessionRecordStore;
saves: SaveRecordStore;
}
Omitted → in-memory, which is the MVP default. The store keeps its own maps either way and
consults persistence only on a miss, so a host adapter is a durability layer, not a
replacement for the store's bookkeeping.
campaignId on the save record is host-side routing, nothing more. A host that lists "your
saves for this campaign" needs it without deserializing every envelope. It is a copy of what
the embedded GameState already says, and §10.2's cross-checks at load read the embedded value,
not this one — so a divergent copy can misroute a listing but can never load the wrong game.
saveIdis the lookup key onSaveRecordStore.get(saveId)andput(record)address the same record, so an adapter that stores under any other key silently makes every save unretrievable — the write succeeds, the read misses, and nothing fails loudly. Stated because it is exactly the defect the first adapter shipped with.
Where sessionId and saveId come from. Both are minted by the session layer, and both are
seamed: RecordIdSource (06-extensibility.md §5.7) is supplied on
SessionHost and, when present, replaces the layer's own crypto.randomUUID() at exactly the
three call sites that mint one — createSession, loadGame, and saveGame. It is a port
separate from IdSource (06 §5.1) because these two ids are the ones that never enter
GameState: they key the records above, which is host metadata by construction, whereas
gameId and seed are serialized replay inputs. Omitted, behaviour is byte-identical to the
unseamed minting it replaced — which is what makes it additive rather than a change to §7.
Failures are SessionStoreError, not CommandResult. None of SessionStore's methods
carry an error channel — SessionHandle, SaveHandle and Scene have nowhere to put one — so
these stay exceptions. What they are not is opaque:
type SessionStoreErrorCode =
| "unknown_session" | "unknown_save" | "storage_failure" // this section
| "concurrent_modification" // this section, below
| "unknown_campaign" | "invalid_state" | "unknown_kind" // §12, the kernel's own
| "save_requires_migration" | "migration_failed"; // §12, the save boundary's
class SessionStoreError extends Error {
readonly operation: string;
readonly code: SessionStoreErrorCode;
}
Every member is a registered ReasonCode (§12) with a shipped core.reason.* string, so a
client renders code through the string table like any other rejection and never reads
message. That is what makes these safe to surface: a demo showing "could not be saved
locally" is rendering storage_failure, not string-matching English (09 §3).
An adapter throwing is storage_failure, with exactly one classified exception. The store
catches whatever a host implementation raises and re-raises storage_failure, so a Postgres
timeout and a localStorage quota error stay indistinguishable to a client — a client can do
nothing different about either, and a host's own exception type leaking through the store would
put an unbounded vocabulary on the other side of the boundary.
The exception is concurrent_modification, and it exists because §7's two lock domains stop
at the process edge. Per-sessionId locking orders operations within one store instance; a
host running several instances over one database has sessions that no lock here serializes, and
that host is the only party positioned to detect the overwrite. It signals one by branding
an exception rather than by raising a type of its own:
const SESSION_PERSISTENCE_CONFLICT = "SessionPersistenceConflict";
interface SessionPersistenceConflict extends Error {
readonly name: typeof SESSION_PERSISTENCE_CONFLICT;
}
The brand is a string on name, deliberately, and not an instanceof check — a host may
resolve a duplicated copy of this package, across which class identity does not survive.
Two rules bound the carve-out, and they are what keep the paragraph above intact rather than
merely qualified. The vocabulary stays closed: one brand, one code, and every other adapter
exception still maps to storage_failure with no path by which a host adds a third outcome.
And a classified failure must be one the caller can act on differently —
concurrent_modification earns its place because "re-read the session and retry" is a real and
different response, which is exactly what a timeout and a quota error do not have. A later code
needs both arguments made here; a brand invented downstream is not a contract.
A rejected write must not leave the store ahead of its persistence. This failure is actionable — the shipped
core.reason.concurrent_modificationstring tells a player the session changed elsewhere and to refresh — so the store's in-memory record must not retain a mutation that persistence refused. An operation that mutates a cached record before persisting it has to restore or evict that record when the write throws. Otherwise the next read is served from the cache, returns the un-persisted state, and the retry the message asks for cannot succeed.storage_failuretolerated this divergence because its own message promises only that the game is still playable;concurrent_modificationdoes not.
7.3 The Campaign Catalog
listCampaigns is the only operation a client calls before a session exists, and it was the
one operation written as though it could not fail and could not wait. Two consequences followed,
and this section settles both.
listCampaigns(profileId?: string): Promise<CampaignCatalog>;
It is asynchronous because a store is not required to hold its campaigns in memory. The
synchronous signature made that a silent requirement: a fetch-backed SessionStore could not
satisfy it at all, so the first host to try prefetched every summary at composition time and
closed over them. That works and is not what the contract said — it said a store must already be
a registry before it is a store. Every other query on this interface is already Promise-
returning; this one was the outlier, not the norm.
The result carries the strings, so a catalog is renderable without a session. titleKey
stays a LocKey — it is not resolved into the summary — and CampaignCatalog.strings is the
table that resolves it. This is getStrings (§7) applied one layer earlier, for the same reason
and with the same property: no locale is baked into a returned DTO, and a client never
string-matches English.
strings carries exactly the keys the summaries carry, and nothing else. It is not the
registry's table. That bound is load-bearing rather than an optimization: the full table holds
every node's authored prose, so shipping it to a visitor choosing a campaign would hand out the
whole of every story before a session began — a spoiler leak dressed as a convenience, and
precisely the reach-past-the-boundary this section exists to close. A catalog that grows a new
LocKey grows its table by that key.
The catalog is titles and progress. It is never content. No node, no variable schema, no
choice, no achievement definition, no Campaign.content in any form. A client that needs those
starts a session.
Order is the registry's own iteration order, which registry assembly fixes when it freezes (§10.1), and this section does not impose a sort on top of it. Two calls against one registry return the same order; a client that wants a different one applies its own.
Progress, and the profileId that gates it
progress is present on a summary iff listCampaigns was given a profileId, and absent
otherwise — an anonymous catalog reports nothing, matching §7.1's anonymous by default. When
present:
discoveredis the number of distinctTerminalRecord.terminalIds the profile holds for thatcampaignId(§7.1).totalis whatKind.terminalCount(§3.2) reports for that campaign. A kind that omitsterminalCountyields noprogressobject at all for its campaigns, even with aprofileIdsupplied — adiscoveredwith no denominator is worse than silence, because it renders as progress toward an unknown target.
Counts only. Never ids. The catalog says three of seven, never which three, and never which seven. Ending ids are authored content, and a list of them is a table of contents for endings the player has not found — the projection boundary (§9) applied to a surface that sits outside any projection. This is the whole of the "hidden ending" protection, and it is structural: there is no field for an id to travel in.
A profile is read here and nowhere near resolution. This is a query on the store, which
already owns the ProfileStore; advance, project and initialState are untouched and still
cannot see a profile. That line matters more than it looks: a projection that varied by profile
would no longer be reproducible from { seed, actionLog }, and the determinism harness (§14)
would be asserting something weaker than it claims to. Progress is store-assembled precisely so
that it cannot reach the kind.
Degradation matches §7.1's. A missing or corrupt profile yields discovered: 0 with its
ProfileWarning raised through the same path, never a failed catalog. A player cannot be
stopped from browsing campaigns because a progress number could not be read.
Migrating callers
This is a breaking change to the package root, and it is meant to be caught by the compiler.
listCampaigns(): CampaignSummary[] and listCampaigns(): Promise<CampaignCatalog> share no
usable call site: an iteration over the old return value does not type-check against a
Promise, and awaiting it yields an object rather than an array. Every existing caller —
the text client, the MCP list_campaigns tool, and any host — fails to build until it is
updated, which is the intended outcome. A signature that silently accepted the old shape would
leave a caller reading undefined at runtime.
The migration is mechanical and is stated so no caller has to derive it:
// before
const campaigns = store.listCampaigns();
render(campaigns);
// after
const { campaigns, strings } = await store.listCampaigns(profileId);
render(campaigns, strings);
In-memory stores are unaffected in behaviour, only in shape: a store that holds its registry resolves the promise immediately and does no I/O. Asynchrony is what the signature permits, not what it requires.
The operation count does not change, and the API coverage checklist stays at ten rows.
listCampaigns is still one operation and list_campaigns is still its one MCP tool
(09-clients.md §4). Resolving the catalog's strings inside the operation
rather than beside it is what keeps that one-to-one mapping intact — the count is checkable by
counting, and this change leaves the count alone. (§7.4 adds three operations and moves the
count to thirteen; what is stated here — that this change left it alone — is unaffected.)
7.4 Session Lifecycle — Listing, Branching, Deleting
Three operations a host needs and has had to counterfeit: list a player's saves, branch a
session at a point in its log, and delete a save. Both existing hosts maintain a private
shadow index of saves because SaveRecordStore could be addressed only by a saveId the host
had to have remembered — a per-host reimplementation of bookkeeping the store already owns,
which is the same argument 06 §5.2 makes for why SessionStore is core-owned in the first
place.
One rule decides all three: a lifecycle operation manipulates records, never state. None of
them resolves an action, none reaches a Kind, and none may perturb serialize() output for
any session it does not create. That is why they belong on the store rather than the engine, and
why none of them appears in the replay input { seed, actionLog }.
Listing
listSaves(profileId: string): Promise<readonly SaveSummary[]>;
profileId is required here, unlike listCampaigns(profileId?) (§7.3). An anonymous save
is reachable only by the saveId its maker kept (§7.1, anonymous by default); there is no
anonymous population to enumerate, so an optional parameter would have to invent one — either
"every save in the store", which is a cross-player leak, or "none", which is a signature that
lies. A caller with no profile does not call this operation.
Records with no profileId are never returned, and neither are another profile's. The
filter is on the stored StoredSaveRecord.profileId (§7.2), the same field saveGame writes.
The order is total, deterministic, and player-meaningful: savedAt descending, then saveId
ascending. Newest first is what a save list is for; saveId breaks a tie when a host's
Clock resolution puts two saves in the same instant. Both keys are on the record, so the store
sorts and the adapter does not — listByProfile (§7.2) may return any order at all.
savedAtis new onStoredSaveRecord, and the sort is why.savedAtSeqwas the only temporal field available and it could not serve: it counts actions within one session, so two saves from two sessions routinely share a value, and ordering by it is not a total order across a player's saves at all. Without a real stamp the tiebreak falls to a random UUID, which is deterministic and meaningless — an order no host could show a player, so no host could retire its shadow index, so the operation would not do the one job it exists for.StoredSessionRecordalready carriescreatedAt/updatedAton the same reasoning (§7.2); this closes an asymmetry rather than opening a new axis.It is
Clock-stamped (06 §5.4), neverDate.now, and it lives on the record rather than inGameState— the §2 rule, unchanged. It cannot reach a replay because nothing in resolution reads it.Adapter cost, stated rather than discovered. An adapter that persists the record as an opaque blob widens for free. One with an explicit column mapping — Adventures' Postgres store is the case in hand — needs a migration that backfills
savedAtfor existing rows. There is no correct value to invent for a save taken before the field existed; backfill them to the epoch so they sort last, which is honest about the fact that their real time was never recorded.
A summary is metadata, never content. No blob, no SaveEnvelope, no GameState, no
kindState, and no campaign title. Resolving campaignId to a display name is
listCampaigns (§7.3), which already carries the LocKey and the table that resolves it; a
second string table here would be the projection boundary crossed twice for one string.
Degradation matches §7.1's. An adapter that throws surfaces storage_failure like any
other (§7.2). A profile that does not exist is not an error — it lists nothing.
Deleting
deleteSave(profileId: string, saveId: string, expectedSavedAt: string): Promise<void>;
A wrong-profile delete is indistinguishable from a missing save. Both raise
unknown_save, and this is deliberate rather than lazy: a distinct "not yours" code would
confirm that a saveId exists to a caller holding no claim on it, which is an existence oracle
over other players' records. One code, no oracle, and a host that wants to log the difference
can — it has both values.
expectedSavedAt is a precondition, not a convenience. The delete removes saveId only
while its stored savedAt still equals the value the caller observed; otherwise it removes
nothing and raises concurrent_modification. The failure it exists for is the one this unit
names: a player opens a save list, a second writer replaces a record, and the delete authorized
against what the player saw would otherwise erase a record they never looked at. The stamp is
returned by both saveGame (SaveHandle.savedAt) and listSaves, so every caller already holds
one and no extra round-trip is needed to obtain it.
The compare and the delete are one step, not two. SaveRecordStore.delete takes the
expected stamp so an adapter can express it as a conditional statement; the store's own
per-saveId lock domain (§7) covers the single-instance case. A host running several instances
over one database is again the only party positioned to detect the overwrite, and signals it the
way §7.2 already established — by branding SessionPersistenceConflict, which the store maps to
concurrent_modification. No new brand and no new classified failure: this reuses the one
carve-out §7.2 opened, and the argument that earned it holds here unchanged, because "re-read
the list and try again" is exactly the different, actionable response.
A refused delete leaves every record untouched — not merely the addressed one. An implementation that removes the record and then discovers the mismatch has already failed the criterion; the condition is evaluated before anything is removed.
Branching
branchSession(sessionId: string, atActionCount: number): Promise<SessionHandle>;
atActionCount is the number of logged actions the branch retains, so 0 branches at
creation and actionLog.length branches at the present. Valid range is [0, actionLog.length]
inclusive; anything outside it raises invalid_fork_point and writes nothing. A count is used
rather than a LoggedAction.seq because the two coincide only while a log is gap-free, and a
count states the intended meaning — how much of this game — without depending on that.
There is a working reference implementation, and it is not quite this. Issue #266 records Adventures forking by replaying a stored log to an
atSeqand writing aStoredSessionRecordstraight throughpersistence.sessions.put, past the store — which leaves the store's own in-memory session cache unaware of the session it just created, and mints the new id withrandomUUID()rather than through a port. Both are why this is a store operation rather than a documented recipe.Callers converting from it change one thing:
atSeq: nbecomesatActionCount: n + 1for a dense log, which every log this engine writes is. Stated because the two signatures accept the same type and differ by one — the failure mode is a fork one action short, which replays cleanly and is wrong.
The branch is replayed, not copied. The store creates a game from the source's
{ gameId, seed, campaignId, campaignVersion } and submits the retained prefix through the
ordinary path. Truncating the source's blob would leave kindState at the present while the log
claimed the fork point — a state no sequence of actions produces, and one that would serialize
to something replay could never reach.
The branch retains the source's
gameIdand mints only a newsessionId. This is the load-bearing decision of the operation.
gameIdis an envelope field (§2) and therefore inserialize()output, so minting a new one would make "the branch serializes byte-identically through the fork point" false by construction — the assertion would have to compare a serialization withgameIdnormalized out, which is precisely the normalization 06 §5.1 records theIdSourceport as having removed. Retaining it makes the claim literal, and a literal claim is one a golden file can hold.What this costs, stated plainly:
gameIdidentifies a lineage, not a playthrough. Two live sessions can share one. Nothing in the core is affected — the engine never parses, compares, or derives from the value (06 §3) — and the two places that could have been are not: the §7.1 profile upsert is idempotent on(campaignId, achievementId)and(campaignId, terminalId), so a branch re-earning an achievement its parent already recorded writes the same row twice and changes nothing; and a replay fixture (07 §2) holds{ config, actionLog }, which addresses the lineage's inputs and never a session at all.The new
sessionIdcomes fromRecordIdSource.newSessionId()(06 §5.7), notIdSource. That is the port whose values never enterGameState, which is exactly what a branch needs a fresh one of. The unit's own wording saysIdSource; it means this, and the distinction matters because minting fromIdSourceis what would have produced the newgameIdthis decision rejects.
The source session and every save it made are untouched — not re-stamped, not re-serialized, not re-locked beyond the read. A branch is a new record; it is never a mutation of an old one.
profileId is inherited from the source record, because a branch is the same player's game.
An anonymous session branches to an anonymous one.
A session that is not replay-compatible cannot be branched. A StoredSessionRecord with
replayCompatible: false has passed through a migrated load (§10.2), and its log is no longer
guaranteed to regenerate its state — which is the entire mechanism a branch depends on. It
raises invalid_state and writes nothing. Failing here is the sticky-forward rule doing its
job: the alternative is a branch that silently diverges from the game it claims to continue.
Reproducing a stored session from its log
Reconstructing a session's blob from { seed, actionLog } requires IdSource.newGameId
pinned to the original gameId. This is stated because it is the one non-obvious step and
because it has been rediscovered once already: gameId is an input to the envelope, not a
derived value, so a reconstruction run under a fresh IdSource differs from the original in
that field and in nothing else — a difference small enough to be mistaken for a normalization
problem and large enough to fail a byte comparison. 07 §5 named the port for exactly this, and
the runner's counting IdSource is the same mechanism applied to fixtures.
The obligation is checkable in both directions, and both belong in the proof: reconstruction
under a pinned newGameId is byte-identical to the stored blob, and reconstruction under any
other id is observably different. A test that asserts only the first cannot tell a correct
pinning from an engine that ignores gameId altogether.
Authorization is host-owned, and SessionStore stays caller-agnostic
profileId on these operations is a scoping key, not a credential. The store treats it as
an assertion already established and never as one it verifies: it has no notion of a caller, no
session token, no tenancy, and no way to distinguish a host passing a profile it authenticated
from one passing a profile it read off a query string. Authenticating a caller and proving the
profileId is theirs is the host's job, entirely, on both existing hosts and any future one.
This is 06 §2's rule read the only way it can be. Authorization cannot change serialize()
output, so it is outside the determinism boundary and therefore outside the engine — and a
SessionStore that grew an identity model would be a store that could refuse a replay, which is
the one thing it must never be able to do.
The deferred trigger, named so it is not rediscovered as a gap. This decision holds while every host owns its own front door. It reopens when the hosting/NEaaS layer (
SubZeroDev.Platform) serves multiple tenants from one store instance, because at that point "the host authenticated this caller" stops being one statement and becomes a claim the store is the only party positioned to scope. That is a contract amendment when it arrives, not a defect now; it is issue #281 in90-decisions.md§2, Found by the first downstream host.
Error semantics
Every code is a registered ReasonCode with a shipped core.reason.* string (§12), raised as
SessionStoreError (§7.2). One code is new; the rest are the existing vocabulary doing what it
already does.
| Raised by | Code | When | Retryable | Caller does |
|---|---|---|---|---|
listSaves | storage_failure | the adapter threw | Yes | retry; the list is not authoritative state |
deleteSave | unknown_save | no such saveId, or it belongs to another profile | No | drop it from the list; it is not the caller's |
deleteSave | concurrent_modification | stored savedAt ≠ expectedSavedAt | Yes, after re-reading | re-run listSaves and re-confirm before deleting |
deleteSave | storage_failure | the adapter threw | Yes | retry; nothing was removed |
branchSession | unknown_session | no such sessionId | No | the session is gone |
branchSession | invalid_fork_point | atActionCount outside [0, actionLog.length] | No | new — correct the count |
branchSession | invalid_state | the source is replayCompatible: false | No | branching this lineage is impossible, not delayed |
branchSession | unknown_campaign | the source's campaignVersion is no longer registered | No | the content was withdrawn (07 §6's unrunnable, one layer down) |
invalid_fork_point joins SessionStoreErrorCode and needs its core.reason.invalid_fork_point
string registered with the rest (§12); registry construction rejects an override of it like any
other protected core key.
Invariants
Each is written to become an assertion. The session store maintains all of them; none is
delegated to an adapter, which is the point of drawing the seam at SessionPersistence
(06 §5.2).
- L1.
listSaves(p)returns everyStoredSaveRecordwhoseprofileId === p, and no other record. Enforced by code. - L2.
listSavesoutput is sorted bysavedAtdescending, thensaveIdascending, and the order is total — no two distinct records compare equal. Enforced by code. - L3. A
SaveSummarycarries no field that is not onStoredSaveRecord, and neverblob. Enforced by the type; a widening is a contract amendment. - D1.
deleteSaveremoves exactly one record on success and exactly zero on any failure. Enforced by code. - D2. A
deleteSavewhoseexpectedSavedAtdoes not match the stored value removes nothing and raisesconcurrent_modification. Enforced by code, and by the persistence conformance suite deterministically reproducing the interleaving. - D3. A
deleteSavefor another profile'ssaveIdis indistinguishable in its result from one for asaveIdthat does not exist. Enforced by code. - B1. For a session
Sand anynin[0, |S.actionLog|], the branch produced bybranchSession(S, n)serializes byte-identically toSreplayed ton—gameIdincluded, because it is retained. Atn = |S.actionLog|it equalsserialize(S)exactly, which is the cheapest form of the assertion and the one a golden file should hold. Enforced by code. - B2.
branchSessionperforms no write against the source session's record, and no write of any kind on any failure path. Enforced by code. - B3. A branch's
sessionIdis distinct from every other session's; itsgameIdequals its source's. Enforced by code. - A1. No lifecycle operation reads or writes a
Kind, and none appears in{ seed, actionLog }. Enforced by instruction and by the determinism harness (§14) — a lifecycle operation that perturbed replay would fail an existing fixture. - A2.
SessionStorenever verifies aprofileId. Enforced by instruction: there is no credential parameter for it to verify with, which is the structural half of the guarantee.
The coverage checklist moves to thirteen
listSaves, branchSession and deleteSave are three store operations, so 09 §4's checklist
gains three rows and three MCP tools — list_saves, branch_session, delete_save — and the
one-to-one mapping that makes "no AI-specific path" (§13) checkable by counting is preserved
at thirteen. 09 §4 states the rule this follows: a client never works around a missing
operation, and a row added there without an operation here is the signal that logic has leaked
upward. This is the sanctioned direction — the operation first, the row second.
@subzerodev/service-contract is kept in step by a gate, not by discipline. Its generation
step fails the build when a SessionStore method has no corresponding row, so these three
operations produce three rows there or the build stops. That gate is the reason this section can
state a count and be believed — the alternative, a checklist maintained by remembering to
maintain it, is the arrangement 09 §4 already calls checkable by counting precisely because
nothing is left to memory.
8. Randomness
Fully specified and built. The core owns the seeded PCG32 generator
(src/engine/src/core/determinism/pcg32.ts, verified bit-identical
to reference vectors) and hands each resolution a scoped handle derived from
(seed, streamId) via deriveStream.
Randomness is derived, never carried. deriveStream(seed, streamId) is a pure
function: the same pair always yields the same generator, and different streamIds are
independent. So a resolution takes a fresh handle, draws from it, and drops it — there
is no generator state to thread through the envelope (§2), and replay needs only
{ seed, actionLog }.
type StreamId =
| { kind: "action"; seq: number }
| { kind: "system"; system: string; seq: number }
| { kind: "agent"; agentId: string; seq: number }
| { kind: "tick"; tick: number; system: string };
interface RngHandle {
nextInt(minInclusive: number, maxInclusive: number): number;
nextPercent(): number;
pick<T>(items: readonly T[]): T;
weightedPick<T>(items: readonly { item: T; weight: number }[]): T;
}
RngHandle exposes no toState(): nothing reads it back. (Pcg32.toState remains on
the primitive, for tests and for reference-vector verification.)
Stream-id encoding is part of the contract. deriveStream hashes a string, so the
StreamId → string mapping is normative — change it and every seeded outcome changes.
It is exactly:
{ kind:"action", seq } → `action:${seq}`
{ kind:"system", system, seq } → `system:${system}:${seq}`
{ kind:"agent", agentId, seq } → `agent:${agentId}:${seq}`
{ kind:"tick", tick, system } → `tick:${tick}:${system}`
Substreams (games/04-engine-specification.md §3.2) mean adding a draw in one place never renumbers another, and
a rival kind's draws never perturb the player's. The MVP uses the action stream for
play plus two system streams; the machinery for more is already there.
| Stream | Derived for | Why it is its own stream |
|---|---|---|
action:${seq} | submitAction (§4) | The play spine — one stream per resolution |
system:start:0 | createGame's initial settle (§4) | A start-of-game draw must not collide with the first action's, which shares seq: 0 |
system:view:${seq} | the read-only calls — scene, availableActions, view (§6, §9) | A kind that ever drew randomness while rendering would otherwise share a stream with the next submitAction at the same seq, and rendering twice would then change the game |
The
viewstream is normative even though nothing draws on it today. No shipped kind takes a random draw during projection, and none should — projection is a narrowing of state, not a resolution. ButKindContextis one type and a read path must supply anrnghandle from somewhere; supplying the action stream's would let an accidental draw during rendering silently perturb the next action, which is exactly the class of bug substreams exist to make impossible. Giving reads their own stream costs oneStreamIdencoding and removes the failure mode by construction. The consequence is that the encoding above is as normative as the other two: changing it changes nothing observable today, and would change every seeded outcome the day a kind does draw while rendering.
What goes in
agent.seqis normative, and it is not the action seq. It is the agent's own draw counter, stored on that agent inkindStateand incremented per draw. Keying it to the action would make an agent's randomness depend on how many actions preceded it, which is precisely what a per-agent stream exists to avoid.
The
tickvariant is for world-level draws in a kind whose turn advances simulated time — guest spawning, incident rolls, weather. Keying them bytickrather than byseqis what makes a batch of ticks produce the same result as the same ticks taken singly;12-world-graph-kind.md§5 states the property and why it is load-bearing.systemhere names the drawing system, not aStreamIdvariant, so two systems drawing on the same tick stay independent.
weightedPickconstrains content. The built implementation requires every weight to be a positive integer and throws otherwise. That makes it a load-time content rule, not a runtime surprise — Tier 1 validation enforces it (03 §11).
9. Projection
Clients receive a projection, never raw state (architecture §7). The core runs the mechanism; the kind supplies the narrowing.
type ProjectionAudience = "player" | "ai";
interface PlayerView {
gameId: string;
status: GameStatus;
kindView: unknown; // kind-narrowed — e.g. StoryGraphView (03 §9)
}
// Engine.view(state, audience):
// kind = kinds[state.kindId]
// return { gameId, status, kindView: kind.project(state.kindState, audience, ctx) }
The core guarantees the envelope's own hidden fields (seed, actionLog,
kindState raw) never reach a client except through kind.project, which is
responsible for excluding the kind's hidden state (03 §9 lists the story-graph
exclusions). The ai audience is the rival/AI view; widening it is a difficulty
setting, declared and visible (games/04-engine-specification.md §6.1) — never granted by accident.
Why
aiand notagent. "Agent" was doing two incompatible jobs across these specs: an AI player here, and a simulated entity inStreamId({ kind: "agent"; agentId }, §8) and throughout12-world-graph-kind.md, where guests and staff are agents. A spatial kind full of autonomous entities made the collision unavoidable, so the audience took the new name andagentnow means exactly one thing: an entity the simulation owns. Renamed before any code existed, which is the only cheap time to do it.
9.1 The Copy Boundary — the Kernel Owns It
project may return values that alias kindState, and every shipped kind does. The core
neutralizes that at the boundary rather than asking each kind to avoid it: every core surface
that carries a kind.project result to a caller returns a structural clone of it. The rule is
the kernel's, applied in one place, and a kind neither implements nor can defeat it.
Two surfaces carry a projection, and the rule binds both:
Engine.view(state, audience).Scene.view(§6), built byEngine.scene(state). §6 declares that field to be the §9 projection, so this is the same obligation rather than a parallel one.
Both are declared in src/engine/src/core/kernel/engine.ts.
What a caller may rely on: mutating anything reachable from a returned view — nested records and
arrays, to any depth — leaves GameState, every later view() or scene(), serialize()
output, and the action log unchanged. What a caller may not rely on is object identity. Two
projections of one state are equal and are never the same object, so a client must not use a view
as a cache key or compare views by reference.
Immutable primitives are not copied, and need not be. Strings, numbers, booleans, null and
undefined are values; a copy of one is indistinguishable from it. The rule governs reachable
containers only. readonly in a view type is a compile-time annotation this rule does not depend
on and is not enforced by: WorldGraphView marks its arrays readonly and StoryGraphView does
not, and both are equally protected, because neither annotation is what protects them.
The one obligation this places on a kind: a project result must be plain, structurally
cloneable data — no functions, no class instance whose prototype the view depends on, nothing
that cannot survive the boundary. A kind that returns such a value fails at its first view()
rather than silently handing out a live reference, which is the failure mode worth having.
Engine.availableActions is outside this rule and needs nothing from it: AvailableAction (§6)
is a flat record of primitives and carries no projection.
Why the kernel and not the kind. A kind is the wrong owner for an invariant no kind can be checked against. All three alias state while projecting today, and each is locally reasonable — a projection legitimately reuses the value it is narrowing. Placing the rule on kinds turns it into an instruction every future kind must re-obey with nothing to catch a lapse, and this repository has already recorded that exact shape failing twice (
90-decisions.md— the emitted → registered gap, and the end-of-week stub register: "a rule with no gate, checked only when someone compares the two sets by hand"). At the kernel it holds by construction, for kinds that do not exist yet.The cost is one copy per read, and it is affordable precisely because a projection is narrow by construction — this section and each kind's own projection section exist to keep it so.
10. Content, Saves, Migration
10.1 Content Registry
interface ContentRegistry {
readonly campaigns: ReadonlyMap<string, Campaign>;
readonly strings: ReadonlyMap<LocKey, string>; // built form — see the authoring boundary below
readonly resolution?: ResolutionId; // the pack set this was folded from (11 §4, §6)
}
interface Campaign {
id: string;
kindId: KindId;
version: string;
titleKey: LocKey;
content: unknown; // kind-specific — e.g. StoryGraphCampaign (03 §1)
/**
* Migrates a `kindState` forward when *this campaign's own* content ids or shape changed
* between `fromVersion` and this `version` (§10.2) — a renamed node or achievement id.
* Optional, for the same reason `Kind.migrateState` (§3) is: most version bumps rename
* nothing a save references. Runs at the save-load boundary only, after any
* `Kind.migrateState`, never during `advance`.
*/
migrateState?(kindState: unknown, fromVersion: string): CommandResult<unknown>;
}
resolutionis optional, and the optionality is the contract. It is present only on a registryresolvePacksfolded from an ordered pack set (11 §4) —buildContentRegistryknows no packs exist and has none to name. Making it required would mean inventing a digest over content that came from no pack, which under 11 §6 would changecampaignVersionfor every existing single-campaign registry and therefore the version every existing save records. The engine never reads it either way: it is inert identity, not an input.
Two
migrateStatefunctions, two axes.Kind.migrateState(§3) is typedCommandResult<KState>because a kind knows its own state type; this one is typedCommandResult<unknown>because a campaign does not — it only remaps ids inside a state whose shape the kind already fixed. That is also why §10.2 runs them in that order.
Content excludes envelope identity. A kind's
content(e.g.StoryGraphCampaign, 03 §1) holds only kind-specific data — it does not repeatid,kindId,version, ortitleKey, which live onCampaignhere. Authored inline strings are lifted intoregistry.stringsat build time (the authoring boundary below), socontentcarries no per-campaign string table at runtime. Same anti-drift rule askindState(§15).
The registry is frozen and pre-validated (§11) before the engine sees it. The engine performs no I/O; a loader package builds the registry from files (architecture §1).
The Authoring → Registry Boundary
"Built from files" is a typed step, not a hand-wave. Authors write player-facing text
inline (03 §12); the runtime sees only LocKeys. Two types and one pure function make
that a contract:
interface AuthoredText { key: LocKey; text: string; }
interface BuiltCampaign {
campaign: Campaign; // runtime form — LocKeys only
strings: ReadonlyMap<LocKey, string>; // lifted out of the source
}
Each kind declares a source type paired with its runtime type — for the flagship,
StoryGraphCampaignSource mirrors StoryGraphCampaign (03 §1) with every player-facing
field typed AuthoredText instead of LocKey. A pure builder validates the source,
replaces each AuthoredText with its key, and returns BuiltCampaign. Repeated identical
key/text pairs deduplicate; the same key with different text is a hard error.
Registry assembly then validates every built campaign (§11), merges the protected core strings (§12) with kind and campaign strings, and freezes both maps.
Parsing and files live outside the engine. YAML/JSON decoding and filesystem access belong to an outer adapter that feeds
unknowninto source-schema validation. The engine package never reads a file — that is what makes "the engine performs no I/O" checkable. The MVP ships one locale, English; additional locales are post-MVP and need no type change, only more string tables.
10.2 Save Envelope and Migration
Carried from games/04-engine-specification.md §16. A save wraps the GameState envelope with the metadata needed
to load it safely.
interface SaveEnvelope {
saveFormatVersion: number; // shape of THIS envelope
serializationVersion: number; // version of the canonical serializer that wrote `state`
engineVersion: string;
kindId: KindId;
kindVersion: string; // a kind's code can change independently of the engine
campaignId: string;
campaignVersion: string; // the published version this save was made under
replayCompatible: boolean;
checksum: string;
state: GameState;
}
The four version fields exist because the four things they track change independently:
the save wrapper's shape, the serializer, the engine, and a kind's code can each move
without the others. Compression and host-side metadata (playtime, title, thumbnail) are
deliberately absent — compression has no consumer yet, and host metadata belongs on the
session-store record (§7), outside the replayable GameState, so it can never perturb
byte-identical replay.
Built during W31 (SessionStore.saveGame/loadGame, core/persistence/envelope.ts),
closing what had been a specified-but-unbuilt mechanism since W3. Not every field gates a
load the same way:
saveFormatVersion/serializationVersionmismatch fails loudly (save_requires_migration, §12) — this unit introduces both, so neither has a real prior value to migrate from yet; a future unit earns that logic only once one of them actually moves.engineVersionmismatch never gates a load by itself — recorded for provenance only, per this section's own reasoning that it changes independently of the others.kindVersion/campaignVersionmismatch is the actual migration:Kind.migrateState(§3) runs first for a kind-state shape change, thenCampaign.migrateStatefor a content-id rename — a shape change is a precondition for content remapping to address the right fields. Either axis missing its migration function when a mismatch is present fails loudly the same way; a registered migration that itself fails does too (migration_failed, §12).checksumcovers{ state, replayCompatible }, not the whole envelope. The scope is narrow by design and the remaining fields are protected differently — by cross-checks that a checksum could not perform anyway:campaign.kindIdagainst the outerkindId, and both outer ids against the embedded (and therefore checksummed)GameState's own.stateis in scope because it is the thing being protected;replayCompatibleis in scope because nothing else guards it, and flipping a migrated save'sfalseback totruein the stored blob would otherwise silently defeat the sticky-forward rule below.90-decisions.mdrecords why this is the accepted scope rather than a gap.- A successful migration sets
replayCompatible: false, sticky forward — once a lineage has passed through a migrated load, it never becomes replay-compatible again, even across further saves that need no further migration. - A migration is idempotent against its own output. Migrating a save, saving it, and
migrating again — which is what happens whenever a player loads, plays nothing, and saves,
or whenever a fixture is regenerated — must reach a canonically identical
kindState. The first pass restampscampaignVersionto the campaign's own (resolveSaveEnvelope), so the second pass finds no mismatch and runs no migration at all; that is why it holds, and stating the property rather than the mechanism is what makes it testable against a migration that mutates in place, accumulates, or appends.replayCompatiblestaysfalsethrough both, which is the sticky-forward rule above doing its work.
What each axis may do to state, stated as a rule rather than per kind. Kind.migrateState
may change the shape of kindState — add a field with a default, drop one, retype one. A
campaign migration may only re-address published content ids the state references, and drop
or default the references that no longer resolve. Neither may invent play: a migration that
awarded money, advanced a week, or unlocked an achievement would make the same save load
differently depending on which version it passed through, which is the class of defect
replayCompatible: false exists to record and not to license. A campaign migration that
finds a reference it can neither remap nor drop fails with migration_failed rather than
guessing — this is architecture §8's "map old ids forward or fail loudly", said from the
implementation side.
Migration functions are engine-or-content-owned, never a host-supplied port: a port may
supply anything that cannot change serialize() output
(06-extensibility.md §6), and remapping old ids is definitionally
a change to it. Proven in core/persistence/envelope.test.ts against a synthetic
kind/campaign, not a real campaign republish — every shipped campaign is still at
1.0.0, so there is nothing real to migrate from yet either (plans/38-save-migration-programme.md).
saveFormatVersionvsGameState.formatVersion— different things.saveFormatVersionversions this wrapper;GameState.formatVersion(§2) versions the envelope inside it. They are separate becauseEngine.serialize/deserializeround-trip a bareGameStatewith no wrapper at all (§4) — the determinism harness (§14) and the golden files compare exactly that string. Without its own stamp, a standalone serialized envelope would carry no version information. Both move independently; a loader reading aSaveEnvelopechecks both.
The migration hazard, made concrete (architecture §8). A save records the
campaignVersion it ran. Loading it against a different published version runs
migration, which must map old ids forward (a story-graph node id that was renamed) or
fail loudly — never strand the player on content that no longer exists. A migrated
save is replayCompatible: false: its action log can no longer be guaranteed to
regenerate its history, because the rules changed.
10.3 Why Not Event Sourcing
The design carries an action log, deterministic replay, and byte-identical state — the ingredients of event sourcing. It stops deliberately short of adopting it as the persistence model.
Pure event sourcing makes current state a derived projection: state = replay(log),
and you persist the log, not the state. That collides head-on with the migration rule
above. A migrated save is not replay-compatible — its log can no longer regenerate
its state across a rule change — so under pure event sourcing a migrated save would be
unloadable. Instead the core persists current state (the envelope) and keeps
the log: you get event sourcing's benefits where they pay off — the determinism harness
(§14) and bug reproduction replay from { seed, actionLog } within one version — without
its cost, which is loads that break the moment the rules move. This hybrid is a choice,
not a gap.
11. Tiered Validation
Every campaign is validated before the registry is frozen. The core runs the
tiers; the kind supplies the checks via validateCampaign.
interface ValidationResult {
ok: boolean; // false iff any Tier-1 error
errors: ValidationError[]; // Tier 1 — hard fail
warnings: ValidationWarning[]; // Tier 2 — load but flag
}
interface ValidationError {
code: ReasonCode;
messageKey: LocKey;
path?: string; // where in the campaign
details?: Readonly<Record<string, string | number>>;
}
interface ValidationWarning { code: ReasonCode; messageKey: LocKey; path?: string; }
-
Tier 1 — load-time, hard fail: referential integrity, schema conformance, declared variables, path validity, duplicate ids, missing string keys. (Story-graph's Tier 1 is 03 §11.)
-
Tier 2 — load-time, warning: unreachable content, unexpected cycles, and
no_reachable_choice— a campaign that settles straight to an ending with no choice node reachable from the start. It loads and plays (§3,InitialStateResult.statusreports"ended"immediately); the warning tells an author their campaign is non-interactive without forbidding a deliberate vignette or a single-scene test fixture. -
Tier 3 — simulation-time: unwinnable campaigns, dead-end states — found by running, not reading. Not part of load, and not part of §14 either: the determinism harness compares a build against itself and cannot answer whether an ending is reachable. Tier 3 is an author-facing check run out of band —
npm run validate-campaignin the engine package, tooling rather than shipped engine code (architecture §9.2).Its one contractual property is what a clean result means. The search is bounded by construction — an explored-state cap, a turn-depth cap, and the same settle-step cap the real engine enforces — so it reports
boundedwhenever any cap was reached anywhere. A bounded result means "not proven", never "passed." A caller that reads the two as the same thing gets a guarantee the checker never offered: it declines to credit an ending found exactly at a cap, precisely so it never claims to have explored more than it did.
Why tiered: "the engine validates AI-authored content" (architecture §9) is only a safety property once you say what validation is and what is decidable when. AI output is data; all data goes through the same tiers, whatever produced it.
Which string table validation checks against
Two of this section's requirements look like they need each other's output. Every campaign
must be validated before the registry is frozen (above), and §12 requires that a registered
reason code with no localized message fails validation — but the final merged string table on
ContentRegistry is a product of registry assembly (§10.1), which runs after validation
clears. Read literally, neither could go first.
They are not in conflict, because "a LocKey resolves" is scoped per campaign, not against
the merged table:
- A campaign's own
LocKeys —titleKey, and everythingKind.validateCampaignreaches insidecontent— are checked against that campaign's built string table, theBuiltCampaign.stringsthe authoring builder lifted out of its source (§10.1). That table is complete for the campaign by construction: a key it authored but did not lift cannot exist. Merging adds other campaigns' keys, which this campaign has no business referencing anyway, so the merged table would not make the check stricter — only later. - Reason-code messages are checked against the declaring kind's own message table,
once per kind rather than once per campaign, and against the core's shipped
core.reason.*set for the base codes. A kind therefore fails registry construction for a gap in its own vocabulary before its messages ever reach the merge.
So the ordering is: validate each campaign against its own strings and each used kind against
its own messages; only then assemble, merging core, kind and campaign strings and freezing
both maps. buildValidatedContentRegistry
(src/engine/src/core/validation/tiered.ts) is the sanctioned entry point that runs them in
that order, and it threads each used kind's messages into assembly so the frozen table
carries them.
For a pack set rather than a single campaign batch, buildValidatedPackRegistry(packs: readonly ContentPack[], kinds: KindRegistry): CommandResult<ContentRegistry> (same file) is
the sanctioned entry point (11 §3, W76): it runs resolvePacks (§7 below) to fold the set,
then this same validate-and-assemble sequence against the folded campaigns and string table,
and reattaches the fold's resolution id — neither stage alone can produce a validated,
resolution-stamped registry. It is exported from the package root alongside
buildValidatedContentRegistry.
This is a clarification of scope, not a weakening. Nothing here permits an unresolved key into a frozen registry. The merged table is a superset of every per-campaign table plus the core's and the kinds' own, so a key that resolves in the narrower table resolves in the wider one; checking early is strictly earlier, not looser. What it does rule out is a campaign silently depending on a different campaign's authored string — which the merged table would have let through, and which is a real drift surface, since pack resolution (11 §6) can change what else is in that table without this campaign changing at all.
12. Reason Codes, State Changes, Messages
Kind-agnostic base vocabulary; kinds extend it (Kind.reasonCodes). Clients never
string-match English (games/04-engine-specification.md §2.3).
type LocKey = string; // key into the string table; stable, additive, never renamed
type ReasonCode = string; // stable, machine-readable; additive, never renamed
const BASE_REASON_CODES = [
// the original seven — the kind-agnostic play vocabulary
"action_not_available", "unknown_action", "requirement_unmet",
"session_ended", "read_only_field", "check_succeeded", "check_failed",
// the pure engine kernel: createGame / submitAction / deserialize rejections
"unknown_campaign", "unknown_kind", "invalid_state",
// registry assembly (§10.1)
"string_conflict", "protected_string_key", "duplicate_campaign_id",
// the core's own Tier-1 checks (§11, §17)
"invalid_identifier", "invalid_loc_key", "missing_string_key",
"missing_kind_reason_message",
// the profile store (§7.1) — mirrors ProfileWarningCode
"profile_missing", "profile_corrupt", "profile_write_failed",
"profile_kind_data_unreadable", "profile_kind_data_rejected",
// the save-load boundary (§10.2)
"save_requires_migration", "migration_failed",
// host persistence (§7.2)
"unknown_session", "unknown_save", "storage_failure", "concurrent_modification",
// session lifecycle (§7.4)
"invalid_fork_point",
// the audit vocabulary — a `StateChange.reason`, not a rejection (below)
"achievement_unlocked",
// content-pack resolution (11 §7) — `resolvePacks`, `registry/packs.ts`
"pack_kind_mismatch", "duplicate_campaign_id_in_pack", "pack_dependency_missing",
"pack_dependency_version_conflict", "pack_dependency_cycle", "pack_override_unexpected",
] as const;
The base set grows; it was never fixed at the MVP. The original seven were the vocabulary one turn needs. Everything after them was added by a unit that found a cross-kind failure mode with no code that fitted — the kernel's three rejections, registry assembly's three, the core's own Tier-1 four, the profile store's five, the save boundary's two, host persistence's four, session lifecycle's one, the audit vocabulary's one, and content-pack resolution's six. That is the intended shape: a code is registered when a real caller produces it, not pre-declared from this list. Because
ReasonCodeis additive, never renamed (above), growth costs nothing — a client switching on a code it has never seen falls through to the localized message, which the core ships for every base code. Expect this list to keep growing, and keep it in step withsrc/engine/src/core/kernel/reasons.ts, which is where the shipped set and its messages actually live.
The core ships their strings. Every base code has a default-English message under a
reserved core.reason.* namespace (core.reason.unknown_action, …), shipped with the
engine. Registry construction (§10.1) merges core strings with kind and campaign strings
and rejects any attempt to write into core.reason.* — a campaign cannot restyle what
an engine-level error says, because clients and tooling depend on those meanings being
stable. Kinds own the strings for codes they add (Kind.reasonCodes); campaigns own
their narrative strings. Validation fails if any registered reason code has no localized
message — that is what makes "clients never string-match English" enforceable rather than
aspirational.
interface StateChange {
path: string; // audit record, not a write path (games/04-engine-specification.md §10.4)
op: "set" | "increment" | "decrement";
value: string | number | boolean;
previous?: string | number | boolean;
reason: ReasonCode;
visible: boolean;
}
interface OutcomeMessage {
key: LocKey;
params?: Readonly<Record<string, string | number>>;
tone?: "neutral" | "positive" | "negative" | "absurd";
visible: boolean;
}
interface CommandResult<T> { ok: boolean; value?: T; errors: ValidationError[]; warnings: ValidationWarning[]; }
interface ActionResult extends CommandResult<GameState> { changes: StateChange[]; messages: OutcomeMessage[]; }
StateChange is an audit record emitted by typed reducers, never the mutation
mechanism — the discipline the simulation kind arrived at (games/04-engine-specification.md §10.4). It feeds
history and the transparency requirement; visible gates what a client may show.
StateChangeis not logging. It is a domain record: localized, returned inAdvanceResult, persisted by what the store keeps, and shown to players. Operational logging and tracing is a separate channel that is emitted to a sink, never returned, never localized, and free to be discarded entirely with no behavioural difference —05-observability.md§1 draws the line and §2 explains why merging the two would break determinism.
Two StateChange shapes are conventions this platform invented rather than derived from
the ancestor, both real in code and load-bearing before either was written down here —
03-story-graph-kind.md §7's achievement_unlocked and §5's
consequence_applied. Restated exactly as the code emits them, kind-agnostic in structure
even though the two examples are both story-graph's:
// An achievement unlock (03 §7). The path reuses the condition-field name (§18) an
// `achieved.<id>` check already reads, so unlocking and querying agree on one name.
{ path: `achieved.${achievementId}`, op: "set", value: true,
reason: "achievement_unlocked", visible: true }
// A variable write from resolving consequences (03 §5). One coalesced change per
// touched variable per batch, not one per typed op — `op` is always "set" regardless of
// which increment/decrement/set operations actually ran, because 03 §5's clamp-after-
// all-effects rule means an intermediate op has no individually meaningful audit value.
// `previous` is the pre-batch value; `visible` mirrors the variable's own declaration.
{ path: `var.${name}`, op: "set", value: <final value>, previous: <pre-batch value>,
reason: "consequence_applied", visible: <declaration's own `visible`> }
Both are conventions, not requirements — a future kind may need a different shape for an
analogous concept, provided it documents that shape here the same way. What they fix is the
pattern: an audit record's path names the thing that changed using the same string a
Condition would read to check it, and reason identifies why using a stable code a
kind-agnostic session store (or a client) can switch on without string-matching prose.
A
StateChange.reasonis a registeredReasonCodelike any other, and both of these are registered.StateChange.reasonis typedReasonCode, andvisiblegates client display — so an audit record can reach a client exactly the way a rejection can, and owes a resolvable message for the same reason. Neither of the two above had one until reconciliation registered them, which is why this is now stated rather than assumed:achievement_unlockedis base vocabulary (core.reason.achievement_unlocked) because the session store's profile upsert (§7.1) switches on it without knowing which kind emitted it, whileconsequence_appliedis kind-owned (story-graph.reason.consequence_applied,src/engine/src/kinds/story-graph/reasons.ts) because only that kind has a consequence. A kind inventing a third audit reason registers it the same way; there is no separate audit namespace exempt from §12's completeness rule.
Kind-owned reason codes carry their own messageKey namespace, distinct from event
names. A kind's Kind.reasonCodes need a localized message the same way the base set
does (above), and the convention is <kindId>.reason.<code> — no kind. wrapper, unlike
05-observability.md §9's event namespace,
kind.<kindId>.<event>. The two are easy to conflate because they differ only by one
segment, which is exactly why this needs stating rather than assuming: a reason-code
message key and an event name are different vocabularies serving different consumers
(one renders to a player, one traces a resolution), and neither is a namespace for the
other. story-graph.reason.unknown_condition_field
(src/engine/src/kinds/story-graph/reasons.ts) is the shipping example — a code this kind adds for a
condition-evaluation failure with no analogue in the base set, so it has no home but a
kind-owned namespace.
13. The MCP Surface
The tool table itself — args, returns, one tool per session-store operation — moved to
SubZeroDev.ServiceContract's
mcp-tool-contract.md:
it's a hosting-facing contract, not core engine material, even though McpTools (implemented
in src/engine/src/mcp/server.ts; see Engine Package) wraps this
repo's own session store (§7) with no runtime
dependency, tested end to end against it (TODO.md W17). Architecture §10 still holds:
no game logic in the adapter, no AI-specific path.
14. Determinism Harness
The acceptance test with teeth (MVP §5, games/04-engine-specification.md §18.4): a { config, actionLog }
fixture replays to a byte-identical serialize().
interface PlaythroughFixture {
name: string;
config: NewGameConfig; // includes a fixed seed
actionLog: LoggedAction[];
}
// runner: createGame(config) → for each logged action, submitAction → serialize final state
- Golden files — committed fixtures with expected
serialize()output; a one-byte diff catches an unintended behaviour change across the whole engine. - Property tests — N random seeds, each run twice, outputs compared; catches non-determinism on paths no fixture touches.
- Sink independence — every fixture replays twice, once with
nullEmitterand once withcreateRecordingEmitter(), and bothserialize()outputs must be byte-identical. This is what makes observability (05-observability.md§2) safe to have inside a deterministic core: it catches a kind that branches on emission, which no state-only golden file would notice. - Stream reproducibility — the same fixture under
createRecordingEmitter()twice yields the identical event sequence, so the event stream is itself a golden-fileable artifact (05 §5).
Canonical serialization (§10, built) and seeded RNG (§8, built and reference-verified) are the two properties that make byte-identical achievable at all.
This harness compares a build against itself. It cannot answer did this change alter a game that already exists — a change that alters every game identically is perfectly deterministic and runs green here. That question needs a different comparison, against a previous build, and a projection that survives an intended serialization change.
07-replay.mdspecifies it.
15. How the Story-Graph Kind Plugs In
Concrete mapping — and the reconciliation this document forces on
03-story-graph-kind.md.
| Core concept | Story-graph realization |
|---|---|
GameState.kindState | StoryGraphKindState — current node, variables, turn, visit counts, unlocked achievements, ending id |
Kind.advance(actionId) | submitChoice → settle (03 §8.2); actionId is the choice id |
AvailableAction | a node choice, gated by showWhen / requirements (03 §4) |
SceneBody | the node's textKey, interpolated (03 §3.1) |
Kind.project | StoryGraphView (03 §9) — turn, visible stats, unlocked achievements, ending; hides non-visible variables and visit counts. Scene text and choices are the generic Scene, not repeated here |
Kind.validateCampaign | 03 §11 |
Kind.outcome | 03 §8.5 — terminalId is the endingId; terminal is "settled onto an EndingNode" (§3.2) |
Kind.terminalCount | 03 §8.5 — distinct endingIds across the campaign's EndingNodes; the denominator in CampaignProgress (§7.3) |
RngHandle.weightedPick | random-transition node resolution (03 §3) |
Reconciliation (done in 03). Writing this seam exposed that
03's state duplicated envelope-owned fields —version,campaignId,campaignVersion,seed,status, and the choice log. Those belong to theGameStateenvelope (§2), not the kind.03§8.1 now definesStoryGraphKindStateas the kind-specific subset only:interface StoryGraphKindState {currentNodeId: string;variables: Record<string, VarValue>;turn: number; // kind-maintained (settle advances it)visitedCounts: Record<string, number>;unlockedAchievements: string[];endingId?: string;}The choice log becomes the envelope's generic
actionLog;turnstays on the kind because a "turn" is kind-specific (a node transition here, a week in the simulation kind).
16. What This Unblocks
With the seam typed and every MVP-blocking gap decided
(OPEN-QUESTIONS.md §1), the build runs against real contracts:
- The pure
Engine(§4) —createGame,submitAction,scene,view, serialize. - The
SessionStore(§7) and theProfileStorebeside it (§7.1). - The registry and its authoring builder (§10.1).
- The story-graph
Kindimplementation (§3, §15) against03-story-graph-kind.md. - The determinism harness (§14) — now that fixtures have a type.
- The MCP server (§13) and text client — thin adapters over
SessionStore.
Nothing above is speculative: every type here is exercised by the MVP
(MVP.md). TODO.md sequences it as units of work W0–W19.
17. Identifier Conventions
One fixed shape for every id, so validation, tooling, debugging, and authoring can rely on it. A peer-review recommendation, adopted before content scales.
| Kind of id | Shape | Example |
|---|---|---|
| Campaign | kebab-case | bulgaria-bureaucracy |
| Node | snake_case | government_office |
| Choice | snake_case, unique within its node | begin_again |
| Variable | snake_case | office_visits |
| Achievement | snake_case | it_builds_character |
| Ending | snake_case | it_builds_character |
LocKey (localization) | dotted, type.id[.field] | event.pipe_disaster.title, choice.wait, stat.money |
| Reason code | snake_case verb/state | requirement_unmet |
Rules: ids are stable once published (a rename is a migration, §10.2); ids are ASCII
[a-z0-9_-] only; LocKeys namespace by content type so string tables stay navigable.
Tier-1 validation (§11) enforces the character set and uniqueness.
18. Frozen Primitives
Two shared primitives are held deliberately small, because these are the surfaces that grow without bound if left open (a peer-review caution taken up-front).
The Condition operator set is closed. The comparison operators
(equals, not_equals, less_than, less_or_equal, greater_than, greater_or_equal,
in, not_in, contains, has_tag, has_flag) plus the tree combinators
(all/any/not) and quantifiers (exists/count) are the whole surface — shared with
the simulation kind (games/04-engine-specification.md §13.1). Tempting additions — between, matches,
arithmetic, inventory() / relationship() / distance() helpers, nested expressions —
are out unless a concrete campaign need justifies each one individually. Every
operator is permanent maintenance: a new one must be validated, evaluated, projected,
migrated, and taught to every tool. The bar to add is high on purpose.
The shape itself, as ported into this repository (core/condition/types.ts):
type ComparisonOperator =
"equals" | "not_equals" | "less_than" | "less_or_equal" |
"greater_than" | "greater_or_equal" | "in" | "not_in" | "contains" |
"has_tag" | "has_flag";
// `value` is optional: `not_equals` against an absent field is authored as `value: undefined`,
// and `JSON.stringify` drops an undefined-valued key, so the wire document never carries it.
interface ComparisonCondition { field: string; operator: ComparisonOperator; value?: unknown; }
interface AllCondition { all: Condition[]; }
interface AnyCondition { any: Condition[]; }
interface NotCondition { not: Condition; }
interface ExistsCondition { exists: { collection: string; where: Condition }; }
// count's own comparison is always two numbers (a match total against `value`) — only
// the six ordering/equality operators, never the array/string-shaped ones, which would
// type-check but always throw at evaluation.
type CountComparisonOperator =
"equals" | "not_equals" | "less_than" | "less_or_equal" | "greater_than" | "greater_or_equal";
interface CountCondition {
count: { collection: string; where: Condition };
operator: CountComparisonOperator;
value: number;
}
type Condition =
ComparisonCondition | AllCondition | AnyCondition | NotCondition |
ExistsCondition | CountCondition;
// What a caller supplies to the evaluator, which knows nothing itself about `var.*`,
// story nodes, or any other kind's field vocabulary.
interface ConditionResolver {
field(path: string): unknown;
collection(name: string): readonly ConditionResolver[];
}
One field of the ancestor's shape did not port. games/04-engine-specification.md
§13.1's Condition also carries a CollectionSelector — a closed union of
simulation-kind paths (player.inventory, world.npcs, …). None of those are
kind-agnostic, so collection here is a plain string, and which strings are legal is
entirely up to whichever kind resolves them (kinds/story-graph/conditions.ts for the one
kind that exists today). The ancestor citation above stays as provenance — per
CLAUDE.md, every games/… citation is provenance, not a second authority — but this
section, not that document, is now where the shape itself lives.
Reason codes are additive, never renamed (§12) — saves and replay logs reference them, so a rename breaks old data.
19. Published Narrative Authoring
The package root is the runtime contract. @the-running-dev/game-engine/authoring is the
separate author-time contract for repositories that own campaign source. It exports the
generic campaign builder, the story-graph source builder, the simulation source builder with
its content-definition source types, the shared adventure builder with its source factory and
its migration helper, portable serialization and manifest digests, and replay-runner
types/functions. It is deliberately a subpath: a runtime host must not import authored
campaign source merely to play published portable JSON.
A kind's export split follows one rule regardless of which kind it is: the builder and its
source types are author-time and belong on /authoring; the campaign, state, view and outcome
types are what a runtime host compiles against and belong at the root
(design/30-slices.md, W88). buildWorldGraphCampaign's root placement predates the subpath
and is an exception noted here rather than moved.
SubZeroDev.Adventures.Content owns the canonical source and publication of narrative
campaigns. GameEngine owns kinds, validation, portable hydration and authoring primitives.
GameEngine may retain a frozen campaign solely as a regression fixture; such a fixture is not
published and not listed in a manifest. Frozen fixtures left the package root in the breaking
0.9.0 release (design/30-slices.md, W74c), which has shipped — src/engine/package.json reads
0.10.0. That peg moved once before it landed: this section originally named 0.8.0 as the
breaking release, and 0.8.0 was then spent on an additive one. A version reserved by this
section is a name to check against src/engine/package.json before a bump, not after.
A third category is sanctioned and is neither of the two above: a kind's own reference
campaign — engine-owned content that exists to make the kind registrable and exercisable,
never authored by Content and never a frozen fixture — is a package-root export. The instance is
buildWorldGraphMvpCampaign / WORLD_GRAPH_MVP_CAMPAIGN_ID, the only content a host can
register the world-graph kind against. The line that keeps this from swallowing the rule: the
root publishes no narrative campaign, which is the claim src/engine/src/authoring.test.ts
enforces in both directions. A second kind adding a reference campaign to the root follows this
sentence; a second reference campaign for one kind does not, and wants its own decision.
Portable campaign documents remain format version 2. toPortable and
digestManifestResolution are public only through /authoring; fromPortable remains a
runtime-root export. digestPortableCampaign is exported from both surfaces: the root, for
hosts verifying fetched content, and /authoring, for authoring pipelines digesting campaign
source before publication.
PortableCampaignBody's simulation arm gains an optional migration, and the format version
does not move. Until now migration existed only on the story-graph arm, which
src/engine/src/portable/format.ts documented as a structural fact rather than an omission —
correct while migrateFromContent was the only reattachable migration there was. It is not a
structural fact about the format; it was one about how many kinds had a declarative migration
written, and the simulation kind now has one
(10-simulation-kind.md §16). toPortable's throw narrows
accordingly: it still refuses a migration for world-graph, which has none.
The version stays at 2 because the change is additive and its silent-loss case cannot
happen. A 2 reader handed a document carrying a simulation migration ignores the member
and attaches no migrateState — which matters only when campaignVersion actually moved, and
that is precisely when resolveSaveEnvelope rejects the load with save_requires_migration
(§10.2). The old reader fails loudly at the exact moment the dropped field would have mattered,
so bumping to 3 would buy a louder failure for documents that load correctly and nothing for
the ones that do not. formatVersion is a literal type, so a bump also breaks every reader
including the ones that would have been fine.
20. The Ordered System Pipeline
Two kinds resolve a turn by running an ordered list of systems: simulation's end-of-week pass
(10-simulation-kind.md §3) and world-graph's tick
(12-world-graph-kind.md §4.1). Those orders are normative and are
owned there, not here. This section owns only the substrate they run on: what applying an
ordered list means, and what doing so must never do.
The substrate is engine-internal. It is exported from neither the package root nor /authoring,
and no host supplies, replaces, wraps, or observes one — a pipeline sits inside the determinism
boundary, which by 06-extensibility.md §2 is the same line as the trust
boundary.
It is a fold over an explicit list, and nothing more.
- Order is the caller's, verbatim. The substrate applies entries in exactly the order given. It never sorts, filters, deduplicates, reorders, or skips, and it holds no registry of systems and no opinion about which belong. A caller that supplies a list is the sole authority on its contents — which is what lets the two normative orders stay owned by their kinds while the mechanism is shared.
- Every entry runs, every time. There is no short-circuit and no early exit. A terminal or failed result is a value carried in the frame, never a control-flow signal; §4.1's rule that "a terminal result does not interrupt the tick" is a consequence of this, not an exception to it. Where a turn stops early it stops in the caller's own loop around the substrate, never inside it.
- Each entry is a total function from frame to frame, and the substrate threads each returned frame to the next entry. No entry observes a frame from anything but its immediate predecessor, and the substrate never inspects, merges, or reconstructs one. The frame type belongs to the caller; the substrate is generic over it and reads no field of it.
- The substrate emits nothing. It holds no
Emitter, draws no randomness, and reads no clock. Every event a turn produces is emitted by a system — and per-system trace events are no exception. Where a kind wants one, the entry's ownruncloses over the system and the emission together, at the point the list is built.simulationdoes this forkind.simulation.system.ran, which its §11 keeps because a stream naming each system in order localizes an ordering regression to the phase that moved;world-graphdeclares no such event and wraps nothing. Both are the same substrate, with no flag distinguishing them. - It never catches. A system that throws propagates to the caller, with no partial commit and
no substitute frame. A throw is an engine defect rather than a game outcome — both existing
pipelines already rely on this, in
world-graph's per-entryprocessingTickguard and its finalize-once assertion, and in the dangling-id lookups both kinds share — and catching one would convert a wrong state into a state that still serializes. No reason code covers it, and introducing one would be the mistake.
Why the substrate is emission-free rather than emitting per system. The two callers disagree today, and both are right:
simulation's runner emits one trace event per system,world-graph's loop emits none and lets each system speak for itself. A substrate that always emitted would add twenty events to every world-graph tick; one that never emitted would delete thesystem.ranstream its §11 relies on. Moving the emission into the list entry settles it without a flag — the substrate stays a pure fold, each kind's event stream stays byte-identical to what it produces now, and which events a turn emits remains a question about systems rather than about the machinery that runs them.
The substrate is declared in src/engine/src/core/pipeline/systems.ts: SystemEntry<Frame>, an
id the substrate never reads plus a frame-to-frame run, and runSystems, the fold itself.
What this section does not settle. Whether a given kind's systems are shaped to run on this
substrate is that kind's own internal matter, constrained by the rules above and by nothing else
here. Both shipped tick-driven kinds now are, by different routes. world-graph already had the
shape — one (frame) => frame type and a declared id list — and keeps its per-entry
processingTick guard by wrapping the list handed to it, since the substrate may read no field of
the frame. simulation had none of it — fifteen bespoke signatures, no frame type, and a
missedCents handoff from housing into finance_reconcile — and gained an EndOfWeekFrame that
carries that handoff explicitly, with each entry pre-wrapped to emit its own
kind.simulation.system.ran. Both reshapes were constrained to leave resolution behaviour,
ordering, and the event stream unchanged, which src/engine/src/campaigns/pipeline-equivalence.test.ts
holds against the committed replay corpus.