Home

Awesome

opt 03

Save Async

Save Async is BUCK's Unity package for asynchronously saving and loading data in the background using Unity's Awaitable class. It includes a simple API that makes it easy to capture and restore state without interrupting Unity's main render thread. That means smoother framerates and fewer load screens!

Features

Getting Started

[!NOTE] This package works with Unity 2023.1 and above as it requires Unity's Awaitable class which is not available in earlier versions.>

📺 Watch the Save Async Tutorial video or follow the instructions below.

Install the Save Async Package

  1. Copy the git URL of this repository: https://github.com/buck-co/save-async.git
  2. In Unity, open the Package Manager from the menu by going to Window > Package Manager
  3. Click the plus icon in the upper left and choose Add package from git URL...
  4. Paste the git URL into the text field and click the Add button.

Install the Unity Converters for Newtonsoft.Json Package (strongly recommended)

  1. Copy this git URL: https://github.com/applejag/Newtonsoft.Json-for-Unity.Converters.git#upm
  2. In Unity, open the Package Manager from the menu by going to Window > Package Manager
  3. Click the plus icon in the upper left and choose Add package from git URL...
  4. Paste the git URL into the text field and click the Add button.

Save Async depends on Unity's Json.NET package for serializing data, which is already included as a package dependency and will be installed automatically. However, Unity types like Vector3 don't serialize to JSON very nicely, and can include ugly recursive property loops, like this:

{
  "x": 0,
  "y": 1,
  "z": 0,
  "normalized": {
    "x": 0,
    "y": 1,
    "z": 0,
    "normalized": {
      "x": 0,
      "y": 1,
      "z": 0,
      "normalized": {
        "x": 0,
        "y": 1,
        "z": 0,
        "normalized": {
          ...
        }
      }
    }
  }
}

Yikes! Installing the Unity Converters for Newtonsoft.Json package takes care of these issues, as well as many more.

Once you've done this, Json.NET should be able to convert Unity's built-in types. In the future, we'll try to include this as a package dependency, but currently the Unity Package Manager only allows packages to have dependencies that come from the official Unity registry.

Basic Workflow

After installing the package...

  1. Add the SaveManager component to a GameObject in your scene.
  2. Implement the ISaveable interface on at least one class (more detail on how to do this is available below).
  3. Register the ISaveable by calling SaveManager.RegisterSaveable(mySaveableObject); This is usually done in MonoBehaviour.Awake()
  4. Call SaveManager API methods like SaveManager.Save() from elsewhere in your project, such as from a Game Manager class. Do this after all your ISaveable implementations are registered.

Included Samples

This package includes a sample project which you can install from the Unity Package Manager by selecting the package from the list and then selecting the Samples tab on the right. Then click Import. Examining the sample can help you understand how to use the package in your own project.

Implementing ISaveable and using the SaveManager API

Any class that should save or load data needs to implement the ISaveable interface.

Example Implementation: GameDataExample

  1. Implement ISaveable in Your Class <br>Your class should inherit from ISaveable from the Buck.SaveAsync namespace.

    using Buck.SaveAsync
    
    public class YourClass : MonoBehaviour, ISaveable
    {
        // Your code here...
    }
    
  2. Choose a Filename <br>This is the file name where this object's data will be saved.

    public string Filename => Files.GameData;
    

    It is recommended to use a static class to store file paths as strings to avoid typos.

    public static class Files
    {
        public const string GameData = "GameData.dat";
        public const string SomeFile = "SomeFile.dat";
    }
    
  3. Generate and Store a Unique Serializable Key <br>Ensure that your class has a globally unique string key, such as "GameDataExample".

    public string Key => "GameDataExample";
    

    Optionally, you can generate and use a serializable Guid to uniquely identify your objects. Use SaveManager.GetSerializableGuid() in MonoBehaviour.OnValidate() to get the Guid and then store it as a serialized byte array (since the System.Guid type itself cannot be serialized).

        [SerializeField, HideInInspector] byte[] m_guidBytes;
        public string Key => new Guid(m_guidBytes).ToString();
        void OnValidate() => SaveManager.GetSerializableGuid(ref m_guidBytes);
    
  4. Register Your Object with SaveManager <br>Register the object with SaveManager. Generally it's best to do this in your Awake method or during initialization. Make sure you do this before calling any save or load methods in the SaveManager class or your saveables won't be picked up!

    void Awake()
    {
        SaveManager.RegisterSaveable(this);
    }
    
  5. Define Your Data Structure <br>Create a struct or class that represents the data you want to save. This structure needs to be serializable.

    [Serializable]
    public struct MyCustomData
    {
        // Custom data fields
        public string playerName;
        public int playerHealth;
        public Vector3 position;
        public Dictionary<int, Item> inventory;
    }
    
  6. Implement CaptureState and RestoreState Methods <br>Implement the CaptureState method to capture and return the current state of your object. Then implement the RestoreState method to restore your object's state from the saved data. Both of these methods will be called by the SaveManager class when you call its save and load methods.

    public object CaptureState()
    {
        return new MyCustomData
        {
            playerName = m_playerName,
            playerHealth = m_playerHealth,
            position = m_position,
            inventory = m_inventory
        };
    }
    
    public void RestoreState(object state)
    {
        var s = (MyCustomData)state;
    
        m_playerName = s.playerName;
        m_playerHealth = s.playerHealth;
        m_position = s.position;
        m_inventory = s.inventory;
    }
    

For a complete example, check out this ISaveable implementation in the sample project.

SaveManager API

SaveManager methods can be called anywhere in your game's logic that you want to save or load, such as in a Game Manager class or a main menu screen. You should add the SaveManager component to a GameObject in your scene. Below you'll find the public interface for interacting with the SaveManager class, along with short code examples.

[!NOTE] The SaveManager class is in the Buck.SaveAsync namespace. Be sure to include this line at the top of any files that make calls to SaveManager methods or implement the ISaveable interface.

using Buck.SaveAsync
<br>

Properties

bool IsBusy

Indicates whether or not the SaveManager class is currently busy with a file operation. This can be useful if you want to wait for one operation to finish before doing another, although because file operations are queued, this generally is only necessary for benchmarking and testing purposes. <br>

Usage Example:

while (SaveManager.IsBusy)
await Awaitable.NextFrameAsync();
<br>

Methods

void RegisterSaveable(ISaveable saveable)

Registers an ISaveable with the SaveManager class for saving and loading.

Usage Example:

SaveManager.RegisterSaveable(mySaveableObject);
<br>

Awaitable Save(string[] filenames)

Asynchronously saves the files at the specified array of paths or filenames.

Usage Example:

await SaveManager.Save(new string[] {"MyFile.dat"});
<br>

Awaitable Load(string[] filenames)

Asynchronously loads the files at the specified array of paths or filenames.

Usage Example:

await SaveManager.Load(new string[] {"MyFile.dat"});
<br>

Awaitable Delete(string[] filenames)

Asynchronously deletes the files at the specified array of paths or filenames.

Usage Example:

await SaveManager.Delete(new string[] {"MyFile.dat"});
<br>

Awaitable Erase(string[] filenames)

Asynchronously erases the files at the specified paths or filenames, leaving them empty but still on disk.

Usage Example:

await SaveManager.Erase(new string[] {"MyFile.dat"});
<br>

byte[] GetSerializableGuid(ref byte[] guidBytes)

Sets the given Guid byte array to a new Guid byte array if it is null, empty, or an empty Guid. The guidBytes parameter is a byte array (passed by reference) that you would like to fill with a serializable guid.

Usage Example:

[SerializeField, HideInInspector] byte[] m_guidBytes;
public string Key => new Guid(m_guidBytes).ToString();
void OnValidate() => SaveManager.GetSerializableGuid(ref m_guidBytes);
<br>

Encryption

If you want to prevent mischievous gamers from tampering with your save files, you can encrypt them using XOR encryption. To turn it on, use the encryption dropdown menu on the SaveManager component in your scene and create a password. XOR is very basic and can be hacked using brute force methods, but it is very fast. AES encryption is planned!

Additional Project Information

Why did we build this?

Figuring out how to save and load your game data can be tricky, but what's even more challenging is deciding when to save your game data. Not only is there the issue of data serialization and file I/O, but in addition, save and load operations often end up happening synchronously on Unity's main thread which will cause framerate dips. That's because Unity's renderer is also on the main thread! Furthermore, while most desktops have fast SSDs, sometimes file I/O can take longer than the time it takes to render a frame, especially if you're running your game on a gaming console or a computer with an HDD.

We hit these pain points on our game Let's! Revolution! and we wanted to come up with a better approach. By combining async and await with Unity's Awaitable class (available in Unity 2023.1 and up), it is now possible to do file operations both asynchronously and on background threads. That means you can save and load data in the background while your game continues to render frames seamlessly. Nice! However, there's still a good bit to learn about how multithreading works in the context of Unity and how to combine that with a JSON serializer and other features like encryption. The Save Async package aims to take care of these complications and make asynchronous saving and loading data in Unity a breeze!

Why not just use Coroutines?

While Coroutines have served us well for many years, the Task-based asynchronous pattern (TAP) enabled by async/await and Unity's Awaitable class has many advantages. Coroutines can execute piece by piece over time, but they still process on Unity's single main thread. If a Coroutine attempts a long-running operation (like accessing a file) it can cause the whole application to freeze for several frames. For a good overview of the differences between async/await and Coroutines, check out this Unite talk Best practices: Async vs. coroutines - Unite Copenhagen.

Contributing

If you have any trouble using the package, feel free to open an issue. And if you're interested in contributing, create a pull request and we'll take a look!

Authors

See also the list of contributors who participated in this project.

Acknowledgments

License

MIT License - Copyright (c) 2024 BUCK Design LLC buck-co


BUCK is a global creative company that brings brands, stories, and experiences to life through art, design, and technology. If you're a Game Developer or Creative Technologist or want to get involved with our work, reach out and say hi via Github, Instagram or our Careers page. 👋