Serialized Save Games including MonoBehaviours using Full Serializer - Sharing a Solution

I’ve been working on a problem over the last several days to implement a non-obtrusive Save Game system.

I’ve seen many (many) other frustrated posters on the topic, and I’m satisfied enough with the (in process) results based on Jacob Dufault’s Full Serializer, that I thought I would share – particularly since Jacob was nice enough to offer the Full Serializer under the MIT License.

I’ll note up front the the solution shared here requires your project to follow a specific nomenclature for Unity.GameObjects and scripts to be serialized. However, it gives the correct hooks to use so you can adjust that requirement as needed.

Desired Solution and Context:

  • The serialization could occur using either BinaryFormatter or JSON to then be encrypted (the latter makes it easier to inspect and validate)
  • Need to be able to save an arbitrary SaveData object, where each of the member fields of that object are serialized and saved
  • The fields on the SaveData object should be the same instances used in the game, not intermediary data object.

I.e., we want to serialize, save, and load this:

public class SaveData
    {
        public Player player; //A MonoBehaviour
        public List<Region> regions; //A list of MonoBehaviours
    }

Not this:

public class SaveData
    {
        public PlayerData player; //A data class as intermediary reconstructed during each save
        public List<RegionData> regions; //A list of data classes as intermediary reconstructed during each save
    }

Limitations of Built-In Unity Serialization

While I came in eyes-wide-open that the built-in deserialization functions do not handle inheritance, and would require a callback receiver, there also appear to be some undocumented limitations in the serialziation of classes that have MonoBehaviour objects as fields. I’ve put a question on Unity Answers that details the expected vs actual behavior, so I won’t belabor it here. Bottom line it was worth exploring other options.

Limitations of Unity Save Load Utility

Many threads reference the Unity Save Load Utility. It’s a great free asset from Cherno, who makes it clear that it’s not one-size fits all. In the end, it’s architecture had a few limitations in the context of my desired solution above:

  • Serialization occurs on a GameObject, not a arbitrary object
  • Serialization (specifically restore) requires a prefab
  • All objects not in the serialized state must be marked as persisted or they will be destroyed when the save is reloaded
  • It does not (appear) to handle more complex types like Dictionaries. With the save and load using the BinaryFormatter it’s difficult to inspect in too much detail.

Limitation #2 and #3 are easily solved with minor tweaking to the code, but based on my limited research #1 and #4 appear to be fundamental to the design.

Leveraging the Full Serializer

The Full Serializer is an extremely robust serialization solution to-and-from JSON (which will ultimately require encryption in this context). It accurately documents that it has very few limitation.

It serialized anything I threw at it perfectly, including fields of MonoBehavior objects, and Dictionaries, overcoming the limitations of the other options above. However, as the author appropriately notes, deserializing MonoBehaviours is tricky. If it was possible generically, I suspect he would have included a generic converter, so it’s time to write a specific one.

The Solution

This solution relies on:

  • The name of the MonoBehaviour class must match either a GameObject in scene or a Prefab

  • Limitation: Only a single script on a GameObject can have Serialized Data

  • This limitation is okay for my structure, as by convention there is a single script to control state and primary behavior, and then possibly a few small behavior scripts that don’t need to be serialized (e.g., “Bring to Font on Enable”).

  • Creating and registering a Converter and a Processor with the Full Serializer (fs). If you have not read about them, please check out the documentation.

The Converter will override the generic fsReflectedConverted (which works very well for serialization and deserialization) to control the instance creation. The Processor will tag the serialized state with the name of the game object to allow saving and restoring prefabs that are inserted into scene at design time.
Step 1: The Converter

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using FullSerializer;
using FullSerializer.Internal;


namespace Data.Serialization
{
    public class MonoBehaviourConverter : fsReflectedConverter
    {
        public override bool CanProcess(Type type) {
            return typeof(MonoBehaviour).IsAssignableFrom(type);
        }
       
        // Create or Invoke an instance of a MonoBehaviour.
        // Requires:
        // storageType.name (a.k.a. current type being deserialized) exists as a componenet on
        // a game object or prefab with the same name
        // Non-prefabs: The MonoBehaviour component has the same name as the GameObject
        // Prefabs:  The MonoBehaviour component has the same name as a prefab &&
        //               If a existing instance in scene should be updated:
        //                The game object name specified during serialization exists in the scene

        public override object CreateInstance(fsData data, Type storageType){

            //First assume the storage type is the game object
            string gameObjectName = storageType.Name;

            // Check to see if the serialized state contains an explicitly specified
            // game object name that we should search for in the scene.  This is useful
            // for prefabs -- enables identifying existing objects rather than always creating new.
            if (data.IsDictionary && data.AsDictionary.ContainsKey(MonoBehaviourProcessor.Key_GameObjectName)){
                fsData gameObjectNameData = data.AsDictionary[MonoBehaviourProcessor.Key_GameObjectName];

                if(gameObjectNameData.IsString) {
                    gameObjectName = gameObjectNameData.AsString;
                }
            }

            // Search for the game object in the scene
            GameObject go = GameObject.Find(gameObjectName);

            // If the game object doesn't exist, it may have originally been
            // created from a prefab that doesn't exist in the scene, and will
            // need to be recreated.  Search again from the prefab list:
            if(go == null){
                GameObject prefab = null;
                // Note - This relies on a 'singleton' DataController that manages the save/load
                // And includes an array of prefabs that are attached in the inspector.  This could
                // be managed other ways as well
                GameObject[] gos = DataController.instance.serialiablePrefabs;
                for (int i = 0; i < gos.Length; i++) {
                    if (gos[i].name == storageType.Name){
                        prefab = gos[i];
                        break;
                    }
                }
                if(prefab != null) {
                    go = GameObject.Instantiate(prefab, Vector3.zero, Quaternion.identity);
                    go.name = gameObjectName;
                }
           
            }
           
            // Return the component from the game object
            object instance = null;
            if(go != null)
                instance = go.GetComponent(storageType);
       
            return instance;
        }
    }
}

Step 2: The Processor. Take note that it waits until OnBeforeDeserializeAfterInstanceCreation to remove the GameObjectName, which is part of the fsObjectProcessor API, but not listed in the GitHub examples.

using System;
using UnityEngine;
using FullSerializer;

namespace Data.Serialization
{
    public class MonoBehaviourProcessor : fsObjectProcessor
    {
        public static readonly string Key_GameObjectName = string.Format("{0}goname", fsGlobalConfig.InternalFieldPrefix);

        public override bool CanProcess (Type type) {
            return typeof(MonoBehaviour).IsAssignableFrom(type);
        }

        public override void OnBeforeDeserializeAfterInstanceCreation (Type storageType, object instance, ref fsData data) {
            // Remove Game Object name key, as it's already been applied to the game object directly during instance creation
            if (data.IsDictionary) {
                var dict = data.AsDictionary;
                dict.Remove(Key_GameObjectName);
            }
        }

        public override void OnAfterSerialize (Type storageType, object instance, ref fsData data) {
            // Add game object name key to identify game objects or name prefabs created into scene
            MonoBehaviour mb = (MonoBehaviour)instance;
            data.AsDictionary[Key_GameObjectName] = new fsData(mb.gameObject.name);
        }
    }
}

Step 3: Registering the Converter and Processor on all MonoBehaviour classes that need to be serialized:

//[... other namespaces]
using UnityEngine;
using FullSerializer;
using Data.Serialization;

[fsObject(Converter = typeof(MonoBehaviourConverter), Processor = typeof(MonoBehaviourProcessor))]
public class Player : MonoBehaviour {
//Lots of attributes to control state
}

Step 4: Define a “SaveData” class with references to the classes to be serialized (simplified example above), and execute serialization / deserialization on that class per the Full Serialization usage documentation.

Conclusion

This has handled everything I need for my game structure; however, I can see how it may need to be quickly extended for several other scenarios, and the converter has opportunity for optimization dependent on how you construct your game objects and prefabs. Also keep in mind this is all invoked via a “DataController”, that manages other activities I consider outside the scope of serialization (e.g., removing prefabs from the scene that aren’t in the saved state, etc.).

This is my first post on the forum, and an attempt to give back to the community. I hope some other folks find it useful.

5 Likes

Lessons Learned / Gotcha’s:

A quick follow-up on my first post regarding some subtleties that might be important to understand if others decide to implement a similar system. We’ll look at two scenarios that could be common pitfalls:

  • Restoring MonoBehaviour objects that have other MonoBehaviour objects as fields
  • Restoring objects when part of the serialized state is null

The resulting behavior for these two scenarios both occur based on how I’ve chosen to use the Converter CreateInstance method; however I’ve taken different paths towards resolution so I am writing them out separately here.

Restoring MonoBehaviour objects that have other MonoBehaviour objects as fields

First, this system only “guarantees” MonoBehaviours to be deserialized properly at the top level. More specifically, if a MonoBehaviour object is a field on another serialized MonoBehaviour object, you need to take special care to ensure you get predictable results. Also I use “guarantee” as loosely as possible, since I’ve only tested this system under the narrowest of use-cases relevant for my games.

To explain the behavior and solutions, let’s take the example from my original post, and assume the player class has at least the following attributes:

public class Player : MonoBehaviour {
  public float health;
  public Region currentRegion; //also a MonoBehaviour
  //etc...
}

As a reminder, the SaveData class looks like this:

public class SaveData  {
  public Player player;
  public List<Region> regions;
}

Consider a scenario where the player has moved regions since the last save. For this “example” (or reality in my situation), all of the regions still exist as game objects in the scene.

If we run deserialization on a serialized state, our intended outcome is that the player.currentRegion will update to the region from the serialized state. However, when our converter processed the player type, it does not actually create a new instance, it returns the existing instance in the scene.

As the serializer traverses the JSON nodes, and gets to the currentRegion node, it will skip the “CreateInstance” method in our converter that we’ve assigned to the Region class, because the instance already exists in its result set – the one that was attached to the player in the scene before we started the load. As a result, we don’t have an opportunity to GameObject.Find() the correct instance from the scene before we start deserializing the its saved state, and end up overwriting attributes on the wrong instance. Oops!

We have a couple tools in our toolbelt to address this issue, from simple to complex.

  • Does this attribute really need to be public and/or serialized?
  • Will the order of deserialization affect results?
  • Do you need to override TryDeserialize in MonoBehaviourConvertor (or a subclass), or the MonoBehaviourProcessor, for additional special handling?
  • Can it be resolved by either putting the game into a specific state before deserialization or performing some post deserialization cleanup in the .Load() method that calls the FullSerialzer?

Option #1 happened to be a solution in my scenario, which is why I list it here, but I wasn’t satisfied I could rely on it in all situations moving forward, so I continued exploring.

Digging into option #2, if you’ve taking FullSerializer for a spin and examined the JSON (fsJsonPrinter.PrettyJson(data) is helpful for creating readable JSON), you’ll realize it has an efficient mechanism for serializing and deserializing objects to ensure they are recreated with the correct references (e.g., if you have referenced to a “Wood” inventory item in 5 places, it will deserialize to 5 references, not to 5 unique instances). You may have also noticed that data is serialized in the order that the fields are defined.

To this end, if we simply change the order that the fields are serialized in our SaveData class to:

public class SaveData {
   public List<Region> regions;
   public Player player;  //No longer the most important GameObject
  }

the FullSerializer will first, update each of the regions with the saved state, and second, when it gets to the player.currentRegion, assign it to the correct region by reference. You’ll notice in the JSON, the first occurrence of an object defining the attributes will be assign an “$id” and subsequent references will simply list a “$ref”, which will be used to assign the correct reference during subsequent occurrences rather than re-executing the full deserialization. When the SaveData fields are defined in the original order, the first instance of the region is within the player object, so it tries to update the attributes of the incorrect instance, rather than just assigning the correct reference.

Options #3 and #4 are very situational specific, and could resolve probably any other issue, so I won’t explore them further here.

Restoring objects when part of the serialized state is null

Let’s consider a super simple inventory structure, building off of the previous class structure, to illustrate this scenario:

 public class Player : MonoBehaviour {
  public Inventory inventory;
  //etc...
}

public class Inventory {
  [fsProperty]
  private Dictionary<string, InventoryItem> inventoryItems;
}

public class InventoryItem{
  public Item item;
  public int quantity;
}

Nothing special (with the exception that we’re serializing a private field with the help of the [fsProperty] annotation). However, similar to the situation above, based on the approach of finding the in-scene instance, when the Player is deserialized, we’ll get the instance of the current inventory before we invoked load. When the deserializer traverses the JSON nodes:

  • Any item still in inventory will be updated correctly
  • Any item no longer in inventory will be added correctly
  • Any item in inventory that wasn’t at the time of save will remain incorrect

The standard converted doesn’t traverse the starting result set, only the serialized state, so there is nothing to indicate it has extra items. Free stuff! Oops.

There are two immediate options:

  • Create a custom converter to remove unwanted items during deserialization
  • Create a custom processor to clear the whole inventory before we start its deserialization to ensure a clean state.

I opted for #2, since it’s dead simple, and chose to create an interface to ensure it was a valid operation inside the processor.

public interface IClearable {
  void Clear();
}


[fsObject(Processor = typeof(ForceClearInstanceProcessor))]
public class Inventory : IClearable {
  [FullSerializer.fsProperty]
  private Dictionary<string, InventoryItem> inventoryItems;
    public void Clear() {
  inventoryItems.Clear();
  }
}

public class ForceClearInstanceProcessor : fsObjectProcessor {
  public override void OnBeforeDeserializeAfterInstanceCreation(Type storageType, object instance, ref fsData data) {
  if (typeof(IClearable).IsAssignableFrom(storageType)) {
  ((IClearable)instance).Clear();
  }
  }
}

Similar to my first post, putting this out here in case someone finds this approach useful so I can possibly save them the 8-16 hours I spent learning the internals of the FullSerializer (incredibly well architected, I have not praised it enough).

2 Likes

Could you please place an example of how to serialize a dictionary to a file and back?

Well… I’m not sure my solution is correct, but here is my variant:

    public class LevelObjectIdConverter : fsReflectedConverter
    public class LevelObjectIdConverter : fsReflectedConverter
    {
        private GameObject gameObject;
        public override object CreateInstance(fsData data, Type storageType)
        {
            gameObject = new GameObject($"{nameof(LevelObjectIdConverter)} Garbage");
            return gameObject.AddComponent(storageType);
        }

        public override fsResult TrySerialize(object instance, out fsData serialized, Type storageType)
        {
            return base.TrySerialize(instance, out serialized, storageType);
        }

        public override fsResult TryDeserialize(fsData data, ref object instance, Type storageType)
        {
            var result = base.TryDeserialize(data, ref instance, storageType);

            UnityEngine.Object.Destroy(gameObject);

            return result;
        }
    }

I know, this generates a lot of garbage. You can optimize this if you find a way to Reset the component values. Then you can hold a reference to your component and return a pure instance to your component in the CreateInstance function.

I still dont get how you save the data into a text asset for example