Save Data with BinaryWriter and BinaryReader

Binary Data

This article will explore saving and loading data to a binary format through BinaryWriter and BinaryReader. Additionally, I’ll provide tips for structuring your save data. Let’s get into it!

What’s the plan?

We’ll build a save data manager that’ll take a SaveData object and either write it to a file or read from an existing file. We’ll support Binary through BinaryWriter and BinaryReader and JSON through Unity’s built-in JsonUtility. The fundamental architecture doesn’t change from one format to the next, so you could always extend the manager to support other backends like MessagePack or ProtoBuf.

Save Data

As I mentioned, the save data manager will read and write a SaveData object. The SaveData object is a class that contains everything we need to recreate the current game state, such as the player’s health and position and the active scene. A more complicated example could be the status and id of every single entity in the world. In one turn-based prototype I wrote, I store every entity and every tile in the world for every turn that has passed so that players can rewind to any previous turn even after quitting and loading the game.

I did my best to recreate a simple example that demonstrates as many principles as possible. Ideally, it’s easy enough to understand, and all you have to do is extend it to support the data your project requires. You can find a link to the Github repository for the example project at the end of this post.

So in this example, we have a game with a bunch of entities. Each entity has an id that links to a prefab through a ScriptableObject. When we save, we grab all the entities in the world and store their id with their position. Here’s how the SaveData looks.

using UnityEngine;

public class SaveData : ScriptableObject
{
    public string[] EntityUuids;
    public Vector3[] EntityPositions;

    public void SetEntities(Entity[] entities)
    {
        EntityUuids = new string[entities.Length];
        EntityPositions = new Vector3[entities.Length];
        for (int i = 0; i < entities.Length; ++i)
        {
            EntityUuids[i] = entities[i].EntityDescriptor.Uuid;
            EntityPositions[i] = entities[i].transform.position;
        }
    }
}

As mentioned, it holds an array of ids and one of the positions. Also, it contains a helper function that takes an array of entities and isolates the data we need to save. Let’s explore how to save this with BinaryWriter.

BinaryReader and BinaryWriter

BinaryReader and BinaryWriter are tools built into dotNET that read and write binary data. It’s faster and more efficient on storage than Unity’s built-in JsonUtility, which stores data in the JSON format. The limitation of BinaryReader is that you must read the information in the same order you wrote it. What I mean is, JSON allows you to search for fields based on their name, but as far as I know, BinaryReader assumes you know the order in which you wrote the elements. By the way, if you’re familiar with BinaryFormatter but haven’t heard the news, you should never use it. It is “insecure and can’t be made secure.” Another limitation of binary data is it isn’t human-readable. The JSON format opens like a text file and is relatively readable. This readability is helpful when debugging save files. On the other hand, you need to use a hex editor to open binary files, and even then, they mostly look like gibberish. Anyway, Let’s see how we might write our SaveData using BinaryWriter.

public static void SaveBinary(string fileName, SaveData saveData)
{
    var fileFullPath = $"{_saveFolderPath}/{fileName}";
    using (BinaryWriter writer = new BinaryWriter(File.Open(fileFullPath, FileMode.OpenOrCreate)))
    {
        writer.Write(saveData.EntityUuids.Length);
        for (int i = 0; i < saveData.EntityUuids.Length; ++i)
        {
            writer.Write(saveData.EntityUuids[i]);
            var position = saveData.EntityPositions[i];
            writer.Write(position.x);
            writer.Write(position.y);
            writer.Write(position.z);
        }
    }

    Debug.Log($"Saved Binary to {fileFullPath}");
}

In this example, we open (or create) a new file with the desired name. Then, we write the length of the EntityUuids array, which is how many entities exist in the world. Knowing the number of entities is necessary because there’s no other way to know when the array of entities ends while we read back the information. Afterwards, we write the id of the entity and its X, Y and Z coordinates.

Now let’s see how to read the information.

public static void LoadBinary(string fileName, SaveData saveData)
{
    var fileFullPath = $"{_saveFolderPath}/{fileName}";
    using (BinaryReader reader = new BinaryReader(File.Open(fileFullPath, FileMode.Open)))
    {
        var entityCount = reader.ReadInt32();
        saveData.EntityUuids = new string[entityCount];
        saveData.EntityPositions = new Vector3[entityCount];
        for (int i = 0; i < entityCount; ++i)
        {
            saveData.EntityUuids[i] = reader.ReadString();
            saveData.EntityPositions[i] = new Vector3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle());
        }
    }

    Debug.Log($"Loaded Binary from {fileFullPath}");
}

It’s very similar to the saving function. First, we read the number of entities and create new arrays that are big enough to fit them. Then, one by one, we read the id and X, Y and Z coordinates while storing them in the SaveData. By the way, a critical distinction between reading and writing is that when reading, you have to specify the type of the next value. That’s why you have to know the data format beforehand and why we have to know precisely how many entities are in the SaveData file. However, given this limitation, what do we do if we need to change the save format? Assuming that you stick with BinaryReader, I recommend versioning your save file.

Save Data Version

I store the version at the beginning of my save files. By that, I mean simply writing a number before any other data. Once you do this, you have to hang on to the method used to load data for every version. That way, you can read older save files and convert them to the most up-to-date format. Ideally, you’d do this from the start; otherwise, it’s tough to detect the version later, and you risk losing user data. So update SaveData to hold the version.

public class SaveData : ScriptableObject
{
    public int Version;
    public string[] EntityUuids;
    public Vector3[] EntityPositions;
}

Then we can modify the SaveManager to read and write the version.

public class SaveManager : MonoBehaviour
{
    static string _saveFolderPath;

    public const int CurrentSaveVersion = 1;

    void OnEnable()
    {
        _saveFolderPath = $"{Application.persistentDataPath}/Saves";

        if (!Directory.Exists(_saveFolderPath))
            Directory.CreateDirectory(_saveFolderPath);
    }

    public static void SaveBinary(string fileName, SaveData saveData)
    {
        var fileFullPath = $"{_saveFolderPath}/{fileName}";
        using (BinaryWriter writer = new BinaryWriter(File.Open(fileFullPath, FileMode.OpenOrCreate)))
        {
            writer.Write(saveData.Version);
            writer.Write(saveData.EntityUuids.Length);
            for (int i = 0; i < saveData.EntityUuids.Length; ++i)
            {
                writer.Write(saveData.EntityUuids[i]);
                var position = saveData.EntityPositions[i];
                writer.Write(position.x);
                writer.Write(position.y);
                writer.Write(position.z);
            }
        }

        Debug.Log($"Saved Binary to {fileFullPath}");
    }

    public static void LoadBinary(string fileName, SaveData saveData)
    {
        var fileFullPath = $"{_saveFolderPath}/{fileName}";
        using (BinaryReader reader = new BinaryReader(File.Open(fileFullPath, FileMode.Open)))
        {
            saveData.Version = reader.ReadInt32();
            var entityCount = reader.ReadInt32();
            saveData.EntityUuids = new string[entityCount];
            saveData.EntityPositions = new Vector3[entityCount];
            for (int i = 0; i < entityCount; ++i)
            {
                saveData.EntityUuids[i] = reader.ReadString();
                saveData.EntityPositions[i] = new Vector3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle());
            }
        }

        Debug.Log($"Loaded Binary from {fileFullPath}");
    }
}

You can read the version number from the save file and branch to the corresponding load method as you add new versions.

Using BinaryReader allows precise control over save file loads, which is both a blessing and a curse. Unity’s built-in JsonUtility tries to associate fields in the JSON file to their counterpart in the C# object. If it fails, that data goes unassigned. This is the equivalent of renaming a serialized field in a MonoBehaviour and losing all the assigned data. However, here’s what the save and load methods look like using JsonUtility for comparison.

public static void SaveJson(string fileName, SaveData saveData)
{
    var fileFullPath = $"{_saveFolderPath}/{fileName}";
    var jsonString = JsonUtility.ToJson(saveData);
    File.WriteAllText(fileFullPath, jsonString);

    Debug.Log($"Saved JSON to {fileFullPath}");
}

public static void LoadJson(string fileName, SaveData saveData)
{
    var fileFullPath = $"{_saveFolderPath}/{fileName}";
    var jsonString = File.ReadAllText(fileFullPath);
    JsonUtility.FromJsonOverwrite(jsonString, saveData);

    Debug.Log($"Loaded JSON from {fileFullPath}");
}

They’re much less verbose but don’t allow you to handle schema changes cleanly. In other words, if you rename fields in SaveData, you’ll lose data if you load an older save file.

Wrap-up

So in this post, we saw how to use BinaryWriter and BinaryReader to save and load data. However, we didn’t see what to do with that data afterwards. That topic is interesting because it explores how to architect your data to be serialized. This includes mapping an id to a prefab to be saved and loaded. That may become a future post, but until then, you can see a simple example in the Github project linked at the end.

Explore the Github project is available here. If you like my work, join my mailing list to be notified when I write a new post.

Leave A Comment