Skip to content

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

  • Unrecognised does not trip report.Success
  • the section is preserved verbatim in report.Unrecognised
  • passing it back as carryOver keeps 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

  1. Is the key one I will never want to change?
  2. Does Restore handle a version older than current — and refuse one newer?
  3. Does Capture return null when there is genuinely nothing to save, rather than an empty shell? Unless "nothing" is itself state your restore has to reproduce — if Restore replaces 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.
  4. Does every object it saves have a StableId that survives across sessions?
  5. If it throws, is that because the payload is genuinely unusable — not merely surprising?