Saving a playthrough
What a save carries and what it deliberately does not, the two load rules that are not configurable, and why you must not use your own serializer.
All documentation — Reference
Start here
Concepts
Authoring for a game
Playing a story
The generated code
The plugin and sync
On this page
A save is two halves composed: the engine’s — where the player is, what they have taken, the rollback window — and the runtime’s, which is variables, the entity overlay, visit counts and the random generator’s state.
DialogueEngine.SavePlaythrough() produces one; RestorePlaythrough(save) puts it back.
PlaythroughSave
| Member | Type | Notes |
|---|---|---|
Position | SavePosition | Board, node, call stack |
History | IReadOnlyList<PlaythroughMoment> | The rollback window, oldest first. The last entry is where the player stands. |
TakenPorts | IReadOnlyList<string> | One key per choice already taken — what marks an option as seen |
SaidRows | IReadOnlyList<string> | One key per line already said, least recently said first — what a list playing least recently said and a row ticked Once read. Empty, and absent from the file, when nothing has been said |
State | RuntimeStateSnapshot | The runtime half |
Language | string | |
const int MaxSavedMoments | 50 |
MaxSavedMoments bounds the save, not the live session. A running playthrough’s transcript is
every step; a reloaded one can step back fifty times and no further.
TakenPorts is stored rather than derived because it is derivable from neither History, whose
window is capped, nor from visit counts — two different options can lead to the same node.
SaidRows is stored for the same reason: a line said by NextLine is in no moment at all. Each
PlaythroughMoment also carries SaidRows as they stood when it was drawn, so a restored moment
shows the line it showed then.
What a save deliberately does not carry
- The
PlaySteps themselves. They are re-derived on load, so a save cannot go stale against an edited board. - The roll-forward stack.
- Anything older than the fifty-moment window.
What a load does
RestorePlaythrough is legal at any time, including while a command is in flight — anything pending
is abandoned.
The two rules that are not configurable
| Situation | What happens |
|---|---|
| A variable the save predates | Takes its authored default |
| A variable the project no longer declares | Dropped |
Neither is an option, and that is deliberate: two supported load behaviours would mean two sets of saves in the field.
Both are reported, because a silently defaulted variable makes “why is my gate false after loading?” unanswerable.
A saved value on an entity field that is a formula field in the payload is dropped, and is not reported: a formula field holds the value the payload carries for the whole playthrough, and no script could have written it.
A spawned instance comes back under its own id, with what was written to
it, as long as its template is still in the project — found by id, then by code. One whose template
is gone is dropped and reported under DroppedEntities with its id. A save made before anything was
spawned carries no instances key at all.
SaveLoadReport
What the load had to change, so you can tell the player.
| Member | Notes |
|---|---|
DroppedMoments | How many rollback moments were dropped because their node no longer exists — the back window is shorter than the save’s |
DroppedEntities | Overlays dropped because the entity is gone, and instances whose template is gone |
DroppedVariables | Codes the project no longer declares |
DefaultedVariables | Variables the save predates. Each took its authored default. |
RefusedValues | Values refused for exceeding the size budget |
IsClean | Did the load rules drop or default anything at all? |
IsClean is the one to branch on. Everything else is what you show when it is false.
Reading and writing the file
PlaythroughSaveJson is a static, and it is the only place a save’s wire format is validated.
| Member | Notes |
|---|---|
const int FormatVersion | 2. The version this writes and the only one it reads. |
const int MaxSaveChars | 8,000,000 UTF-16 code units |
string Write(PlaythroughSave save) | Canonical text: UTF-8, two-space indent, one trailing newline, fixed key order |
PlaythroughSave? Read(string? text) | Never throws. Null for any reason it could not be read. |
bool TryRead(string? text, out PlaythroughSave? save, out string? reason) | The same, with why |
JObject WriteObject(…) / bool TryReadObject(…) | For a caller composing a larger file |
TryRead checks in a deliberate order: empty, then length before any parse, then the parse, then
the format version, then each field. The length check must come first because a parse cannot be
interrupted once it has begun.
A format version that is absent, non-numeric, older or newer is refused outright, never partially loaded.
The supporting types
| Type | What it is |
|---|---|
SavePosition | Where the player stands: board, node, and the call stack |
PlaythroughMoment | One entry of the rollback window, with a full copy of the state as it was |
CallFrame | One frame waiting to be returned to — how a save inside a sub-board resumes into its caller |
MomentDecision | What the player did at a moment. Built by Advance or Choice(portId). |
MomentDecision is what makes roll-forward replay a decision rather than re-asking it. A lapse — a
countdown running out — records nothing, so it is deliberately not replayable.
What is not in a save
The engine’s Language is. Your own game state is not, and neither is anything you keep outside the
runtime: an inventory you modelled in your own C# rather than as project variables is yours to
persist.
The rule of thumb: if a script could write it, it is in the save. If only your code could, it is not.