Save Game System in Unity using Interfaces and Custom Serialization


Creating a robust save game system in Unity is a fundamental part of many games. This tutorial will guide you through the process of implementing an advanced, flexible save system using interfaces, which lets each object decide how it’s serialized.

Defining the Serialization Interface

The first step is to create an interface that each object can implement to control its serialization. This interface, ISerializableObject, includes methods for serialization and deserialization.

public interface ISerializableObject
{
    SerializableObject Serialize();
    void Deserialize(SerializableObject serializedObj);
}
Creating a Data Structure for Save Data

Next, we need a structure to store the serialized data. We create a SerializableObject class to hold the data for each object we want to save, and a SaveData class to contain a list of these objects.

[System.Serializable]
public class SerializableObject
{
    public string prefabPath;  // Relative path from the Resources folder
    public bool isActive;
    public Dictionary<string, object> data = new Dictionary<string, object>();
}

[System.Serializable]
public class SaveData
{
    public List<SerializableObject> objects = new List<SerializableObject>();
}
Designing the Save Manager

The Save Manager is responsible for saving and loading game data. It uses the Singleton pattern to ensure only one instance exists during runtime.

public class SaveManager : MonoBehaviour
{
    public static SaveManager Instance { get; private set; }

    private string savePath;
    private List<ISerializableObject> gameObjects = new List<ISerializableObject>();

    private void Awake()
    {
        if (Instance != null)
        {
            Destroy(gameObject);
            return;
        }

        Instance = this;
        DontDestroyOnLoad(gameObject);

        savePath = Path.Combine(Application.persistentDataPath, "saveFile.dat");
    }

    public void RegisterObject(ISerializableObject obj)
    {
        gameObjects.Add(obj);
    }

    public void DeregisterObject(ISerializableObject obj)
    {
        gameObjects.Remove(obj);
    }
}
Implementing ISerializableObject

With the interface defined, we can implement it in our game scripts. Here’s an example for a Player class:

public class Player : MonoBehaviour, ISerializableObject
{
    private void Awake()
    {
        SaveManager.Instance.RegisterObject(this);
    }

    private void OnDestroy()
    {
        SaveManager.Instance.DeregisterObject(this);
    }

    public SerializableObject Serialize()
    {
        SerializableObject serializedObj = new SerializableObject
        {
            prefabPath = "Prefabs/Player", // Adjust this path for your specific project setup
            isActive = gameObject.activeSelf
        };

        serializedObj.data.Add("position", transform.position);
        serializedObj.data.Add("rotation", transform.rotation);
        // Add other data as needed

        return serializedObj;
    }

    public void Deserialize(SerializableObject serializedObj)
    {
        transform.position = (Vector3)serializedObj.data["position"];
        transform.rotation = (Quaternion)serializedObj.data["rotation"];
        gameObject.SetActive(serializedObj.isActive);
    }
}

This system allows each class to determine what data it saves, providing us with a flexible save system.

Saving and Loading Game Data

Back in our SaveManager, we can now add the SaveGame and LoadGame methods.

public class SaveManager : MonoBehaviour
{
    // ...

    public void SaveGame()
    {
        BinaryFormatter formatter = new Binary

Formatter();
        FileStream fileStream = new FileStream(savePath, FileMode.Create);

        SaveData data = new SaveData
        {
            objects = gameObjects.Select(obj => obj.Serialize()).ToList()
        };

        formatter.Serialize(fileStream, data);
        fileStream.Close();
    }

    public void LoadGame()
    {
        if (File.Exists(savePath))
        {
            BinaryFormatter formatter = new BinaryFormatter();
            FileStream fileStream = new FileStream(savePath, FileMode.Open);

            SaveData data = formatter.Deserialize(fileStream) as SaveData;
            fileStream.Close();

            foreach (SerializableObject objData in data.objects)
            {
                GameObject prefab = Resources.Load<GameObject>(objData.prefabPath);
                if (prefab != null)
                {
                    GameObject obj = Instantiate(prefab);
                    ISerializableObject serializableObj = obj.GetComponent<ISerializableObject>();
                    if (serializableObj != null)
                    {
                        serializableObj.Deserialize(objData);
                    }
                }
                else
                {
                    Debug.LogError("Prefab not found at " + objData.prefabPath);
                }
            }
        }
        else
        {
            Debug.LogError("Save file not found in " + savePath);
        }
    }
}

In the SaveGame method, we serialize each registered object and save it to a file. In LoadGame, we load the serialized data from the file and instantiate or update the appropriate objects.

And there you have it! With this system, you can easily save and load any object in your game. The interface-based approach allows you to extend the save system to cover new scripts and systems without altering the save system itself, making your game development process more efficient and your code cleaner. You can also extend the system to deal with more situations and patterns. For example, you might want to serialize objects and components separately, and handle multiple serializable components on one GameObject. But those are tasks for another day. Happy coding!

,