Tips and Resources

Unity How to save and load references to GameObjects

Unity guide for saving and loading references to gameObjects in a scene.

In this example, I'm working on a platformer game and need to save the state of certain gameObjects as the player progresses through you game. In particular I need to save the positions of platforms that have been moved by the player.

Firstly - Things that didn't work for me

Directly saving references to GameObjects

If you try to save references to gameObjects in your scene directly into a save game file (such as using the BinaryFormatter) you'll find that Unity's gameObjects cannot be directly serialized like other variables - if you try to write a gameObject to a save game file using the BinaryFormatter, you'll likely hit an error such as SerializationException: Type 'UnityEngine.GameObject' in Assembly 'UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' is not marked as serializable.

Using an object's InstanceID

You may also have tried using the 'InstanceID' of your gameObjects to record their state, but that doesn't work either as each gameObject's InstanceID is generated at runtime, so each object will get a new ID each time your game is run.

Unity's GlobalObjectId

Unity version 2019.2 introduced a new ID system that allows you to reference gameObjects by their unique globalObjectID which persists between executions of your game. Unfortunately GlobalObjectIDs are only available when using the editor (are not available in build) so can't be used to load and save objects when your game is deployed to a device.

My Solution to the gameObject PersistentID problem  

To work around this, the solution I came up with was to create my own GUID system. Note this likely isn't the most efficient or performant way to do this, but it works! Many blog posts I've read on this subject offer abstract use cases and are not a easy to follow in a practical way, or don't offer examples of how to achieve this, so here goes!

By way of example, here's what I looking to achieve with this approach - I'm building a 2D platformer game where some of the platforms can be moved by the player. When the player reaches a checkpoint part-way through the level, I want to record the positions of all the moveable platforms that have been moved by the player to a savegame file. If the player dies or re-loads the game from the checkpoint, the game sets up the level with the movable platforms in the new positions.

PersistentObject script

The first step in being able to save and load an object's state is to be able to uniquely identify each object. This unique ID needs to be created when the gameObject is added to the scene, and can't change between executions of the game.

To do this, I've created a PersistentObject.cs script and added to to the moveable platform prefab:

using System;
using UnityEditor;
using UnityEngine;

[ExecuteInEditMode]
public class PersistentObject : MonoBehaviour
{
    public string guid;

#if UNITY_EDITOR

/// <summary>

/// Create a new unique ID for this object when it's created
/// </summary>
    private void Awake()
    {
        if (Application.platform != RuntimePlatform.WindowsEditor)
        {
            guid = Guid.NewGuid().ToString();
            PrefabUtility.RecordPrefabInstancePropertyModifications(this);
        }
    }

/// <summary>
/// This is only needed if you are adding this script to prefabs that already have instances of in your scenes
/// - This will update any object that doesn't already have a guid with one.
/// </summary>
    private void Update()
    {
        if (String.IsNullOrEmpty(guid))
        {
            guid = Guid.NewGuid().ToString();
            PrefabUtility.RecordPrefabInstancePropertyModifications(this);
        }
    }
#endif
}

A few notes about this script:  The combination of [ExecuteInEditMode] and the  #if UNITY_EDITOR block means that the Awake() and Update() functions only run when you are in the editor, and not in the build of the game. The if (Application.platform != RuntimePlatform.WindowsEditor) statement prevents the code from running when you are testing the game from within the editor (when you press the play button to test your game).

The call to PrefabUtility.RecordPrefabInstancePropertyModifications(this) tells the Editor to save value of the guid variable in the scene.

Once added the platform prefab, you'll see guids for each platform in your level:

Note the blue line next to the Guid - this indicates that the Guid value is saved in the scene and won't be generated at runtime.

Using the Persistent Object Guid

Now that each movable platform has it's own unique ID, we need a place to store references to these objects when they move. In my case, I've got an empty game object in my scene with a LevelController script attached to it. That script has a public list of platformControllers components.

public class LevelController : MonoBehaviour
{ ///.... public List<PlatformController> movedPlatforms; // List of moved platforms in the level(to save in the checkpoint data)
///...
public class PlatformController : MonoBehaviour
{
public void MovePlatform(){
///....
    LevelController level = FindObjectOfType<LevelController>(); //find the level controller
    if (level) //null check level controller
        {
            level.movedPlatforms.Add(this);//add this to the level controllers list of dropped tiles
        }
///...

Note that calling FindObjectsOfType() isn't the best idea - it's not very performant. It would be better to cache a reference to the levelController in the PlatformController but for the sake of example, it will do.

Ok, so now all of our movable platforms have a unique ID and whenever a platform is moved, the level controller has a reference to all the moved platforms in the level.

PlayerData class

The next step is build a system to save the references to these moved platforms and any other state information we'd like to save about the platforms.

To save the player's progress, I use a PlayerData class that stores information about the player eg their current & max health, elapsed playthrough time etc and about the level - the moved platforms new positions, door that have been opened etc.


using System.Collections.Generic;
using UnityEngine;
[System.Serializable] public class PlayerData
{
    ///...
    public List<MovedPlaformData> movablePlatformsToMove; // GUIDs and positions of movable platforms in the level to move on level load
    [System.Serializable]     public struct MovedPlaformData
    {
        [SerializeField]       public string guid;
        [SerializeField]       public SerializedVector3 pos;
        public MovedPlatformData(string g, Vector3 p)
        {
            this.guid = g;             this.pos = new SerializedVector3(p);
        }
    }
    public PlayerData (GameManager gameManager)
    {
        ///...
        movedPlatformsToMove = new List();
        //loop through the list of dropped tiles and add their ID and position to the datafile
        foreach (PlatformController platform in gameManager.levelController.movedPlatforms)
        {
            movedPlatformsToMove.Add(new MovedPlatformData(platform.GetComponent<PersistentObject>().guid, platform.transform.position));
        }
    }
}

Of note in this class is the use of a struct that stores the GUID of the movable platform (as a string) and the position of the platform (as a Serialized Vector 3) - Vector3's are not normally serializable; I've created a serializable version that you can read more on here.  The PlayerData constructor takes in the GameManager object - a script that manages the overall game, and has a reference to the levelController for the current level. When the player hits a checkpoint in the game, a new PlayerData object is created using the current list of moved platforms in the level controller.

Save the PlayerData object to disk

Now that we have a fully serialized PlayerData object, we can write it to disk.  The SaveSystem static class below takes a reference to a GameManger, builds a new PlayerData object and writes to to persistent storage. SavePlayer() is called whenever you want to save the players progress, such as hitting a checkpoint.

using UnityEngine;
using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
public static class SaveSystem
{
    public static void SavePlayer(GameManager gameManager)
    {
        PlayerData data = new PlayerData(gameManager);
        WriteSaveDataToDisk(data);
    }
    private static void WriteSaveDataToDisk(PlayerData data)
    {
        BinaryFormatter formatter = new BinaryFormatter();
        string path = Application.persistentDataPath + "/Game.savegame";
        FileStream stream = new FileStream(path, FileMode.Create);
        formatter.Serialize(stream, data);
        stream.Close();

///... 
}

Load the PlayerData object from Disk

Great - now we've saved the data about the position and GUID of each moved platform to disk, we need a mechanism to retrieve this information on level load and set up the platform's new locations. This is the LoadPlayer() method is in the static SaveSystem script:

 public static PlayerData LoadPlayer()
{
    string path = Application.persistentDataPath + "/Game.savegame";
    if (File.Exists(path))
    {
        BinaryFormatter formatter = new BinaryFormatter();
        FileStream stream = new FileStream(path, FileMode.Open);

        try
        {
            PlayerData data = formatter.Deserialize(stream)as PlayerData;
            stream.Close();
            return data;

         }
         catch (Exception)
         {
            stream.Close();
            return null;
         }

    }
    else
    {

    return null;
    }
}

LoadPlayer() returns a PlayerData object, which we can then use to setup the platforms in the level. This code snippet below is in the GameManager's Awake() method:

///... 
PlayerData data = SaveSystem.LoadPlayer();
       if (data!=null) // null check
       {
           PersistentObject[] o = FindObjectsOfType<PersistentObject>(); //find all the persistent objects in the level
           foreach (PersistentObject obj in o) //for each persistent object found in the level
           {
               if(data.movablePlatformsToMove != null) //if the save game data has a list of movable platforms
               {
                   foreach (MovedPlatformData platform in data.movablePlatformsToMove) // for each movable platform in the save game file
                   {
                       if (platform.guid.Equals(obj.guid)) // if this object's guid is in the movedPlatform list from the save game data
                       {
                           obj.transform.position = platform.pos; // move the platform to the postion in the save game data
levelController.
movedPlatforms.Add(obj.GetComponent<PlatformController>()); // update the levelController's moved platforms

                      }
                   }
              }
           }

///...

That's it! When the level loads, the GameManage's Awake() function is called. It grabs the save game data from the disk, creates an array of all the gameobjects in the level with persistentIDs and then loops through the list of moveable objects in the save game file. If there is a match between the ID of an object in the level and an object in the list, it updates the position of the game object.

Again I stress that this isn't the most efficient way to do this, but I think as an example it's easy enough to follow along. If you've got any suggestion on improving this method, let me know in the comments.

References

https://www.gamasutra.com/blogs/SamanthaStahlke/20170621/300187/Building_a_Simple_System_for_Persistence_with_Unity.php

https://catlikecoding.com/unity/tutorials/object-management/persisting-objects/

https://answers.unity.com/questions/1249093/need-a-persistent-unique-id-for-gameobjects.html

https://docs.unity3d.com/2021.2/Documentation/ScriptReference/GlobalObjectId.html

https://forum.unity.com/threads/does-object-getinstanceid-value-ever-change-at-runtime.416587/

https://bdts.com.au/tips-and-resources/unityengine-vector3-is-not-marked-as-serializable.html

https://answers.unity.com/questions/1320236/what-is-binaryformatter-and-how-to-use-it-and-how.html