Save — Integration Surfaces¶
Where your logic plugs in
There is exactly one extension seam: IRevSaveParticipant. Everything else on this page is about using it well.
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 affect the order sections are written and outcomes reported.
Crafting goes last, after Inventory and Currency
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 erases what Crafting just produced.
var participants = new List<IRevSaveParticipant>
{
new InventorySaveParticipant(itemDatabase),
new CurrencySaveParticipant(currencyService, currencies),
new CraftingSaveParticipant(craftingService, recipes), // last
};
Nothing detects the wrong order. No participant fails, no section is marked, and the report reads clean — the player is just short an offline craft.
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.
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.
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 or savedAtUtc | Use your own Version; those two are diagnostic only |
| Put file paths behind the coordinator | The string is the boundary; storage is yours |
| 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