Table of Contents

Basic concepts

To understand how Safekeeper works, it's important to think about where the save data is stored.

The following diagram illustrates the complete process of first loading the data and then saving it back:

sequenceDiagram
    participant P as Persistent Storage
    participant M as Memory
    participant G as Game State
    P->>M: Load(SaveMode.PersistentOnly)
    Note over P,M: Fetching the data
    M->>G: Load(SaveMode.MemoryOnly)
    G->>M: Save(SaveMode.MemoryOnly)
    M->>P: Save(SaveMode.PersistentOnly)
    Note over P,M: Committing the data

Persistent storage

The persistent storage is the place where the actual save data is stored. It can be a file on the disk, a browser's local storage, a cloud, etc. Once the data is stored here, it will persist even after the game is closed. From the player's perspective, the game has been saved only if it has been committed to the persistent storage.

Memory

The memory is a staging area for our save data. When saving the game state we start by serializing it into the memory. The game can then continue as usual while the data is asynchronously committed to the persistent storage.

Game state

As the name suggests, the game state is the current state of our game as represented by game objects. Once the save data is loaded into the memory, we can deserialize it and apply the values to the objects in the scene.

Saving and loading

In the diagram above, loading means moving the save data from left to right. Analogically, saving means moving the date in the opposite direction. Note that we don't need to always go all the way. For example, sometimes it may be useful to save the data only in the memory.

Imagine the following scenario: The player is in Scene A and knocks over a vase. They then leave Scene A and move to Scene B. We'd like to store the information about the vase so that when the player returns to Scene A it's in the same state they left it in. At the same time, our game uses a checkpoint system, where the game is saved only in key moments of the story. We don't want to save it each time the player switches scenes.

In this case, saving the game only in the memory would be a good solution. When the player returns to Scene A, we can load the data from the memory and apply it to the vase. But only commit it to the persistent storage when the player reaches a checkpoint.

Additionally, we can allow the player to go back to the previous checkpoint by purging the in-memory data and loading it again from the persistent storage.

Save Controllers

In Safekeeper, each save slot is represented by an instance of the SaveControllerBase class. It serves as a handle to the underlying data and provides methods to save, load, and clear the slot.

When instantiating a new save controller, you need to provide it with an ISaveLoader. The loader is responsible for communicating with the persistent storage. Safekeeper comes with a few built-in loaders, but you can also implement your own:

  • FileSaveLoader - Loads and saves the data to a file on the disk using Unity's persistent storage path.
  • DummySaveLoader - Does nothing. Useful for testing.

After the save controller is instantiated, you need to initialize it by calling the Initialize method. This is an asynchronous operation, so it may take a while for the controller to be ready. You can await the returned task or check its status by reading the IsLoading property.

Note

The controller uses a semaphore so its safe to call other methods before the initialization is complete. They will be queued and executed once the controller is ready.

Save data

Once the save controller has been initialized and the persistent data has been fetched for the first time, you can access the in-memory data by reading the Data property.

It provides a few method for reading and writing the data, similarly to Unity's JsonUtility:

class StoredData {
    public int health;
    public int mana;
}

var location = new SaveLocation("global", "player");

// Reading the data as a new instance:
var data = controller.Data.Read<StoredData>(location);

// Reading the data into an existing instance:
controller.Data.Read(location, data);

// Writing the data:
controller.Data.Write(location, data);

Save locations

In order to read and write the data, we need to specify the SaveLocation. It's a simple structure that contains two strings: chunkId and objectId. For organizational purposes, objects store in the save data are grouped into chunks. This allows us to reuse Unity's GlobalObjectId to automatically generate unique locations for our objects.

If you want to automatically generate the location for a component, add ObjectLocationAttribute to a serialized SaveLocation field:

public class MyComponent : MonoBehaviour {
  [ObjectLocation]
  public SaveLocation Location;
}

For ScriptableObjects, you can use AssetLocationAttribute:

public class MyScriptableObject : ScriptableObject {
  [AssetLocation("my-assets")]
  public SaveLocation Location;
}