Skip to content

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
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

MemberTypeNotes
PositionSavePositionBoard, node, call stack
HistoryIReadOnlyList<PlaythroughMoment>The rollback window, oldest first. The last entry is where the player stands.
TakenPortsIReadOnlyList<string>One key per choice already taken — what marks an option as seen
SaidRowsIReadOnlyList<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
StateRuntimeStateSnapshotThe runtime half
Languagestring
const int MaxSavedMoments50

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

SituationWhat happens
A variable the save predatesTakes its authored default
A variable the project no longer declaresDropped

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.

MemberNotes
DroppedMomentsHow many rollback moments were dropped because their node no longer exists — the back window is shorter than the save’s
DroppedEntitiesOverlays dropped because the entity is gone, and instances whose template is gone
DroppedVariablesCodes the project no longer declares
DefaultedVariablesVariables the save predates. Each took its authored default.
RefusedValuesValues refused for exceeding the size budget
IsCleanDid 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.

MemberNotes
const int FormatVersion2. The version this writes and the only one it reads.
const int MaxSaveChars8,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

TypeWhat it is
SavePositionWhere the player stands: board, node, and the call stack
PlaythroughMomentOne entry of the rollback window, with a full copy of the state as it was
CallFrameOne frame waiting to be returned to — how a save inside a sub-board resumes into its caller
MomentDecisionWhat 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.

Written for
Unity developers · Advanced integrators
Last updated