Skip to content

State Management ​

All persistent data generated and used by Naninovel at runtime is divided into three categories:

  • Game state
  • Global state
  • User settings

The data is serialized to JSON format and stored as either binary .nson (default) or text .json save slot files under a platform-specific persistent data directory ↗. On WebGL, due to security policies in modern browsers, the serialized data is stored in IndexedDB ↗ instead.

The serialization behaviour is controlled by serialization handlers independently for game saves, global state and user settings. By default, universal serialization handlers are used. In most cases, they will use asynchronous System.IO ↗ to read and write the slot files to the local file system. However, on some platforms (eg, consoles) the .NET IO APIs are not available, in which case the universal handlers fall back to Unity's cross-platform PlayerPrefs ↗.

Serialization handlers, path to the save folder, maximum allowed number of save slots and other related parameters can be modified via the state configuration menu.

cover

Game State ​

Game state is data that varies per game save slot, describing the state of engine services and other objects in relation to player progress. Examples include: the currently played scenario script and the index of the played script command within the script, currently visible characters and their positions in the scene, currently played background music track name and its volume, and so on.

To save the current game state to a specific save slot or load it from one, use the IStateManager engine service as follows:

cs
// Get instance of a state manager.
var stateManager = Engine.GetService<IStateManager>();

// Save current game session to `mySaveSlot` slot.
await stateManager.SaveGame("mySaveSlot");
// Load game session from `mySaveSlot` slot.
await stateManager.LoadGame("mySaveSlot");

// You can also use quick save-load methods without specifying slot names.
await stateManager.QuickSave();
await stateManager.QuickLoad();

Note that the save-load API is asynchronous ↗. If you're invoking the API from synchronous methods, use IStateManager.OnGameSaveFinished and IStateManager.OnGameLoadFinished to subscribe to completion events.

Global State ​

Some data should be persistent across game sessions. For example, the "Skip Read Text" feature requires the engine to store which scenario script commands were executed at least once (meaning the player has already "seen" them). Such data is stored in a single "global" save slot and doesn't depend on game save-load operations.

The global state is loaded automatically on engine initialization. You can save the global state at any time using IStateManager:

cs
await stateManager.SaveGlobal();

User Settings ​

User settings, such as language, sound volume and text speed, are stored in a single save slot, similar to global state. The settings file is always saved as text .json, even when Binary Save Files is enabled, so that players can modify the values if they wish.

User settings are loaded automatically on engine initialization. You can save settings at any time using IStateManager:

cs
await stateManager.SaveSettings();

Save Files ​

When the universal serialization handlers are used on a platform with file system access, all the state is written to the "Saves" folder under Unity's persistent data directory ↗. For example, if the company's name is Foo and the game title is Bar, the path will be:

text
C:/Users/User/AppData/LocalLow/Foo/Bar/Saves
text
~/Library/Application Support/unity.Foo.Bar/Saves
text
~/.config/unity3d/Foo/Bar/Saves
text
/var/mobile/Containers/Data/Application/<guid>/Documents/Saves
text
/storage/emulated/0/Android/data/<package>/files/Saves

The folder contains the following files, where ### is the slot number:

FileDefault LimitContent
GameSave###.nson99Game state of the save slots.
GameQuickSave###.nson18Game state of the quick save slots.
GameAutoSave###.nson18Game state of the autosave slots.
GlobalSave.nson1Global state.
Settings.json1User settings.

With the default slot limits, the folder holds at most 137 files. The size of a game state file mostly depends on Thumbnail Resolution in the camera configuration and Saved Rollback Steps in the state configuration.

When Binary Save Files is disabled, the game and global state files have the .json extension instead of .nson. The folder name, file names and slot limits can be changed in the state configuration.

In the Unity Editor, the files are stored under .nani/Transient/Saves of the Naninovel data folder (Assets/NaninovelData by default) instead.

TIP

Cloud save services, such as Steam Auto-Cloud ↗, should only sync the "Saves" folder; other files in the persistent data directory are local to the device. Consider limiting the sync to the .nson files, as Settings.json includes device-specific options, such as graphics quality and input bindings. Steamworks documentation has an example ↗ of configuring the paths for a Unity game.

Custom State ​

You can delegate state handling of your custom objects to IStateManager, so they serialize to save slots with all the engine's data when the player saves and deserialize back when the game is loaded. Built-in state-related features (eg, rollback) will also work out of the box with custom state.

The following example demonstrates delegating state handling of a MyCustomBehaviour component.

cs
using UnityEngine;
using Naninovel;

public class MyCustomBehaviour : MonoBehaviour
{
    [System.Serializable]
    private class GameState
    {
        public bool MyCustomBool;
        public string MyCustomString;
    }

    private bool myCustomBool;
    private string myCustomString;
    private IStateManager stateManager;

    private void Awake ()
    {
        stateManager = Engine.GetService<IStateManager>();
    }

    private void OnEnable ()
    {
        stateManager.AddOnGameSerializeTask(SerializeState);
        stateManager.AddOnGameDeserializeTask(DeserializeState);
    }

    private void OnDisable ()
    {
        stateManager.RemoveOnGameSerializeTask(SerializeState);
        stateManager.RemoveOnGameDeserializeTask(DeserializeState);
    }

    private void SerializeState (GameStateMap stateMap)
    {
        var state = new GameState {
            MyCustomBool = myCustomBool,
            MyCustomString = myCustomString
        };
        stateMap.SetState(state);
    }

    private Awaitable DeserializeState (GameStateMap stateMap)
    {
        var state = stateMap.GetState<GameState>();
        if (state is null) return Async.Completed;

        myCustomBool = state.MyCustomBool;
        myCustomString = state.MyCustomString;
        return Async.Completed;
    }
}

If your custom object is created after the game state is loaded, use IStateManager.Game to access the last loaded state and manually invoke the deserialize method:

cs
private async void Start ()
{
    if (stateManager.Game is { } state)
        await DeserializeState(state);
}

EXAMPLE

A more advanced example of using custom state with a list of custom structs to save-load game state of an inventory UI can be found in the inventory sample. Specifically, de-/serialization of the custom state is implemented in Scripts/Runtime/Inventory/UI/InventoryUI.cs.

You can also access global and settings state of the engine to store custom data with them. Unlike game state, which is specific to game sessions and requires subscribing to save-load events, global and settings state objects are singletons and can be directly accessed via properties of the state manager.

cs
[System.Serializable]
class MySettings
{
    public bool MySettingsBool;
}

[System.Serializable]
class MyGlobal
{
    public string MyGlobalString;
}

MySettings MySettings
{
    get => stateManager.Settings.GetState<MySettings>();
    set => stateManager.Settings.SetState<MySettings>(value);
}

MyGlobal MyGlobal
{
    get => stateManager.Global.GetState<MyGlobal>();
    set => stateManager.Global.SetState<MyGlobal>(value);
}

State objects are indexed by type. In some cases you may have multiple object instances of the same type, each with their own state. Both GetState and SetState methods allow providing an optional instanceId argument to discriminate such objects, eg:

cs
[System.Serializable]
class MonsterState
{
    public int Health;
}

var monster1 = stateMap.GetState<MonsterState>("1");
var monster2 = stateMap.GetState<MonsterState>("2");

Custom Serialization Handlers ​

By default, when universal serialization handlers are selected, the engine state (game saves, global state, settings) is serialized via asynchronous System.IO ↗ or with Unity's cross-platform PlayerPrefs ↗ as a fallback for some platforms. To customize the serialization scenario, use custom handlers.

To add a custom handler, implement the ISaveSlotManager<GameStateMap>, ISaveSlotManager<GlobalStateMap> and ISaveSlotManager<SettingsStateMap> interfaces for the game save slots, global state and settings, respectively (each should have its own implementing class).

Implementations are expected to have a public constructor with StateConfiguration and string arguments, where the first is the state configuration object and the second is the path to the saves folder; you can ignore the arguments in your custom implementation if desired.

Below is an example of a custom settings serialization handler that only logs when any of its methods are invoked.

cs
using Naninovel;
using System;
using UnityEngine;

public class CustomSettingsSlotManager : ISaveSlotManager<SettingsStateMap>
{
    public event Action<string> OnBeforeSave;
    public event Action<string> OnSaved;
    public event Action<string> OnBeforeLoad;
    public event Action<string> OnLoaded;
    public event Action<string> OnBeforeDelete;
    public event Action<string> OnDeleted;
    public event Action<string, string> OnBeforeRename;
    public event Action<string, string> OnRenamed;

    public bool Loading => false;
    public bool Saving => false;

    public CustomSettingsSlotManager (StateConfiguration config, string saveDir)
    {
        Debug.Log($"Ctor({saveDir})");
    }

    public bool AnySaveExists () => true;

    public bool SaveSlotExists (string slotId) => true;

    public void DeleteSaveSlot (string slotId)
    {
        Debug.Log($"DeleteSaveSlot({slotId})");
    }

    public void RenameSaveSlot (string sourceSlotId, string destSlotId)
    {
        Debug.Log($"RenameSaveSlot({sourceSlotId},{destSlotId})");
    }

    public Awaitable Save (string slotId, SettingsStateMap data)
    {
        Debug.Log($"Save({slotId})");
        return Async.Completed;
    }

    public Awaitable<SettingsStateMap> Load (string slotId)
    {
        Debug.Log($"Load({slotId})");
        return Async.Result(new SettingsStateMap());
    }

    public Awaitable<SettingsStateMap> LoadOrDefault (string slotId)
    {
        return Load(slotId);
    }
}

NOTE

You can pick any name for your custom serialization handler; CustomSettingsSlotManager is just an example.

When a custom handler is implemented, it appears in the state configuration menu, where you can select it instead of the built-in one.

cover