beasty-save-system / guides / scene-state.md

Scene state

How to save the objects that are already in your scene: BeastySaveable marks an object as worth saving, BeastySaveManager writes them all to one file. This page covers both, and the four behaviours around ids that decide whether your objects come back or not.

If you have not built the basic setup yet, save-without-code.md walks through it click by click. This page explains what that setup actually does.

The two components

BeastySaveable goes on any object whose state you want to keep. It has an id and a list of components to capture. One per GameObject — the component disallows duplicates.

BeastySaveManager goes on one object in the scene. It holds the BeastySaveSettings used by the scene save, and it is the thing you call SaveAll and LoadAll on. It keeps a static Instance.

Every enabled BeastySaveable registers itself with the manager. SaveAll walks that registry, asks each saveable’s ticked components for their state, and writes the lot into one file, keyed by id.

Ids

The id is how a save file recognises an object across sessions. Everything about scene saving comes down to whether the id is the same the next time the game runs.

It is auto-generated, and it is editable

Add a BeastySaveable and it gets an id automatically. The inspector shows it as Save Id, with a New button next to it that generates a fresh one.

You may edit the id by hand, and there are good reasons to — player, door.cellar, chest.tutorial are easier to find in a save file than a random string.

Warning Changing an id orphans the data already saved under the old one. The state is still in the file, under a key nothing in the scene claims any more, and the object with the new id will not find it. Decide on your ids before you have saves you care about. The New button has exactly the same effect: it throws away the object’s connection to every existing save.

A prefab asset carries no id

The prefab asset itself has no id. Each instance gets its own, generated when it is created. Two copies of the same prefab in a scene are two different saveables with two different ids, which is what you want — they are two different chests.

An object spawned at runtime gets a fresh id every time

This is the one that catches people.

An object you Instantiate while the game is running gets a brand new id, every single time. Play the game, spawn an enemy, save: the enemy’s state goes into the file under an id generated in that session. Restart, spawn an enemy, load: the new enemy has a different id, finds nothing in the file, and keeps its default state. The old state sits in the file, unreachable, forever.

The fix is to give the spawned object an id you control, at spawn time:

using Beasty_SaveSystem;
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    public GameObject enemyPrefab;

    public GameObject Spawn(string enemyId, Vector3 position)
    {
        GameObject enemy = Instantiate(enemyPrefab, position, Quaternion.identity);

        // A stable id, and the components to capture.
        BeastySaveManager.Register(enemy, $"enemy.{enemyId}", enemy.transform);

        return enemy;
    }
}

Register adds the BeastySaveable for you, sets the id, and assigns the component list — so the object is saveable from that moment on. There are two overloads:

static BeastySaveable Register(GameObject target, params Component[] components);
static BeastySaveable Register(GameObject target, string id, params Component[] components);

The one without an id uses whatever id the object already has, which is only useful for objects that came from the scene. For anything you spawned, use the overload that takes an id.

The id has to be something you can reproduce on the next run. enemy.goblin.3 derived from your own game data is stable. enemy. plus Guid.NewGuid() is not — that is the same problem again, in your own code.

The counterparts, when an object goes away for good:

BeastySaveManager.Unregister(gameObject);   // this one is gone
BeastySaveManager.UnregisterAll();          // clear the registry
BeastySaveManager.SyncSceneSaveables();     // re-scan the scene for saveables

Two objects with the same id: the second one disappears

If two saveables end up with the same id, the second one is not registered. An error is logged, and that object silently falls out of the save — it is not written, and it is not restored.

Duplicate ids happen when you copy-paste a GameObject that already has a hand-typed id, or when you spawn two objects and pass the same id to Register. If an object mysteriously refuses to persist, check the console for the duplicate-id error, then check its id in the inspector.

Inactive objects are saved

A BeastySaveable registers on OnEnable and unregisters on OnDestroynot on OnDisable.

So an object you deactivate with SetActive(false) is still in the registry, still written to the save, and still restored on load. That is the behaviour you want: a disabled enemy or a closed UI panel keeps its state instead of losing it the moment it goes away.

Destroying the object is what removes it from the save.

Several components of the same type

Two BoxCollider components on one GameObject both round-trip. They are stored separately in the file (the second one gets a #1 suffix on its key) and restored to the right slots. You do not have to do anything for this to work.

Saving and loading

From a button, with no code

SaveAll(string) and LoadAll(string) are void methods that take a single string, which makes them wireable straight to a uGUI Button’s On Click () with the slot name typed into the inspector. This is the zero-code path, and it is covered step by step in save-without-code.md.

From code

BeastySaveManager manager = BeastySaveManager.Instance;

SaveResult saved = manager.SaveAllNow("slot1");
if (!saved.Success)
    Debug.LogError($"Save failed: {saved}");

LoadResult loaded = manager.LoadAllNow("slot1");
if (!loaded.Success)
    Debug.LogError($"Load failed: {loaded}");

SaveAllNow and LoadAllNow are the ones that return a result. Use them whenever you want to know whether it worked — which is always, in shipped code. SaveAllNow also takes an optional metadata dictionary:

manager.SaveAllNow("slot1", new Dictionary<string, string>
{
    ["chapter"] = "3",
    ["playtime"] = "01:22",
});

SaveAll and LoadAll do the same work but return nothing, because a UnityEvent cannot use a return value. They store the outcome instead:

manager.SaveAll("slot1");
SaveResult result = manager.LastSaveResult;   // and manager.LastLoadResult after a load

manager.DeleteSlot("slot1") removes a slot and its backup.

The events

void OnEnable()
{
    BeastySaveManager.Instance.SaveCompleted += OnSaved;
    BeastySaveManager.Instance.LoadCompleted += OnLoaded;
}

void OnDisable()
{
    if (BeastySaveManager.Instance == null) return;
    BeastySaveManager.Instance.SaveCompleted -= OnSaved;
    BeastySaveManager.Instance.LoadCompleted -= OnLoaded;
}

void OnSaved(SaveResult result)
{
    if (result.Success) ShowToast("Game saved");
    else ShowError(result.Message);
}

void OnLoaded(LoadResult result)
{
    if (result.Success) return;
    if (result.BackupAvailable) OfferBackupRestore();
    else ShowError(result.Message);
}

SaveCompleted and LoadCompleted fire for every save and load, from the buttons as well as from code. They are the right place to put the “Game saved” toast, and the right place to offer the backup when a load fails. LoadResult.BackupAvailable is filled in on every load result, success or failure — see backups-and-corruption.md.

What actually ends up in the file

A scene save has the type Beasty.SaveGroup and looks like this:

{
  "saveables": {
    "player": {
      "UnityEngine.Transform": { "module": "core", "data": { } }
    },
    "door.cellar": {
      "UnityEngine.BoxCollider":   { "module": "physics3d", "data": { } },
      "UnityEngine.BoxCollider#1": { "module": "physics3d", "data": { } }
    }
  }
}

One entry per saveable id, one entry per saved component inside it, and the module that wrote each. The #1 suffix is the second component of the same type on that object.

A saveable that is in the scene but has no data in the file — a new object added after the save was written — is left untouched, with a warning. It is not an error, and it does not fail the load.

Ticking a component you cannot save

The Saved Components checklist labels each component with the layer that can convert it: core, a module id like ugui, or dev for a converter you registered yourself.

If you tick a component nothing knows how to convert, the inspector warns you, and a save will fail with TypeUnavailable. Either untick it, enable the module it needs (converter-modules.md), or write a converter for it (custom-converters.md).

See also