Kagari
Type to search documentation.

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:

PartExampleTaken from
The path of the function that contains the choice!crate::story::Prologuethe module tree and the function name
The choice idconfessthe id = "…" of the choice!
The selected armcrate::story::Confess::Yesthe 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

PartContent
The tracethe 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 handlerthe path of the function passed to nav::start as on_exit
The script hashthe SHA-256 of the .kagari file the save was made with
Slot informationthe local time, the speaker and text of the last line, and a 320×180 thumbnail of the screen
The format versionthe 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:

CauseMessage reports
The slot is empty or out of rangethe slot is empty
The file cannot be read, or has an unknown format versionthe save cannot be read
The entry scene no longer existsthe missing story
The entry arguments no longer fit the entry scene’s parameter typesthe starting values no longer fit
The story reaches a different choice than the recorded onethe expected and the found choice
The recorded arm is gone, or its guard is now falsethe choice and the missing arm
The story ends before all recorded selections are usedthe number of selections left
Fewer steps follow the last selection than recordedthe expected and reached step count
The story stops with a runtime error during replaythe error
Replay does not reach the position within 1,000,000 stepsthe 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:

ChangeEffect on the save
Rewording text, changing speakers, voices, text effectsnone
Editing renders, assets, the screen, animations, audionone: the screen is rebuilt by replay
Adding, removing or reordering s! steps between two choicesnone
Reordering the arms of a choice whose arm values are enum variantsnone
Adding an arm to a choicenone
Changing the game system: UI, modes, settings, bindingsnone
Adding or removing s! steps after the last selection before the saved positionthe 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 variantdivergence at that choice
Removing an arm, or a guard that now hides the recorded armdivergence at that choice
Changing story logic so that a different choice is met first, or the story ends earlierdivergence
Renaming or moving the entry scene, or changing its parameter typesdivergence
Arms recorded by position (#n) being reorderedthe 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 by game.save(slot) from a save page.
  • The quick slot is written and read by the QuickSave and QuickLoad actions 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>(). Each nav::start seeds 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 Rat and Decimal (see Numbers). Every computation gives the same result on every platform.
  • Renders are pure. A render is a const fn of 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