Curitor
Gameplay Systems · Topic Guide

Saving & Loading

One small interface makes anything survive a quit. Saves are plain, readable JSON you can open, diff and edit by hand — no binary blobs, no bespoke serializer per feature.

What it is

Persistence is the seam every other module plugs into rather than a feature of its own. A class implements one interface, registers itself, and its data is written and restored — the save system never learns what that class is, and you never edit the save system to add a consumer.

Everything is written as plain JSON under the player's persistent data folder, one file per participant, pretty-printed. That is a deliberate choice, not a shortcut: a save you can read is a save you can debug, diff in version control, hand-author for a test, or fix when a player reports something odd.

Technical elements

SaveManager is created automatically at boot — you never place it, and it survives scene loads. It holds the registered participants, the storage backend and the authority check.

FieldTypeDefaultWhat it does
SaveIdstringStable, unique id — and the file name the data lives in. Never change it once you have shipped: renaming it orphans every existing save. Convention: "mygame.thing".
ScopeSaveScopeSlotWHERE the data lives. Slot = belongs to a save slot (progress, world state). Shared = follows the installation regardless of slot (settings, key bindings).
OwnerSaveOwnerWorldWHO may write it — a different question from Scope, and what the authority check reads. World = the host writes it, and only the host (world memories, dropped items). Player = every machine writes its own (settings, records, a player's inventory and appearance). A player's inventory is slot data the PLAYER owns.
PriorityintModule (100)Load order. Whatever is read FROM must restore first: Manager (0), Module (100), Consumer (200). Ties break on SaveId, never on registration order.
ModeLoadModeGreedyGreedy = restored by LoadAll at load time, for things that already exist. Lazy = restored when the object asks, so something that spawns later still gets its data.
Serialize()SavePayloadBuild the object to write. Return null to write nothing at all.
Restore(payload)voidApply a payload that has already been version-checked and migrated. Synchronous — see the note on async below.

Implementing it looks like this. Note that the stored shape is a separate small class carrying a stable id, which is what appears in the file:

HighScoreStore.cs
using Curitor.PartyCoreKit.Persistence;
using UnityEngine;

// Anything can participate. The Persistence module never learns what
// this class is, and you never edit the Persistence module to add it.
public class HighScoreStore : MonoBehaviour, ISaveParticipant
{
    public string SaveId   => "mygame.highscore";
    public SaveScope Scope => SaveScope.Slot;
    public SaveOwner Owner => SaveOwner.Player;
    public int Priority    => SavePriority.Module;
    public LoadMode Mode   => LoadMode.Greedy;

    private int _best;

    private void OnEnable()  => SaveManager.Register(this);
    private void OnDisable() => SaveManager.Unregister(this);

    public SavePayload Serialize() => new HighScoreSave { best = _best };

    public void Restore(SavePayload payload)
    {
        if (payload is HighScoreSave save) _best = save.best;
    }
}

// The stored shape. The id in the attribute -- not the class name -- is
// what goes in the file, so you can rename this class freely.
[SaveType("mygame.highscore", 1)]
public class HighScoreSave : SavePayload
{
    public int best;
}

Remembering a scene object

For scene objects there is no interface to implement and no payload class to write. Add a Memory component, then compose what should survive from a list of memories: Position keeps where the object was, Active State keeps whether it was still there, Pickup keeps whether a collectable had been taken, and Container keeps what a chest or other WorldInventorywas holding, resolving each saved item id back to an asset through the level's World Drop Store Known Itemslist — the same registry loose drops use. Identity comes from the object's Scene Object Id, which the component requires and validates in the Inspector.

Modules add their own memory types the same way nodes are added — one small class, no registration — and they appear in the picker automatically. A memory is itself a versioned payload, so a module that changes the shape of what it remembers gets the same migration hook everything else does.

The three kinds of object

A memory component restores state onto an object, which quietly assumes the object will be there to receive it. Most will be. Two kinds will not, and they need different mechanisms — worth knowing before you wire your own content, because the failure is silent: the save is written correctly and the load simply has nowhere to put it.

FieldTypeWhat it does
Survives in the sceneMemoryA crate you pushed, a door you opened, a lever you pulled. The object is still there when the level loads, so a Memory component puts its state back. This is the ordinary case.
Removed at runtimeRespawnable + MemoryA collectable. It must be CONSUMED rather than destroyed, because a destroyed object cannot be restored — there is nothing left to apply the data to. Respawnable hides it instead; a load un-hides it.
Created at runtimeA storeA dropped item, a spawned prop. It has no scene identity, so no memory can find it. The save holds enough to REBUILD it, and something re-creates it on load — WorldDropStore does this for items.

Loose items on the floor take the third route. They are built at runtime by ItemDropper and have no scene identity at all, so WorldDropStore saves a recipe — item id, how many, where it came to rest — and builds them again on load. It needs a list of the items that may appear in the level, for the same reason CharacterInventory keeps one: a save holds an id, and an id only becomes an asset if something can look it up.

Carried items and equipment

Inventory Save Store sits on the character alongside Character Inventory and persists every stack, which hotbar slot was selected, and each worn piece. One file per seat, so four players on a couch never share a bag; bots are skipped, since a bot is built from the same prefab and nobody wants a save file for one.

Worn pieces are filed under the slot's name, not its enum index — the same instinct as the stable [SaveType]id. Inserting a slot into the enum one day would otherwise move a hat onto a character's feet.

Progression records

ProgressionStore keeps a best time and completion count per level, and lifetime roundsPlayed / roundsWon / roundsDnf / fails per player. There is nothing to install and nothing to place: the store creates itself, finds each level's round controller as scenes load, and writes the moment a round finishes. Bots are excluded — a roster of four would otherwise inflate every count on the host.

A HUD or a scripting condition reads the records straight from the store — ProgressionStore.Instance.BestTimeFor(levelId), CompletionsFor(levelId) and RecordFor(playerId). It is also the clearest demonstration that persistence is a seam rather than a feature: Progression is a separate module the save system has never heard of, added without editing it.

Character appearance

Character Appearance Store persists a CharacterSelection— gender and each category's part — one file per seat, so four players on a couch keep four characters. Gender and category ids are stored by name, never by enum index.

It is also the one participant that is genuinely Lazy. A character does not exist when the level's saves are read, so it pulls its own data on spawn — and nothing wants it restored mid-session, because pressing Load should not re-instantiate every part model under a player who is standing there.

Storage backends

Where saves physically live is a strategy behind ISaveStorage. Three ship: JsonFileStorage (the default — plain files under the persistent data path), PlayerPrefsStorage, and InMemorySaveStorage, which forgets everything on shutdown and is genuinely useful for a kiosk or demo build that should never remember a session. Encryption and cloud saves are not built, but nothing in the interface precludes them.

Versioning and migration

Every payload carries its schema version, so a save written by an older build of your game still loads. Additive changes need no work at all; destructive ones get an explicit hook.

SettingsSave.cs
// Adding a field needs NO migration: a save written before the field
// existed simply arrives with the field at its C# default.
//
// Override Migrate only for a DESTRUCTIVE change -- a renamed field, a
// repurposed one, a unit change -- and bump the version.
[SaveType("mygame.settings", 2)]
public class SettingsSave : SavePayload
{
    public float masterVolume = 1f;

    protected internal override bool Migrate(int fromVersion)
    {
        // v1 stored volume as 0-100; v2 uses 0-1.
        if (fromVersion < 2) masterVolume /= 100f;
        return true;   // false = teaching error, defaults kept
    }
}

When saves are written

Each consumer picks its own moment rather than everything writing on a timer. Settings write the instant they change, so a player who adjusts the volume and closes the game keeps it. There is also SaveAll() for writing everything on demand. Nothing is written mid-round.

Linked & used by

This module consumes:nothing but the engine — no networking, no third-party packages. Its only outside question is "is this machine the authority?", asked through a one-member seam that reads the game mode.

Used by: the settings screen (master volume and UI theme), per-object world memories (including container contents), the inventory module (carried stacks, worn equipment, loose drops), the customization module (character appearance), and the progression module (best times and lifetime counts, fed by the rounds module). The UI theme system supplies the stable theme ids a saved choice refers to; the inventory module supplies the item ids.

Not built yet: encryption and cloud saves (nothing in ISaveStorage precludes them), a save-slot browser (the slot index is in the API and tested; slot 0 in practice), input rebinding storage (no rebinding UI exists to feed it), and mid-round session resume — deliberately excluded, since memories are captured at explicit save points rather than snapshotted.

How to set it up

Nothing to install for the save system itself — it has no assets and creates its manager on demand. To see it working end to end:

  1. Run Install once

    The Setup Window's Install adds the Save and Load controls to the escape-menu settings screen, stamps stable ids onto the shipped themes, makes every Playground pickup remembered, puts the world drop store in the level and the inventory store on the player, and attaches a Container memory to every WorldInventory in the level. Each appears as a row under Persistence in the installation manifest.
  2. Change a setting in game

    Press Escape → Settings, move Master Volume and cycle the Theme. Both apply immediately and save themselves.
  3. Quit and come back

    Stop play mode, start it again, reopen Settings. The volume and theme are as you left them. This is the whole feature in one gesture.
  4. Now do it with the world

    In Playground: shove the crate, collect a stone, drop something from your hotbar, and open a chest — take one item out, put a different one in. Press Escape → Save. Stop play mode and start it again. The crate is where you left it, the stone is still collected, what you dropped is still lying where it fell, and the chest holds exactly what you left it holding — not a fresh loot-table roll.
  5. Open the file

    Saves live in Unity's persistent data folder, under PartyCoreKit/ — on Windows that is %USERPROFILE%/AppData/LocalLow/<Company>/<Product>/PartyCoreKit. The status line under the Save button shows the tail of that path so you can recognise it; every error message carries the path in full. Inside is pck.settings.json:
    pck.settings.json
    {
        "typeId": "pck.settings",
        "schemaVersion": 1,
        "masterVolume": 0.25,
        "themeId": "Slate"
    }
  6. Edit it by hand, then press Load

    Change masterVolume in a text editor, save the file, and press Load in the settings screen. The change applies live. If you type something invalid, the console tells you which file, which field and how to fix it — and leaves your file alone so nothing is lost.
  7. Add your own data

    Implement ISaveParticipant as shown above. Register it, and it saves and restores with everything else.