Save — One File, Every System¶
This folder contains the save participants: one per system, each translating that system's own snapshot API into the shared contract the save coordinator understands.
The coordinator itself lives in Runtime/Core/Save/ and ships in every package. These participants are optional and compile only when their system is present.
Why This Exists¶
Every system already knew how to save itself. None of them knew how to save together.
Inventory speaks JSON strings. Currency returns objects. Health returns a struct. Crafting wants identity resolvers injected. Status Effects had no persistence surface at all. Combining them into one save file was left to you.
The coordinator composes them, and none of the five systems changed to make it work. Each participant is a translator in a define-gated assembly, exactly like the rest of Integrations/ — so deleting a system still compiles.
The Shortest Version¶
using RevGaming.RevFramework.Core.Save;
var participants = new IRevSaveParticipant[]
{
new HealthSaveParticipant(),
new InventorySaveParticipant(itemDatabase),
};
// Save
string json = RevSaveCoordinator.Capture(participants);
File.WriteAllText(path, json);
// Load
var report = RevSaveCoordinator.Restore(File.ReadAllText(path), participants);
if (!report.Success) Debug.LogWarning(report);
There is no file I/O in the coordinator, deliberately. It takes and returns a string, so where a save lives is entirely yours: Application.persistentDataPath, PlayerPrefs, cloud save, inside a larger save file of your own, or encrypted. Nothing here holds an opinion about platforms.
The One Thing That Will Catch You¶
Every object whose state you want saved needs a StableId component.
A GameObject reference means nothing across sessions, so participants identify owners by StableId.Id. An object without one is invisible to every participant — it will not be saved, and it will not be restored. That is deliberate: saving it would write state that could never be matched back to anything.
Add RevFramework ▸ Core ▸ Stable Id to your player, your enemies, your chests — anything whose state should survive a save.
Participants¶
| Participant | What it needs | What it saves |
|---|---|---|
HealthSaveParticipant | nothing | Current, max and dead state per owner |
CurrencySaveParticipant | service + currency ids | Balances for the currencies you name |
InventorySaveParticipant | ItemDatabase | Container contents, and equipment where present |
CraftingSaveParticipant | service + recipes | Queued, running and paused jobs |
StatusEffectsSaveParticipant | effect factory | Active statuses, with their remaining time |
Health is the only one that needs nothing. A participant is something your game assembles and hands over — not something discovered by reflection — which is why the others take constructor arguments.
Two of them need more than a reference, and the reason is the same in both cases: the thing has no durable identity of its own.
- Currency has no "every currency this wallet holds" query — balances are addressed by id, not enumerated — so you must name the currencies to save.
- Crafting recipes have no id at all.
ItemDefinitioncarries aguidfor exactly this purpose; recipes have no equivalent. You hand the participant your recipes and it identifies them by asset name, matching the convention the teaching panels already use. - Status Effects are polymorphic objects rather than data, so only your game can rebuild one from an id. You supply a factory, and it receives the saved remaining time.
Trades You Should Know About¶
These are decisions, not defects. Each is pinned by a test so it stays honest.
Crafting goes last in your participant list, 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. Nothing detects it; the report reads clean and the player is short an offline craft.
var participants = new IRevSaveParticipant[]
{
new InventorySaveParticipant(itemDatabase),
new CurrencySaveParticipant(currencyService, currencies),
new CraftingSaveParticipant(craftingService, recipes), // last
};
Crafting's restore replaces the live job list rather than adding to it, which also makes it the only participant that does not apply on top of existing state — adding would duplicate every job on a second load. So its capture writes a section even when nothing is in flight, because "you had nothing crafting" is a state a load has to be able to restore.
Renaming a recipe asset invalidates saves that reference it. Asset name is the recipe's identity, because nothing better exists. The job is dropped on load and reported — the save still loads, that job is simply gone, and the inputs and currency it consumed were spent when it started and are not refunded. Use the resolver constructor if your project has durable recipe identity of its own.
A restore zeroes wallets the save does not mention — the one place a participant clears rather than applying on top. A capture skips an owner with no wallet, so an owner paid after the save was written has no entry in it: transfer 500 to a companion, quickload, and without the sweep the player's wallet is restored while the companion keeps the 500. That is the currency-level exploit one level up, and the rule about not clearing is there to stop data being destroyed, not to protect money being minted. Opt out where wallets legitimately outlive a load:
new CurrencySaveParticipant(service, currencies, unsavedWallets: UnsavedWalletPolicy.Leave);
Restoring a status runs its Apply. Statuses re-establish themselves properly, which is right for most. But where a status owns state another participant also restores — a shield status alongside Health's shield — you can get it twice. Either have the factory build a version that skips re-application, or leave those statuses out and let Health's snapshot own the result.
StatusContext.SourceDef is not restored. It is a ScriptableObject reference and there is no asset registry to resolve it through. Instigator, source id, slot and note all survive. Bake the definition into your factory if a status needs it.
Economy and Pickups have no participant. Economy orchestrates Currency and Inventory rather than owning state, and Pickups are world objects. Nothing is missing — there is nothing there to save.
Loading On A Build That Lacks A System¶
Supported, and handled deliberately. A section no participant claims is reported as Unrecognised rather than failing the load, and handed back so you can preserve it:
var report = RevSaveCoordinator.Restore(json, participants);
// Later, when saving again — keep data for systems this build does not have
string json2 = RevSaveCoordinator.Capture(participants, out _, report.Unrecognised);
Without that second argument, saving on a build with a system removed silently destroys that system's data. Since RevFramework is sold in SKUs, that is a normal situation rather than an edge case.
Your Own Data In The Same File¶
Framework systems are not privileged. Implement IRevSaveParticipant and your quest flags, unlocked levels or settings sit in the same file alongside them:
public sealed class QuestSaveParticipant : IRevSaveParticipant
{
public string Key => "mygame.quests"; // stable forever; renaming it orphans existing saves
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));
}
}
Use your own key prefix and it can never collide with a framework one. Throwing from Restore is a legitimate way to refuse a payload — the coordinator isolates it, records that section as failed, and carries on with the others.
Failure Handling¶
Nothing here throws at you. RevSaveReport tells you what happened per section:
- A participant that throws is isolated and recorded; the rest still run. A half-loaded save that names the broken section is more useful than an exception that abandons everything.
- Corrupt or truncated payloads report through
FatalErrorrather than throwing. A load that throws is how a game ends up stuck on its main menu. - Duplicate keys are reported rather than resolved by enumeration order.
- A section that matched no owners at all is reported rather than passing as a clean load. Some owners missing stays silent — a save outlives the objects it was taken from — but none of them found means the section applied to nothing, and the only symptom otherwise is a game full of default state. Usually the wrong scene is loaded, or ids have changed since.
report.Success is deliberately not tripped by unrecognised or skipped sections — those are supported situations, not failures.
Versioning¶
Two independent version numbers, at different layers:
- Envelope version — the shape of the save container itself.
- Participant version — the shape of one section's payload.
The version a payload was written with is what reaches Restore, not your current one. That is what makes migration possible: without it, a participant could never tell old data from new, and every format change would silently corrupt existing saves.
A save from a newer envelope version is still read rather than refused, because sections are self-describing and refusing outright would throw away data that could have been restored.
Safe To Delete¶
If you are not using the save coordinator, this entire folder can be deleted. No system depends on it.