Core Concepts
Saves, Replay and Determinism
A save in Kagari is not a dump of memory. It is the record of the player’s selections from the start of the story, and loading replays them. This chapter explains what a save contains, what a studio may change in a patch without breaking existing saves, and why replay is exact.
A save is a witness of reachability
The story is a pushdown machine whose only inputs are the player’s selections and the draws of its random number generator. Given the entry scene, its arguments, the generator’s seed and the list of selections, the story’s position is determined. A save records exactly that list, the trace, and loading runs the story again from the entry scene, feeding it the recorded selections, until it reaches the saved position.
So a save is a witness that a position is reachable, not a state: it is a path from the start. No save of an unreachable position can be constructed, and a load never produces a combination of story variables that the story itself could not produce. Every analysis the compiler runs on the story therefore holds for loaded games too.
Stable choice IDs
A trace does not record line numbers or arm positions. It records each selection as a choice ID made of three parts:
| Part | Example | Taken from |
|---|---|---|
The path of the function that contains the choice! | crate::story::Prologue | the module tree and the function name |
The choice id | confess | the id = "…" of the choice! |
| The selected arm | crate::story::Confess::Yes | the arm’s value: the enum variant’s path, or #n (the arm’s position) when the value is not an enum variant |
For example crate::story::Prologue/confess/crate::story::Confess::Yes. Replay supplies the call
context, so a choice id only has to be unique within its function, even when the function is
called from many places or runs in a loop. Write arm values as variants of an enum you declare: an
arm recorded by position breaks when the arms are reordered.
What a save contains
| Part | Content |
|---|---|
| The trace | the entry scene’s path, the entry arguments, the PRNG seed, the choice ID of every selection, and the number of steps after the last selection |
| The exit handler | the path of the function passed to nav::start as on_exit |
| The script hash | the SHA-256 of the .kagari file the save was made with |
| Slot information | the local time, the speaker and text of the last line, and a 320×180 thumbnail of the screen |
| The format version | the version of the save layout |
A save contains no system state: no mode stack, no settings, no meta (see
Two nested machines). The slot information is what
saves::info(slot) and ui::thumbnail(slot) show on a save page; it plays no part in loading.
Loading and divergence
nav::load(slot) reads the slot and replays its trace on the running program. The replay advances
through s! steps on its own; at each choice! it must meet the recorded choice ID and select
the recorded arm. When the trace is exhausted, it advances the recorded number of steps after the
last selection and stops there. The restored game then replaces the whole mode stack.
Replay is silent: it sets no read flags or seen sets, repeats no meta writes, plays no audio and makes no autosaves. The restored step starts again from step entry: its timeline restarts, so its animations play again, and its voice plays again.
If the save no longer fits the program, the load stops with a divergence: nav::load returns
Err(divergence), leaves the stack unchanged, and divergence.describe() gives a message for the
player. A load diverges when:
| Cause | Message reports |
|---|---|
| The slot is empty or out of range | the slot is empty |
| The file cannot be read, or has an unknown format version | the save cannot be read |
| The entry scene no longer exists | the missing story |
| The entry arguments no longer fit the entry scene’s parameter types | the starting values no longer fit |
| The story reaches a different choice than the recorded one | the expected and the found choice |
| The recorded arm is gone, or its guard is now false | the choice and the missing arm |
| The story ends before all recorded selections are used | the number of selections left |
| Fewer steps follow the last selection than recorded | the expected and reached step count |
| The story stops with a runtime error during replay | the error |
| Replay does not reach the position within 1,000,000 steps | the step limit |
The quick-load binding reports a divergence only in the log; a load page shows it with
describe().
What a patch may change
Because a trace holds only choice IDs and a final step count, saves survive most edits. For a save whose position is after the edited place:
| Change | Effect on the save |
|---|---|
| Rewording text, changing speakers, voices, text effects | none |
| Editing renders, assets, the screen, animations, audio | none: the screen is rebuilt by replay |
Adding, removing or reordering s! steps between two choices | none |
| Reordering the arms of a choice whose arm values are enum variants | none |
| Adding an arm to a choice | none |
| Changing the game system: UI, modes, settings, bindings | none |
Adding or removing s! steps after the last selection before the saved position | the save lands on a different line; if too few steps remain, it diverges |
Renaming or moving the function that contains a choice, its id, the arm’s enum or variant | divergence at that choice |
| Removing an arm, or a guard that now hides the recorded arm | divergence at that choice |
| Changing story logic so that a different choice is met first, or the story ends earlier | divergence |
| Renaming or moving the entry scene, or changing its parameter types | divergence |
Arms recorded by position (#n) being reordered | the save silently takes a different arm |
Changing story variables, their initial values, or conditions is safe as long as the recorded selections still lead along the same choices; replay reports the first place where they do not.
Read flags, which drive skipping read text and marking read lines in the backlog, are kept outside
saves and keyed by the function and the position of the step among the steps of that function, in
code order. Inserting or removing a step (s! or choice!) in a function therefore shifts the
read flags of every later step in that same function: after such a patch, some lines a player has
read count as unread and the reverse. Steps in other functions are not affected.
Snapshots and traces
The trace is the compatibility contract: it is the part of a save that is meaningful across versions of the script. A snapshot — the story state and the logical screen at a position — is only a cache that makes reaching a position cheaper. It depends on the exact program, so it is trusted only when the script hash matches; on any other script the engine replays the trace.
Tentative: the current engine writes no snapshot into a save. Every load and every rewind replays the trace from the start of the story. The script hash is recorded already, and it decides one detail today: an exit handler written as a closure is restored only by the same script; a named function is looked up by its path.
Rewind
game.rewind(i) returns the running game to backlog entry i. It cuts the current trace at that
entry and replays it, like a load. It never changes meta: read flags and seen sets stay set.
Save slots
A game has three kinds of slot, SaveSlot::Manual(k), SaveSlot::Quick and SaveSlot::Auto(k):
- Manual slots,
0..save_slots, are written bygame.save(slot)from a save page. - The quick slot is written and read by the
QuickSaveandQuickLoadactions of the bindings. - Auto slots,
0..auto_slots, are written by the engine on entering every choice step and on quit, rotating through the slots.
A slot index outside the configured count is ignored: saving does nothing and the slot reads as
empty. The API is specified in
std::system::saves.
Determinism
Replay is exact because everything the story does between selections is deterministic:
- Randomness is seeded and recorded. A scene draws a random value with
rng_choose::<T>(). Eachnav::startseeds a fresh story PRNG; the seed is part of the trace, and the generator’s state is part of the story state. Replay therefore reproduces every draw, and a save never lets the player re-roll a draw by loading. - There are no floating-point numbers. The language has bounded integers, Rust integers, and
the exact types
RatandDecimal(see Numbers). Every computation gives the same result on every platform. - Renders are pure. A render is a
const fnof the previous screen, so replaying the selections rebuilds the same logical screen (see Renders are blind to the story). - The system cannot reach the story. Settings, meta, signals and wall-clock time never flow into the story; meta reaches it only through the entry arguments, which the trace records.
The same model makes the story analyzable: for the compiler, a random draw is a free choice over
every value of T, like a selection by the player.
Preview
Planned. The same machinery serves an authoring preview: jumping to a step in the editor is replaying a trace to it. The cost is bounded by keyframes: the story is replayed from the start (or from a snapshot, for an unchanged script), but renders run only from the last keyframe on the path, without animations. Editing a render invalidates only the screens after its first use on the current path. No preview tool ships yet.
Last updated Oct 29, 2026