Skip to content

Save — Integration Surfaces

Where your logic plugs in

One seam carries almost everything: IRevSaveParticipant, which is how your state joins the save file. Most of this page is about using it well.

Two smaller ones exist for the cases it cannot cover on its own:

  • IRevSaveOrdered — implemented alongside IRevSaveParticipant, when your restore reads or writes another system's state and therefore cannot run wherever the caller happened to put it.
  • IRevSaveStore — implemented instead of using the default, when payloads belong somewhere other than local files. Assign yours to RevSaveManager.Store.

Neither is something most projects touch. Both exist because the alternative was a rule in prose that a caller had to remember.


The seam

public sealed class QuestSaveParticipant : IRevSaveParticipant
{
    public string Key => "mygame.quests";   // stable forever
    public int Version => 1;

    public string Capture() => JsonUtility.ToJson(QuestLog.Current);

    public void Restore(string payload, int version)
    {
        if (version > Version) throw new NotSupportedException("Save is from a newer build.");
        QuestLog.Apply(JsonUtility.FromJson<QuestState>(payload));
    }
}

Hand it to the coordinator alongside the framework ones and it sits in the same file:

var participants = new IRevSaveParticipant[]
{
    new HealthSaveParticipant(),
    new InventorySaveParticipant(itemDatabase),
    new QuestSaveParticipant(),          // yours, same standing
};

There is no registration step, no attribute, and no discovery. A participant is something your game assembles and hands over. That is what makes it possible to give one constructor arguments only your game has.


Choosing a key

Rule Why
Pick a prefix you own (mygame.) Cannot ever collide with a framework key
Treat it as permanent Renaming orphans every existing save's data for that section
One key per participant Two participants with one key means the second is skipped and reported

If you do need to rename a key, the migration is: keep the old participant reading the old key for a release, write under the new one, then retire the old.


Choosing a version

Version describes the format you write, not the version of your game.

public int Version => 3;

public void Restore(string payload, int version)
{
    switch (version)
    {
        case 1: ApplyV1(payload); break;          // migrate old saves
        case 2: ApplyV2(payload); break;
        case 3: ApplyV3(payload); break;
        default:
            throw new NotSupportedException($"Quest payload v{version} is newer than this build.");
    }
}

Bump it only when the payload changes in a way older code could not read. Adding an optional field that older code ignores harmlessly does not need a bump; renaming or removing one does.

You receive the written version, not yours

This is the whole point of recording it. Without it, a participant could never tell old data from new, and every format change would silently corrupt existing saves.

Refusing a newer version is the conservative default, and what the framework participants do. Applying data you do not understand is worse than declining it.


Returning null from Capture

public string Capture()
{
    if (QuestLog.Current.IsEmpty) return null;   // nothing to save
    return JsonUtility.ToJson(QuestLog.Current);
}

A null or empty payload records the section as Skipped and writes nothing. That is better than an empty shell, because a section that exists but means nothing still has to be routed, versioned and reasoned about on load.


Throwing vs reporting

Throwing from Capture or Restore is supported and isolated. The question is when it is right.

Situation Do this
Payload is genuinely unusable (wrong shape, newer version) Throw — the section is recorded Failed and named
There is simply nothing to save Return null from Capture
A single item inside your payload is bad Handle it yourself — drop the item, keep the section

The framework's own Crafting participant takes the third route: a job referencing a renamed recipe is dropped with a warning, and the rest of the section still loads. Losing one job beats losing every job.


Where the object identity comes from

Participants that save per-object state address objects by StableId.Id. If you write a participant that does the same, use the same identity — mixing schemes means your data cannot be matched against anyone else's.

foreach (var stableId in Object.FindObjectsByType<StableId>(FindObjectsSortMode.None))
{
    var id = stableId.Id;
    if (string.IsNullOrWhiteSpace(id)) continue;   // unsaveable by design
    // ...
}

An object without a StableId is invisible. It is not saved, not restored, and not reported as missing — there is nothing to report it against.


Composing the participant list

The list is ordinary code, so it can be conditional:

var participants = new List<IRevSaveParticipant> { new HealthSaveParticipant() };

#if REV_INVENTORY_PRESENT
participants.Add(new InventorySaveParticipant(itemDatabase));
#endif

Order does not affect routing — a section goes to the first participant claiming its key, and keys are unique — but it does decide the order sections are written, the order outcomes are reported, and the order participants are restored in. That last one is the only one that can change what a load produces, and the next section is about the one case where it does.

Build the list once

The same list should be used for capture and restore. Restoring with a different set is legal and safe, but any section whose participant is absent comes back as Unrecognised rather than being applied.


Declaring a restore position — IRevSaveOrdered

Most participants should not implement this. Restoring is normally independent per section: Inventory does not care whether Health has run, and a list nobody asked to reorder comes back untouched.

Implement it when your Restore reads or writes another system's state, because that is the only case where order can change the outcome:

public sealed class ShipmentSaveParticipant : IRevSaveParticipant, IRevSaveOrdered
{
    public int RestoreOrder => RevSaveOrder.Late;   // delivers into the inventory as it restores

    public string Key => "mygame.shipments";
    public int Version => 1;
    // …
}
Constant Value For
RevSaveOrder.Early -1000 State others read while restoring
RevSaveOrder.Default 0 What a non-implementer is treated as
RevSaveOrder.Late 1000 A restore that writes into another system

Any int is legal — the values are only ever compared with each other — but the constants read as intent rather than as magic numbers.

Ties keep your order. The sort is stable, and everything that does not implement the interface shares RevSaveOrder.Default, so only a participant that explicitly asks to move, moves.

Capture ignores it. Each participant snapshots state it already owns into its own section, so no capture order produces a different file.

The framework's one ordering rule now answers itself

CraftingSaveParticipant declares RevSaveOrder.Late. It is the only participant whose restore writes into another system's state: Crafting reconciles offline progress as it restores, so a job whose timer elapsed while the game was shut delivers its outputs into the inventory and refunds currency when that delivery fails — during the load. Inventory's restore clears the container before applying its snapshot and Currency's writes absolute balances, so either one running afterwards would erase what Crafting just produced.

var participants = new List<IRevSaveParticipant>
{
    new CraftingSaveParticipant(craftingService, recipes),   // declared Late; restored last
    new InventorySaveParticipant(itemDatabase),
    new CurrencySaveParticipant(currencyService, currencies),
};

That used to be yours to get right by convention, and getting it wrong was undetectable — no participant failed, no section was marked, and the report read clean while the player was short an offline craft. Older documentation therefore told you to list Crafting last. That advice still produces the right result; it is simply no longer what makes it correct.

Do the same for your own. A rule in prose is one a caller has to remember. A declared order is one they cannot get wrong.

A declaration is not a dependency graph

RestoreOrder says roughly where, not after which participant. Two participants that both declare Late and depend on each other are still ordered by the sequence you supplied them in, and nothing detects that. If your restore genuinely needs another participant's output rather than another system's state, the honest fix is one participant, not two ordered ones.

A RestoreOrder getter that throws is reported under the key <order threw> and that participant defaults to the position you supplied — it still restores, because a broken ordering hint is no reason to drop state that would otherwise load.


Preserving data you cannot read

The full round trip, including the part most projects forget:

// Load
var report = RevSaveCoordinator.Restore(json, participants);

if (!string.IsNullOrEmpty(report.FatalError))
    Debug.LogError(report);              // nothing was applied
else if (!report.Success)
    Debug.LogWarning(report);            // some sections failed; others applied

_carried = report.Unrecognised;          // keep it

// Save
string json2 = RevSaveCoordinator.Capture(participants, out var saveReport, _carried);

Hold report.Unrecognised for as long as the session lasts. It is the only copy of data belonging to systems this build does not have.

RevSaveManager is this loop, already written

It keeps Unrecognised and Unapplied after every load, exposes them on CarriedOver, and feeds them into the next Save. If you are writing the code above by hand, check whether the component would do. See Public API → RevSaveManager.


Putting payloads somewhere else — IRevSaveStore

The coordinator's string has to end up somewhere, and RevSaveManager puts it in a file by default. Replace that with anything slot-addressable:

public sealed class CloudSaveStore : IRevSaveStore
{
    public bool TryRead(string slot, out string payload) {  }
    public bool TryWrite(string slot, string payload)    {  }
    public bool Exists(string slot)                      {  }
    public bool TryDelete(string slot)                   {  }
    public bool TryListSlots(List<string> results)       {  }
}

manager.Store = new CloudSaveStore();     // assigning null restores the default FileSaveStore

Three rules an implementation has to honour:

Rule Why
Never throw A save runs from a checkpoint or a quit button, and an exception there takes the rest of that frame's logic with it. Report success or failure instead
TryListSlots distinguishes "none" from "cannot tell" An empty list means no saves; false means could not answer. Showing "no saves" for a store that failed is how a player concludes their progress is gone
Make an unexpected slot name safe The name arrives from game code, not from this framework. FileSaveStore refuses anything that is not a plain name rather than letting "../../config" become a path

Write your own when the default's guarantees are not the ones your title needs — console certification, cloud sync conflict policy, encryption, or an atomic replace your platform offers and FileSaveStore cannot promise. Public API → Write failure states exactly where the default's guarantee stops.


What not to build here

Do not Instead
Parse another participant's payload Write your own participant for the state you need
Add a participant that wraps several others Give each its own key; the coordinator is the composition point
Branch on frameworkVersion Use your own Version; that field is diagnostic only
Parse savedAtUtc out of the envelope yourself Read report.SavedAtUtc — handed to you parsed, on capture and restore alike
Put file paths behind the coordinator The string is the boundary — implement IRevSaveStore instead
Throw from an IRevSaveStore method Return false; the interface reports, it does not raise
Document an ordering rule for callers to remember Declare it: implement IRevSaveOrdered
Auto-register participants by reflection You would lose the ability to inject what only your game knows

  • Public API — the exact contract for every member named here
  • Mental Model — keys, versions and StableId as three separate identities
  • Integrations/Save/README.md — the five framework participants and their trades