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 alongsideIRevSaveParticipant, 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 toRevSaveManager.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 |
Related¶
- Public API — the exact contract for every member named here
- Mental Model — keys, versions and
StableIdas three separate identities Integrations/Save/README.md— the five framework participants and their trades