Save — FAQ¶
Decision-making, not API reference. For the exact contract see Public API.
Getting started¶
Where should the save file live?¶
Wherever your game needs it — RevSaveCoordinator returns a string and has no opinion.
Application.persistentDataPath is the usual answer for a PC or mobile build. WebGL has no synchronous filesystem, so PlayerPrefs or IndexedDB via a JS plugin is the norm. Consoles have certification requirements about write timing and save indicators that only your title knows.
That is exactly why the coordinator holds no opinion: a wrong default baked into the router would be wrong for somebody, and a wrong default in a save system loses player data.
So do I have to write the file myself?¶
No — that is what RevSaveManager is for. Add the component, register your participants, and call Save("slot1").
Out of the box it writes one file per slot under Application.persistentDataPath/Saves, through a FileSaveStore. That is a replaceable default sitting one layer above the coordinator, not an opinion inside it: assign RevSaveManager.Store to put payloads in cloud storage, PlayerPrefs, an encrypted blob, or a slot inside a larger save file of your own. Assigning null puts the default back rather than leaving the manager unable to do anything.
manager.Register(new HealthSaveParticipant());
manager.Register(new InventorySaveParticipant(itemDatabase));
var report = manager.Save("slot1");
if (!report.Success) Debug.LogWarning(report);
The part worth having even if you would have written the rest yourself is carryOver, wired — see How do I stop a save shrinking every time it is loaded? below.
Is a failed save guaranteed to leave my previous save intact?¶
No, and it is worth knowing where the edge is. FileSaveStore writes to a temporary file first and then swaps it in, which covers the write itself — the long part, and the one a player force-quitting during an autosave is most likely to land in. An interrupted write leaves the previous save readable rather than a truncated one.
The swap is not atomic. A failure or a process kill between removing the old file and moving the new one into place leaves the slot holding nothing — but not the payload. It is sitting complete in a .tmp file beside it, which TryWrite deliberately keeps and names in the error it logs. Renaming it recovers the save; nothing does that for you, and the next write to that slot overwrites it.
Save reporting a fatal write error means nothing was saved, which is the outcome that matters to the caller. It does not promise the previous save survived. If your title needs a stronger guarantee than that, write an IRevSaveStore around whatever atomicity your platform actually offers — that is what the interface is for. See Public API → Write failure.
Do I need all six participants?¶
No. Pass the ones your game actually uses. A participant you do not construct simply contributes no section, and its absence is not an error.
The list is ordinary code, so it can be conditional on defines if you ship multiple configurations.
Why did my object not save?¶
Almost always: it has no StableId.
Participants address objects by StableId.Id. An object without one is invisible — not saved, not restored, and not reported as missing, because there is nothing to report it against.
Add RevFramework ▸ Core ▸ Stable Id to anything whose state should survive.
I added a StableId and it still does not come back¶
Three likely causes, in order:
- The object is spawned at runtime. In a build it gets a fresh id every launch, so nothing next session claims the saved data. Call
StableId.AssignId(...)at spawn with an id derived from durable data. - It shares an id with another object. Duplicating a configured object copies its id, so one gets restored with another's state. Run RevFramework ▸ Validate ▸ Duplicate Stable Ids.
- The participant for that system was not in the list on capture, restore, or both.
Read the report first, though. If nothing in a section came back — not one owner, not one character, not one wallet — the report says so, and says which section. Cause 1 or 2 across the board, or the wrong scene loaded, will show up there rather than leaving you to guess:
RevSave: completed with problems
revframework.health: Failed — Restore threw: None of the 12 health owner(s) this save
names are in the loaded scenes, so the section restored nothing at all…
Some owners missing stays silent, because that is ordinary — a save outlives the objects it was taken from.
The load said Success and nothing came back¶
If it really applied nothing, it will not say Success — see the entry above. A section that matched no owners at all is reported as Failed and kept in Unapplied.
What is still quiet, deliberately, is partial reach: eleven of twelve owners restoring is a save outliving one of its objects, and reporting it would fire on almost every real load. Check report.Outcomes against what you expected if a specific object is missing rather than all of them.
I quickloaded and my crafts are still running¶
Loading a save taken while nothing was in flight now clears them, the same way loading one taken mid-craft replaces them. If you are on an older build, an idle capture wrote no section at all and the Crafting participant was never called on restore, so whatever you started after saving kept going.
I loaded a save and an NPC's wallet was emptied¶
By design, and it is the one place a participant clears rather than applies on top. A capture skips an owner with no wallet, so an owner paid after the save was written has no entry in it — and a restore that wrote only what the save names would leave that money in place while restoring the player's, which is the same duplication the currency-level union closed, one level up.
If your game's wallets legitimately outlive a load — a persistent world, a shop economy outside the player's save, a partial restore — opt out:
new CurrencySaveParticipant(service, currencies, unsavedWallets: UnsavedWalletPolicy.Leave);
Leave still warns and names the owners, because the balance it preserves is indistinguishable from one arriving out of nowhere. See System Guarantees Matrix → §8.
The console says a wallet "could not be zeroed"¶
Your ICurrencyService answers HasWallet but returns false from ICurrencyWalletQuery.TryGetHeldCurrencies, so the participant can see that a wallet exists but not what is in it — and it will not guess. Implement TryGetHeldCurrencies on your service, or use SceneCurrencyService, which always can.
Versioning and compatibility¶
What happens when a player loads a save from an older build?¶
The version the payload was written with reaches your Restore, and you decide. Framework participants are all at version 1, so nothing has needed migrating yet.
For your own participants, branch on the version parameter. See Integration Surfaces → Choosing a version.
What about a save from a newer build?¶
Two separate answers:
- Newer envelope version — still read. Sections are self-describing, so the build restores the ones it recognises rather than refusing the file outright.
- Newer section version — the participant decides. The framework participants refuse, because applying data you do not understand is worse than declining it.
When do I bump Version?¶
When the payload changes in a way older code could not read — a renamed or removed field, a changed meaning, a restructured shape.
Adding an optional field that older code ignores harmlessly does not need a bump.
Can I rename a participant key?¶
You can, but every existing save's data for that section is orphaned — it stays in the file, nothing claims it, and it reports as Unrecognised.
If you must: keep a participant reading the old key for one release while writing under the new one, then retire the old.
SKUs and missing systems¶
What happens on a build that does not have every system?¶
Supported and normal — the framework ships in SKUs.
Sections nobody claims report as Unrecognised, which does not trip report.Success, and they come back verbatim in report.Unrecognised.
How do I stop a save shrinking every time it is loaded?¶
Pass the unrecognised sections back when you next capture:
var report = RevSaveCoordinator.Restore(json, participants);
_carried = report.Unrecognised;
// later
string json2 = RevSaveCoordinator.Capture(participants, out _, _carried);
This is not automatic — unless you use RevSaveManager
Omit it and saving on a build with a system removed permanently discards that system's data. Load a Complete-era save in a project without Crafting, save again without carrying over, and the Crafting section is gone.
RevSaveManager.Load keeps those sections itself — Unrecognised plus Unapplied — exposes them on CarriedOver, and feeds them into the next Save. Nobody wires the parameter by hand, because you have to read the coordinator's remarks to learn it exists. That is most of what the manager is for.
Should I always pass carryOver?¶
Yes, unless you have a specific reason not to. It costs nothing when there is nothing to carry, and the failure mode without it is silent data loss.
A carried section is dropped in favour of a live participant that wrote a section under the same key, so it cannot resurrect stale state. Declaring the key is not enough: a participant that threw, or that had nothing to save, produced no section — so the carried one is written and the key survives the save. Either way the collision is reported as Displaced and the dropped section is handed back, never lost in silence.
The manager kept carrying data after I loaded a different slot¶
It should not, and the two cases are worth telling apart.
CarriedOver is replaced by each load, not accumulated — loading slot B does not drag slot A's passengers into it. But a load that could not read the slot at all (a missing file, a store that failed) applies nothing and leaves the previous carry-over exactly where it was, because there is no new answer to replace it with.
A load that did read the slot and then rejected the payload is the other way round: the coordinator returned a report, so CarriedOver is replaced by that report's lists, which for a fatal payload are empty. If you were holding passengers you cared about, capture them from CarriedOver yourself before loading something you are not sure about.
Writing participants¶
Should a participant throw, or return null?¶
| Situation | Answer |
|---|---|
| Nothing to save | Return null from Capture — records as Skipped, writes no section |
| Payload genuinely unusable | Throw from Restore — records as Failed, names the section |
| One bad item inside an otherwise fine payload | Handle it yourself — drop the item, keep the section |
The Crafting participant takes the third route for unknown recipes: one job is dropped with a warning and every other job still loads. Losing one job beats losing all of them.
Does the order I register participants in matter?¶
For capture, no: each participant snapshots state it already owns into its own section, so no order produces a different file.
For restore it can, and only in one case: a participant whose Restore reads or writes another system's state. Everything else is independent — Inventory does not care whether Health has run.
If yours is that case, do not rely on list position. Implement IRevSaveOrdered and declare RevSaveOrder.Early or RevSaveOrder.Late, and the coordinator moves it wherever the caller listed it. A list you ordered deliberately still comes back untouched, because the sort is stable and everything that does not implement the interface shares RevSaveOrder.Default.
The framework's one such participant already does this: CraftingSaveParticipant declares RevSaveOrder.Late, so it restores after Inventory and Currency without you arranging it. Older documentation asked you to list it last by hand; that advice still produces the right result, but it is no longer what makes it correct.
Can my own data sit in the same file as framework data?¶
Yes, and it is not second-class. Implement IRevSaveParticipant, use your own key prefix, and hand it to the coordinator alongside the framework ones. Quest flags, unlocked levels, settings — all the same mechanism.
Can one participant read another's payload?¶
No. The coordinator never parses payloads and neither should you — formats are deliberately private so they can change freely.
If you need state another participant owns, get it from that system's own API at capture time, or write your own participant for it.
Behaviour and failure¶
report.Success is false — did anything load?¶
Probably yes. Success means nothing failed, not nothing applied.
Check FatalError first: if it is set, the payload was unreadable and nothing was applied. Otherwise some sections failed and the rest applied — report.Outcomes says which were which, and report.ToString() is a readable summary.
Does a participant throwing break the save?¶
No. It is isolated, recorded against its own section, and every other participant still runs. That mirrors how the framework treats consumer callbacks everywhere: your game does not break because one handler misbehaved.
Two participants declared the same key — which wins?¶
The first registered. The clash is reported as DuplicateKey and trips report.Success.
It is reported rather than resolved because picking by enumeration order produces behaviour that only shows up in someone else's project.
Can I save on a background thread?¶
Not the capture itself — participants read live Unity state, so Capture must run on the main thread.
Once you have the string, writing it to disk on a background thread is entirely fine. The string is the boundary.
Is the save file human-readable?¶
The envelope is JSON, so keys, versions and timestamps are legible. Each payload is whatever its participant wrote — the framework ones are JSON too, but nested as escaped strings, so it is readable with effort rather than pleasant.
Do not build tooling that edits payloads in place. Formats are private and free to change.
What stops another participant's data being read as mine?¶
A section is addressed only by its key, and nothing stops two participants choosing the same string. Worse, JsonUtility never fails on a document it does not recognise: a field the JSON omits comes back empty rather than null, so a foreign payload under your key parses cleanly into an empty everything and restores as a success that applied nothing.
The framework's own participants each write a format stamp — one field naming the participant, written by Capture and required back by Restore — and refuse a section that does not carry it. A collection being present is not a stamp, and neither is the section version: another writer's version can be 2 as easily as yours.
Write one in your own participants. It is the single cheapest thing that separates your format from somebody else's, and the failure it prevents is silent. If your payload replaces state rather than applying entries one at a time, treat it as required rather than advisable — that is the shape where a foreign empty document does not merely do nothing, it wipes what you had.
And version the change. A stamp that existing saves cannot carry has to be required only from the version that introduced it, or you refuse every file already on a player's disk.
Related¶
- Overview — what the system is and the shortest working example
- Integration Surfaces — writing a participant properly
- Guarantees Matrix — the behavioural contract, including non-guarantees
- System Boundaries — what it deliberately will not do