Save — Overview¶
In one sentence
RevSaveCoordinator composes any number of IRevSaveParticipants into a single versioned save payload, and routes a saved payload back to them — without performing file I/O, parsing any payload, or letting one bad section break the rest.
The problem it solves¶
Every RevFramework system already knew how to save itself. None of them knew how to save together.
| System | Its own snapshot shape |
|---|---|
| Inventory | JSON strings |
| Currency | objects |
| Health | a struct |
| Crafting | wants identity resolvers injected |
| Status Effects | had no persistence surface at all |
Combining those into one file was left to the caller, and it was genuinely awkward work: five different shapes, five different failure modes, and no answer at all for what happens when a save written by the Complete package is loaded on a build that only has Health.
The coordinator is the common denominator those shapes are adapted to. None of the five systems changed to make it work — each participant is a translator living in a define-gated assembly, so removing a system still compiles.
The shape¶
your game
│
│ IRevSaveParticipant[]
▼
RevSaveCoordinator ──────► JSON string ──────► wherever you want it
│ (file, PlayerPrefs, cloud,
│ inside a bigger save, encrypted)
▼
RevSaveReport
(per-section outcomes)
Three types are all most projects touch:
IRevSaveParticipant— something that can write its state out and read it backRevSaveCoordinator— static, stateless;CaptureandRestoreRevSaveReport— what happened, per section, and when the save was stamped
A fourth if you would rather not own the "wherever you want it" end yourself:
RevSaveManager— a component that holds the participants, addresses saves by slot, and writes through a replaceableIRevSaveStore
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);
RevSaveReport.ToString() produces a multi-line per-section summary, which is why logging the report directly is usually enough during development.
The coordinator never touches the filesystem, deliberately¶
The coordinator takes a string and returns a string. Where that string lives is entirely yours: Application.persistentDataPath, PlayerPrefs, a cloud save service, inside your own larger save file, or an encrypted blob.
Baking a path in would drag every platform question — WebGL, console certification requirements, cloud sync conflict policy — into a type that has no business holding an opinion about them.
If you would rather not write the file yourself¶
RevSaveManager (RevFramework ▸ Core ▸ Save Manager) is the coordinator driven. It holds the participant list, addresses saves by slot, writes through a replaceable IRevSaveStore, and carries forward the sections a load could not place.
manager.Register(new HealthSaveParticipant());
manager.Register(new InventorySaveParticipant(itemDatabase));
manager.Register(new CraftingSaveParticipant(craftingService, recipes));
var saved = manager.Save("slot1");
var loaded = manager.Load("slot1");
- The default store is a
FileSaveStorewriting one<slot>.jsonper slot underApplication.persistentDataPath/Saves. AssignRevSaveManager.Storeto replace it; assignnullto put the default back. - Registration order is the restore order, stably sorted by any participant that declares a position through
IRevSaveOrdered. For the five framework participants that means the order you register them in does not matter:CraftingSaveParticipantdeclaresLateand moves itself. For two participants of your own that touch the same state, it still does. carryOveris wired. After a load the manager keeps the sections nobody claimed, plus any a participant refused without applying anything, and feeds them into the next save. That is the parameter almost nobody passes by hand, and the reason this component earns its place.- Subscribe to
LoadCompletedto reconcile presentation, because what a restore raises varies by participant. Health and Attributes write state directly and raise nothing — otherwise every load would spawn death VFX and drop loot. Currency, Inventory, Status Effects and Crafting restore through their own APIs and do raise. Reconciling here answers both, and is also wherereport.SavedAtUtctells you how long the game was closed.
Nothing here starts on its own: no Awake hook, no autosave, no quit handler. See Public API → RevSaveManager, which also states exactly what a failed write does and does not promise.
The one thing that will catch you¶
Every object whose state you want saved needs a StableId component (RevFramework ▸ Core ▸ Stable Id).
A GameObject reference means nothing across sessions, so participants identify owners by StableId.Id. An object without one is invisible to every participant: not saved, and not restored.
That is deliberate rather than an oversight — saving it would write state that could never be matched back to anything on load.
StableId is necessary, not sufficient
It is stable for scene-authored objects, where the id is generated in the editor and serialized into the scene.
A runtime-spawned object has no serialized id, so one is generated in Awake — a different one every launch. Anything saved against it can never be matched again. Call StableId.AssignId(...) with an id your game derives from durable data (a spawn table entry, a room and slot index, a quest id).
Duplicating an object copies its id. Unity's duplicate copies serialized fields and the automatic generation only fills an empty value, so building a level by duplicating a configured enemy leaves several objects claiming one identity — and one gets restored with another's state. Nothing self-corrects this, because nothing can tell which copy was the original. Use RevFramework ▸ Validate ▸ Duplicate Stable Ids to find collisions.
The five framework participants¶
| Participant | Key | What it needs | What it saves |
|---|---|---|---|
HealthSaveParticipant | revframework.health | nothing | Current, max and dead state per owner |
CurrencySaveParticipant | revframework.currency | service + currency ids | The currencies you name, plus whatever each wallet actually holds |
InventorySaveParticipant | revframework.inventory | ItemDatabase | Container contents, and equipment where present |
CraftingSaveParticipant | revframework.crafting | service + recipes | Queued, running and paused jobs |
StatusEffectsSaveParticipant | revframework.statuseffects | effect factory | Active statuses, with remaining time |
AttributesSaveParticipant | revframework.attributes | nothing | Base values and bounds per owner |
Health and Attributes are the two needing nothing. A participant is something your game assembles and hands over — not something discovered by reflection — which is why the others take constructor arguments.
Where one needs more than a service reference, the reason is nearly always the same: the thing has no durable identity of its own. Recipes have no id (an ItemDefinition carries a guid; a recipe has no equivalent). Status effects are polymorphic objects rather than data, so only your game can rebuild one from an id.
Currency is the exception, and its list means something narrower than it used to. A service implementing ICurrencyWalletQuery can be asked which currencies a wallet holds, and a capture asks — so what gets written is the currencies you name plus whatever each wallet actually holds. The list is still required, because a currency a wallet has never touched has no key to enumerate, and a line at zero for it is the only thing that lets a restore put that balance back to zero. A service that cannot enumerate falls back to your list alone.
Economy and Pickups have no participant, deliberately. Economy orchestrates Currency and Inventory rather than owning state, and Pickups are world objects. Nothing is missing — there is nothing there to save.
Failure is reported, not thrown¶
Nothing here throws at you.
- A participant that throws is isolated and recorded; every other section still runs.
- A corrupt, truncated, null or empty payload reports through
RevSaveReport.FatalError. - Duplicate keys are reported rather than resolved by enumeration order.
- A section that found none of its owners 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, usually because the wrong scene is loaded or ids have changed.
A half-loaded save that names the broken section is considerably more useful than an exception that abandons the lot. A load that throws is how a game ends up stuck on its main menu.
One ordering rule, and the code now enforces it¶
Participants restore in the order you supply them — except where one declares otherwise through IRevSaveOrdered.
There is exactly one such participant in the framework. Crafting must restore after Inventory and Currency: it is the only one whose restore writes into another system's state, reconciling offline progress as it loads by delivering craft outputs into the inventory and refunding currency. Inventory's restore clears the container first and Currency's writes absolute balances, so either one running afterwards erases what Crafting produced.
CraftingSaveParticipant declares RevSaveOrder.Late, so the coordinator moves it after them whatever order you pass:
var participants = new IRevSaveParticipant[]
{
new CraftingSaveParticipant(craftingService, recipes), // declares Late — restored last anyway
new InventorySaveParticipant(itemDatabase),
new CurrencySaveParticipant(currencyService, currencies),
};
Listing it last as well is harmless and reads clearly. It is simply no longer what makes it correct.
Your deliberate order still comes back untouched
The sort is stable and everything that does not implement IRevSaveOrdered shares RevSaveOrder.Default, so only a participant that explicitly asks to move, moves. Capture ignores the interface entirely — no capture order produces a different file.
Write a participant whose restore reads or writes another system's state and you should declare its position the same way, rather than documenting a rule the next caller has to remember.
Everything else is order-independent between framework participants. See System Guarantees Matrix → §10.
Loading on a build without every system¶
Supported and handled deliberately, because RevFramework is sold in SKUs — this is the normal case, not an edge case.
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);
Omitting carryOver destroys data
Without that third argument, saving on a build with a system removed silently discards that system's section. Load a Complete-era save in a project with Crafting removed, save again without carrying over, and the Crafting data is gone for good.
RevSaveManager does this for you — it keeps the sections after every load, exposes them on CarriedOver, and passes them into the next Save. Nobody wires the parameter by hand, because you have to read the coordinator's remarks to learn it exists.
report.Success is deliberately not tripped by unrecognised or skipped sections. Those are supported situations, not failures.
Where to go next¶
- Mental Model — how to think about participants, keys and versions
- Public API — the supported surface
- Integration Surfaces — writing a participant for your own state
- Guarantees Matrix — the behavioural contract, including what is not guaranteed