Save — Mental Model¶
The idea in one line
The coordinator is a post office, not a library. It routes sealed envelopes to the right recipients. It never opens one, and it never keeps a copy.
Think of it as routing, not serialising¶
The instinct with a save system is that it serialises your game. This one does not. It moves strings that other things serialised.
That single decision explains most of the design:
| Because the coordinator never parses a payload… | …this follows |
|---|---|
| A participant can change its format freely | No other participant, and not the envelope, needs to know |
| There is no shared schema to keep in sync | Adding a participant cannot break an existing one |
| Your own data is not second-class | Quest flags and Inventory are handled identically |
| The coordinator has no opinion about JSON | It uses JsonUtility only for the envelope itself |
If you find yourself wanting to reach into another participant's payload, that is the signal you want a participant of your own rather than a change to this one.
A participant is an adapter, not a store¶
A participant does not hold state. It knows how to ask a system for its state, and how to give it back.
HealthSystem ──snapshot──► HealthSaveParticipant ──payload string──► coordinator
HealthSystem ◄──restore─── HealthSaveParticipant ◄──payload string── coordinator
This is why the framework participants are constructed by your game rather than discovered. They are a translation layer you assemble, and assembling them is where you supply the context only your game has — the ItemDatabase, the recipe list, the currency ids that matter, the factory that rebuilds a status effect.
A participant discovered by reflection could not be handed any of that.
Three identities, and none of them are the same thing¶
This is the part worth slowing down on, because conflating them causes most save bugs.
1. The participant key — who owns this section¶
public string Key => "mygame.quests";
Stable forever. It is how a saved section finds its way back to the code that can read it. Renaming a key orphans every existing save's data for that section — the data is still in the file, but nothing claims it, so it reports as Unrecognised and is dropped unless carried over.
Use your own prefix and you can never collide with a framework one.
2. The payload version — what shape this section is in¶
public int Version => 2;
Recorded per section and handed back to Restore. Critically, the version you receive is the one the payload was written with, not your current one. That is what makes migration possible at all: without it, a participant could never tell old data from new, and every format change would silently corrupt existing saves.
3. The StableId — which object this state belongs to¶
Not part of the save contract at all — it lives inside participant payloads. But it is the identity that decides whether a particular object survives a save, and it is the one most likely to bite.
A useful test
If you renamed a class, would it break? → that is a key problem. If you changed a field, would it break? → that is a version problem. If you duplicated a prefab, would it break? → that is a StableId problem.
Two version numbers, at two layers¶
envelopeVersion ──────► the shape of the container
│
└── sections[]
└── version ──► the shape of THIS section's payload
They move independently on purpose. Adding a field to the envelope does not force every participant to bump, and a participant changing its format does not touch the envelope.
A save from a newer envelope version is still read, not refused. Sections are self-describing, so an envelope gaining a field does not stop this build reading the sections it recognises — and refusing outright would throw away data it could have restored.
What a participant does with a newer section version is its own decision. The framework participants refuse it, which is the conservative choice: applying data you do not understand is worse than declining it.
Failure is data, not control flow¶
The coordinator reports; it does not raise.
participant throws ──► that section: Failed ──► others still run
payload unparseable ──► report.FatalError ──► nothing applied
nobody claims a key ──► that section: Unrecognised ──► handed back to you
two same keys ──► first wins, clash reported ──► no silent overwrite
This mirrors how the rest of the framework treats consumer callbacks: your game does not break because one handler misbehaved. A restore that applies four of five sections and names the fifth is a better outcome than an exception that abandons all five.
The corollary is that report.Success is a decision point, not an assertion. Check it, log it, branch on it — but a false does not mean nothing loaded.
Unrecognised is a feature, not an error¶
The instinct is to treat an unclaimed section as corruption. It is usually the opposite: it is data belonging to a system this build does not have.
RevFramework ships in SKUs. A player moving between builds, or a project with a system removed, is routine. So:
Unrecogniseddoes not tripreport.Success- the section is preserved verbatim in
report.Unrecognised - passing it back as
carryOverkeeps it alive through the next save
Think of those sections as passengers: this build cannot read them, but it can carry them safely to a build that can.
The one rule: a carried section whose key a live participant also claims is dropped in favour of the live one. Current state always beats a stale passenger.
What the coordinator is not¶
| It is not | Because |
|---|---|
| A file format | It produces a string; the destination is yours |
| A serialiser | Participants serialise; it routes |
| A migration engine | It delivers the version; migrating is the participant's job |
| A scheduler | It is synchronous, with no autosave and no timing opinion |
| An identity registry | StableId is identity; the coordinator never resolves objects |
See System Boundaries for the full list and the reasoning behind each.
The mental checklist before you ship a participant¶
- Is the key one I will never want to change?
- Does
Restorehandle a version older than current — and refuse one newer? - Does
Capturereturn null when there is genuinely nothing to save, rather than an empty shell? Unless "nothing" is itself state your restore has to reproduce — ifRestorereplaces what it finds rather than applying on top of it, an empty section is the only thing that can carry "there was nothing here" across a load. That is why the Crafting participant writes one. - Does every object it saves have a
StableIdthat survives across sessions? - If it throws, is that because the payload is genuinely unusable — not merely surprising?