Using BinaryFormatter in Unity to Save and Load Game Data

The class BinaryFormatter in C# performs the actions of “serialization” and “deserialization” of binary data. It takes simple data structures such as integers (int), decimal numbers (float), and collections of letters and numbers (string) and can convert them into a binary format. This means more complex structures like a class can have its fields encoded into a binary format for saving to a file and later reading by a program. In Unity, through using this C# class, it is possible to save data, such as might be done with PlayerPrefs, but on a more complex level.

Using BinaryFormatter is not a drop-in solution for creating save files for a game. The class can encode some C# data structures into a serialized format and read them back again. This means other users, programs, and systems can “see” the data in this encoded form. BinaryFormatter does not encrypt or hide data in any way, merely converts from one form to another for easier reading and writing to a file. Always verify all data accepted from its usage.

Working with FileStreams

When working with files, there is no way to know if a file is on the local hard drive a few inches away from the user, a hard drive in another room, or as part of cloud storage thousands of miles away. To help with this, C# provides a class called FileStream. It reads or writes to a file and lets the operating system handle the low-level management of the process. For the developer, it provides a way to access files without knowing the physical location and using a “stream,” be it local or remote connection, to work with the file.

Note: FileStream is part of the System.IO namespace, a collection of classes and methods connected to working with input (“I”) and output (“O”). When creating a new Scripting Component in Unity, the namespace will need to be added.

Where are files in Unity?

In order to create a new FileStream, the path to a file is needed. Yet, when using Unity, a developer may not know the structure of a player’s hard drive or storage system. To help with this problem, Unity provides the static field of the class Application called persistentDataPath. The value Application.persistentDataPath will always contain a “persistent” path for working with files in Unity, either while using the editor or when a project is run using its Player.

As the Application.persistentDataPath value points to a path, this means one additional detail is needed, the file. The “data path” is a directory, not a file.

File Example

 Application.persistentDataPath + "/gamedata.data"

Note: The filetype of “data” was chosen for this example because it is not a known format used by other programs.

Does this game data file exist?

The first step when working files, given the ability of users to change or remove then between sessions, is to check if it exists. The class File provides the method Exists() for checking if a specific files exists. When working with Application.persistentDataPath, this is a good first step to identifying if a file exists before attempting to read it (which would fail) or if it needs to be created for the first time.

// Save the full path to the file.
string saveFile = Application.persistentDataPath + "/gamedata.data";

// Does it exist?
if(File.Exists(saveFile))
{
  // File exists!
}
else
{
  // File does not exist.
  //
  // This could mean it was deleted or has not been created yet.
}

Creating Game Data

If a file does not exist, this could mean it was deleted or was not created. In either case, this means it needs to be created. When creating a new FileStream, its constructor needs to know two things: the location of the file and in what “mode” it should be working. Through using Application.persistentDataPath, the location of the file is known. The mode of the FileStream determines how it should work. Is it creating a file? Reading a file? Appending to a file?

When creating a file, the mode is Create.

// File does not exist.
//
// This could mean it was deleted or have been created yet.

// Create a FileStream connected to the saveFile path and set the file mode to "Create".
FileStream dataStream = new FileStream(saveFile, FileMode.Create);

Reading Game Data

Much like the process of using the FileMode of “Create” to create a file, the mode of “Open” opens a file for the mode of reading its data.

// Save the full path to the file
string saveFile = Application.persistentDataPath + "/gamedata.data";

// Does it exist?
if(File.Exists(saveFile))
{
  // File exists!
  //
  // Create a FileStream connected to the saveFile and set to the file mode of "Open"
  FileStream inputStream = new FileStream(saveFile, FileMode.Open);
}
else
{
  // File does not exist.
  //
  // This could mean it was deleted or have been created yet.

  // Create a FileStream connected to the saveFile path and set the file mode to "Create".
  FileStream outputStream = new FileStream(saveFile, FileMode.Create);
}

Using a GameDataManager Class

Programming should make a developer’s tasks easier. In the cases where, like the previous code, the same type of data is used across multiple tasks, this might be a good opportunity to better organize its usage. Because the class FileStream is used in two places to perform very similar tasks, it should be created as its own field of a class. This would allow it to be accessed as neeeded.

When working with classes, it is often very helpful to break up tasks into methods of the class. In the above cases, there is the tasks of “writing to a file” and “reading from a file” within the same block code. Breaking up these tasks would help in better using and testing them with new methods matching the task themselves.

Using a GameDataManager class, the “game data” can be managed. (The GameData class is created in the next section.)

public class GameDataManager
{
    // Create a field of this class for the file to write
    string saveFile = Application.persistentDataPath + "/gamedata.data";

    // Create a single FileStream to be overwritten as needed in the class.
    FileStream dataStream;

    void readFile()
    {
        // Does the file exist?
        if (File.Exists(saveFile))
        {
            // Use the file mode "Open".
            dataStream = new FileStream(saveFile, FileMode.Open);
        }
    }

    void writeFile()
    {
        // use the file mode "Create".
        dataStream = new FileStream(saveFile, FileMode.Create);
    }
}

Using GameData Class

It is often useful to create a class whose purpose is to hold a collection of values used by other classes or processes. A new class, GameData, will serve this purpose.

Note: A new C# script file can be created in the Project View through Create -> C# Script. Right-click on the file to rename it something other than its default name.

The new GameData class does not need to be complex, as it will only hold values.

public class GameData
{
    // Public lives
    public int lives;

    // Public highScore
    public int highScore;
}

System.Serializable

A class is a complex data structure. In order for it to be “serialized,” additional information is need to let Unity and C# know its fields should be serialized.

[System.Serializable]
public class GameData
{
    // Public lives
    public int lives;

    // Public highScore
    public int highScore;
}

Combining GameDataManager and GameData

The class GameData contains values and the class GameDataManager “manages” these values, working with FileStream to read and write to a file.

// Add System.IO to work with files!
using System.IO;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameDataManager : MonoBehaviour
{
    // Create a GameData field.
    GameData gameData;
    
    // Create a field of this class for the file to write
    string saveFile = Application.persistentDataPath + "/gamedata.data";

    // Create a single FileStream to be overwritten as needed in the class.
    FileStream dataStream;

    void readFile()
    {
        // Does the file exist?
        if (File.Exists(saveFile))
        {
            // Create a FileStream connected to the saveFile.
            // Set to the file mode to "Open"
            dataStream = new FileStream(saveFile, FileMode.Open);
        }
    }

    void writeFile()
    {
        // Create a FileStream connected to the saveFile path.
        // Set the file mode to "Create".
        dataStream = new FileStream(saveFile, FileMode.Create);
    }
}

Adding BinaryFormatter

The class BinaryFormatter handles the work of serializing and deserializing data into and out of a binary format. So far, FileStream has been used to “Create” or “Open”, but no data has been used.

Like FileStream, it would be used for both reading and writing actions. However, it is part of a different namespace: System.Runtime.Serialization.Formatters.Binary.

It provides two methods, Serialize() and Deserialize(). These work on InputStream objects (of which FileStream inherits from) and converts into and out of binary data.

// Add System.IO to work with files!
using System.IO;
// Add System.Runtime.Serialization.Formatters.Binary to work with BinaryFormatter!
using System.Runtime.Serialization.Formatters.Binary;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameDataManager : MonoBehaviour
{
    // Create a GameData field.
    GameData gameData;
    
    // Create a field of this class for the file to write
    string saveFile = Application.persistentDataPath + "/gamedata.data";

    // Create a single FileStream to be overwritten as needed in the class.
    FileStream dataStream;

    // Create a single BinaryFormatter to be used across methods.
    BinaryFormatter converter = new BinaryFormatter();

    void readFile()
    {
        // Does the file exist?
        if (File.Exists(saveFile))
        {
            // Create a FileStream connected to the saveFile.
            // Set to the file mode to "Open".
            dataStream = new FileStream(saveFile, FileMode.Open);

            // Serialize GameData into binary data 
            //  and add it to an existing input stream.
            converter.Serialize(dataStream, gameData);

            // Close the stream
            dataStream.Close();
        }
    }

    void writeFile()
    {
        // Create a FileStream connected to the saveFile path.
        // Set the file mode to "Create".
        dataStream = new FileStream(saveFile, FileMode.Create);

        // Deserialize binary data 
        //  and convert it into GameData, saving it as part of gameData.
        gameData = converter.Deserialize(dataStream) as GameData;

        // Close stream.
        dataStream.Close();
    }
}

Converting into Unity Concepts

Using only C#, the value Application.persistentDataPath would seem to be used as part of its fields. However, Unity only creates this value after it starts other systems. In other words, it must be used as part of another method like Awake().

// Add System.IO to work with files!
using System.IO;
// Add System.Runtime.Serialization.Formatters.Binary to work with BinaryFormatter!
using System.Runtime.Serialization.Formatters.Binary;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameDataManager : MonoBehaviour
{
    // Create a GameData field.
    GameData gameData;

    // Create a field of this class for the file to write
    string saveFile;

    // Create a single FileStream to be overwritten as needed in the class.
    FileStream dataStream;

    // Create a single BinaryFormatter to be used across methods.
    BinaryFormatter converter = new BinaryFormatter();

    void Awake()
    {
        // Update the path once the persistent path exists.
        saveFile = Application.persistentDataPath + "/gamedata.data";
    }

    public void readFile()
    {
        // Does the file exist?
        if (File.Exists(saveFile))
        {
            // Create a FileStream connected to the saveFile.
            // Set to the file mode to "Open".
            dataStream = new FileStream(saveFile, FileMode.Open);

            // Deserialize binary data 
            //  and convert it into GameData, saving it as part of gameData.
            gameData = converter.Deserialize(dataStream) as GameData;

            // Close the stream.
            dataStream.Close();
        }
    }

    public void writeFile()
    {
        // Create a FileStream connected to the saveFile path.
        // Set the file mode to "Create".
        dataStream = new FileStream(saveFile, FileMode.Create);

        // Serialize GameData into binary data 
        //  and add it to an existing input stream.
        converter.Serialize(dataStream, gameData);

        // Close stream.
        dataStream.Close();
    }
}

Public Fields and Methods

In order for other classes to be able to access the internal GameData of GameDataManager, it needs to be public. The same for the methods readFile() and writeFile(). With the use of this keyword, these can be used by other classes, calling or changing the internal data of GameDataManager for the purposes of reading or writing the save file used within the class.

BinaryFormatter as a Start, Not a Finish Line

The class BinaryFormatter serializes and deserializes binary data. Because users can edit this data at any time when working with files, it should not be fully trusted. It can be a starting point to create data formats using other tools, but anything read from files should be verified for acceptable ranges of values.