Approach for Saving Runtime Objects

Hello,
this post is related to the RPG saving system.
I though of a way to implement additional saving of runtime objects and just wanted to share my experience and thoughts on this.

Lets first define what a runtime objects is.
Its an GameObject (GO) that

  • gets destroyed at runtime but is already present at scene load (1)
  • spawns at runtime (2)
    • and enventually gets destroyed

The problem with these objects is that the saving system wont be able to track their states.

  • (1) The Problem here is that the saving system wont be able to save the state if the object gets destroyed. Consider the following case: There are objects the player can pick up like Items. The gameobject in the world gets destroyed and the pickup is just some data in the invetory. In this case the saving system has to capture the state of the inventory item. But it has to destroy the game object after the scene finishes loading. Otherwise this item will still exist in the scene and can be picked up by the player again.

  • (2) If a gameobject spawns at runtime it is not possible to save the state, because no UUID is assigned. However this could be an easy fix if the UUID gets checked at Awake() or Start(). But still the saving system wont be able to find that object at sceneload and therefore is unable to restore the state.

There are workarounds that can be used for specific cases. For example a solution to (1) is instead of destroying objects just disable them. But in order to also track disabled GOs it is important to not use GetComponents() because it will only return active scene objects. With unity 2020 there will be an additional parameter that can be used to also include inactive GOs but for now one solution to this is to use Resources.FindObjectsOfTypeAll() which returns all scene GOs but also prefabs, materials etc. So it is important to check whether the returned objects existst in the scene. This can be done using a check

if(saveable.gameObject.scene == SceneManager.GetActiveScene())

But i do not like to disable the object instead, because this will cause the object to still persist in the scene. Consider this case: There is a GameObject which can be interacted with and put into the inventory. The GameObject in the scene will then just be disabled and not destroyed. The item in your inventory can then be placed again into the world creating a new GameObject.
just like you can do with blocks in minecraft
This would lead to many unused objects being present in the scene.

The solution i came up with is to use a manager that handles the runtime GOs which is also mentioned in the BONUS lecture.
First of all the Manager itself must also be saved to disk because the objects it has to respawn or destroy must somehow be remembered. So my idea was to make the manager script implement ISaveable and put it on a gameobject that also has a SaveableEntity component ( i called my SaveableEntity SaveableObject, so if I write SaveableObject by mistake it is equivalent to the SaveableEntity component).

Ok but what should the manager track?

  • In case of (1) for the saving part the manager must detect that an object existing at compile time was destroyed during runtime.
  • In case of (2) for the saving part the manager must detect that an object was created during runtime.

For those runtime objects I used a separate saveable class named SaveableRuntimeObject.
The idea was that the manager looks for those SaveableRuntimeObjects and in case of (1) the GO will be destroyed and in the case of (2) it will first be respawnt and then the state restored. But for this case there were 2 problems still. First it is not possible to serialize a gameobject because it is not system.serializable. And second if scripts contain code that expect a GO to be parented this must also be considered when spawning the GO. So this object also has to be spawned underneath the correct parent GO if needed. For this the manager has also to include the parent for some GOs in order to know where to spawn them in the hierarchy.

Ok now things get a bit complicated. Because - once again - it is not possible to serialize a reference to a parent Transform I made some additional adjustments. I thought of making a parent class and let both SaveableObject and SaveableRuntimeObject inherit from it. The parent class is called UniqueIdentifiable. It can create a UUID just like the SaveableObject did. The idea is to use this class for parent objects if a SaveableRuntimeObject has to be parented. The parent can then be looked up using its UUID.
Instead of making SaveableRuntimeObject inherit from UniqueIdentifiable I could also just have let SaveableRuntimeObject inherit from SaveableObject but i did not want the saving system class to also find the SaveableRuntimeObjects as they inherit from SaveableObject.

Now how does the manager work? As said before the manager itself is ISaveable because it has to save the objects that were destroyed / spawned and along with some additonal data. Lets first examine how obejcts destroyed during runtime are handled (1).
My idea was to track what happens with the RuntimeSaveableObjects that are present during compile time. When the game is played for the first time, the manager needs to know which of them do exists at scene load. So in Awake() it looks for all UniqueIdentifiable. Why not just SaevableRuntimeObject? Because i need all of them during restore including the parent if an object has to be spawned inside a parent in the hierarchy. Then if the UniqueIdentifiable is in fact a SaveableRuntimeObject it is added to a separate Dictionary containing <string, RuntimeSaveable>. This RuntimeSaveable is not SaveableRuntimeObject. It is a struct that the manager uses. This struct contains a couple of information.


    [Serializable]
    struct RuntimeSaveable
    {
        public string prefabPath;
        public object state;
        public bool wasAliveAtSavingTime;
        public bool isRuntimeInstantiated;
        public string parentUUID;
    }
  • The idea of the prefabPath is used to spawn objects from a prefab asset. Because the GO itself cannot be serialized my idea was to save the path and then instantiate from that asset and restore the state… more on this later.
Code I used to get the prefab path
    SerializedObject serializedObject = new SerializedObject(this);
    SerializedProperty property = serializedObject.FindProperty("prefabPath");

    if (string.IsNullOrEmpty(gameObject.scene.path))
    {
        PrefabStage ps = PrefabStageUtility.GetPrefabStage(gameObject);
        if (ps != null)
            property.stringValue = ps.prefabAssetPath;
        else
            property.stringValue = AssetDatabase.GetAssetPath(gameObject);
    }
    else
    {
        property.stringValue = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(gameObject);
    }
    serializedObject.ApplyModifiedProperties();
  • The state is the GOs state that gets restored after instantiation just like the other SaveableObjects.

  • wasAliveAtSavingTime is used by the manager to cache if an object was alive when saving occured. The manager can then delete those from the dictionary afterwards.

  • isRuntimeInstantiated is used for objects that were instantiated at runtime (2)

  • parentID is the UUID of the parent if the object needs to be instantiated at a parent in hierarchy

Capturing state of type (1) objects

Now when the user saves the game the managers captureState() method is called by the saving system. The manager marks all entries in the dictionary as not alive. Then all SaveableRuntimeObjects are retrieved in the scene. The UUID of those is used to lookup the dictionary and mark the entry as alive. Then, after all scene objects are processed, all the entries that are not marked as alive are deleted and the dictionary is returned to the saving system.

Capturing state of type (2) objects

Lets shortly talk about objects of type (2). During Awake() they will get a reference to the manager and during start() they will generate a UUID if needed and automatically register at the manager using their generated UUID including information like parent ? yes : no. The process of how (1) are saved can also be used to save these obkects (2).

Restoring state of type (1) objects

When the user wants to load a savegame the saving system calls RestoreState() on all SaveableObjects which in turn also causes to call RestoreState() on the manager. The manager itself already got all the UniqueIdentifiables and SaveableRuntimeObjects that were present at scene load during Awake(). In restoreState() the Dictionary is replaced by the deserialized one (that was previously saved) and the UniqueIdentifiable Dictionary (which also contains all the SaveableRuntimeObjects, since they inherit from UniqueIdentifiable) is kept. The manager then looks for all SaveableRuntimeObjects in the UniqueIdentifiable Dictionary. For every entry the UUID is used to check if it also exists in the deserialized dictionary. If thats the case the object with the corresponding UUID was alive at save time and then the objects state is restored, otherwise the GO is destroyed.

Restoring state of type (2) objects

In a second pass all the RuntimeSaveables are considered that are marked with isRuntimeInstantiated == true in the deserialized dictionary, aka objects of type (2).

Those objects are instantiated first using the prefabasset path (if needed at specified parent). There are several methods that can be used to instantiate them using the asset path like resources API but i think that adressable assets system can also be used instead.
I did not look much into those APIs because in my game there are currently just very few objects that can be spawned and i decided to make the spawner responsible and handle the saving/loading.

Sidenote: If the spawner is responsible

In that case the spawner (the GO that spawned the type (2) object during runtime) is itself a SaveableObject and captures the state of the corresponding type (2) GO in its own CaptureState() and also spawns and restores the state in its own RestoreState(). In this case the spawner script however has to know a bit more things. How many and what GOs were spawned / are still alive. It also needs a reference to the GO / Prefab or again the path to the prefab asset in order to spawn it into the scene. This additional information is why i think it is a bit ugly to let the spawner handle the saving/loading of those objects. Because it blows scripts up that spawn objects during runtime.

After the object is instantiated the UUID must be overridden with the UUID from the deserialized dictionary entry and the objects state must be restored.
Thats also the point why I decided to generate the UUID during Start() and not Awake().
If the UUID is generated during Awake() this will also happen directly after the manager instatiates them. Which means they will generate a UUID and then register at the manager with a different UUID automatically.
In theory this should not be a problem as far as im not overseeing something. Thats because the manager should override the UUID and restore the state after it instantiated the object anyway. Later when the user saves the game again the entry with the newly, during Awake(), generated UUID will be deleted because the UUID cannot be found in the scene. But as mentioned before i did not look much into objects of type (2) and how to restore them using the manager because in my game only very few exist and those are spawned by the spawner and not the manager.

Alright. Looks like I am nearly done with this post. I just want to mention one additional thing that came up during the extension of the saving system. In my game I am using an inventory that uses scriptable objects as items. Those cannot be serialized again! Sometimes you want your item also to hold a reference to a GameObject or a Sprite depicting the Item. Those are not serializable anyhow. To solve this problem I gave my Item class an addtional field int id. The item class looks then somewhat like this

public class Item : ScriptableObject
{
    public int id;
    public Sprite sprite;
    public GameObject gameObject;
    ...
}

Because my game does not automatically generate items it is okay to manually set the id for all the items. The id should be unique. I then created a scriptable object thats only purpose is to behave like a database. This database contains a List and a method Item GetItem(int id) to retrieve the corresponding Item given an id. I could have used a Dictionary<int, Item> instead but performance is negligible for short Lists (maybe the hashing algorithm makes the Dictionary even slower). The inventory does hold a reference to this database. Now when my inventory is saved the id of the items is serialized. When restoring the inventory state my inventory takes the deserialized id, passes it to the database and adds the Item returned from the database.
I think this approach can also be used to instantiate GOs from prefabs instead of saving the asset path.

Well that was a long post… I just wanted to share my thoughts and the experience I made extending the saving system. Maybe this is helpful to anyone who also wants to save objects that are destroyed / created at runtime or that are not serializable like in the case of the inventory.
I know that this system can be improved. I might also overcomplicated some things. Instead of tracking objects that still exist during save time I could also have used callbacks like OnDestroy() but I was not sure about it. OnDestroy is also called when a scene is unloaded so maybe this could have caused some issues.

I would like to hear about what you think, improvements, your own experience, etc.

Merry Christmas to all readers and the GameDev.tv team.

Updated Sat Dec 28 2019 20:43

Privacy & Terms