ink + Unity: Story Saving and Restoring (Using JSON Serialization)

When working with Unity, ink stories exists as two separate states: source and compiled JSON. When using the ink + Unity integration plugin, changes to a source (.ink) file will result (unless the automatic compilation is turned off) in the creation of new compiled (.json). This allows authors to work in ink source and lets the plugin handle setting up the API between its internal runtime and Unity C# code.

This post covers the two use cases of using serialization on either the Story class via its toJson() method or accessing the StoryState instance inside every Story class to restore the state of a story after potentially saving its values to a file. For smaller projects, the first use case can be helpful, but for most larger projects the second, where only StoryState is used, is the preferred way to approach serialization of story data.

Introduction to Story class toJson() method

In the Ink.Runtime namespace is the Story object. This has a method named toJson() that produces a JSON string or stream based on the current story. It creates a new JSON story or stream based on the current values in the story. It performs what is known as serialization.

In programming, the action of serialization is the conversion of in-memory values into another format capable of being stored. For example, it is possible to take a set of values in memory and convert them into simple text to be read and re-created. In the case of the toJson() method, this takes the current values of a Story object and serializes it as a JSON string or stream.

Saving and Restoring the Whole Story

The use of the toJson() method of the Story class acts as a snapshot of story content as it currently exists in memory. All textual content, current values of variables, the callstack, and other values are translated into JSON for possible storage.

In the case of smaller stories, this presents a useful way to save and restore a story. Using the toJson() method saves the whole story, which can then be encrypted and saved as either binary or text data.

A possible example class named StorySerialization might be the following:

using UnityEngine;
using Ink.Runtime;
using System.IO;

public class StorySerialization
{
    // Set a path to save and restore Story.
    static string savePath = Application.persistentDataPath + "/currentStory.json";

    // Set a default path (in case the save file does not exist).
    static string defaultPath = Application.persistentDataPath + "/default.json";

    // Convert a story into a JSON string.
    static public void Serialize(Story s)
    {
        // Either create or overwrite an existing story file.
        File.WriteAllText(savePath, s.ToJson());
    }

    // Create a story based on saved JSON.
    static public Story Deserialize()
    {
        // Create a story to return.
        Story s;

        // Create internal JSON string.
        string JSONContents;

        // Does the file exist?
        if (File.Exists(savePath))
        {
            // Read the entire file
            JSONContents = File.ReadAllText(savePath);
            // Create a new Story based on JSON
            s = new Story(JSONContents);
        }
        else
        {
            // File does not exist.
            // Load the default
            JSONContents = File.ReadAllText(defaultPath);
            // Create Story based on default
            s = new Story(JSONContents);
        }

        // Return either default or restored story
        return s;
        
    }
}

In the above code, the static public methods (meaning they can be called from anywhere) StorySerialization.Serialize() and StorySerialization.Deserialize() can be called to save a Story object to a file or restore from it.

A very simplified usage might be the following:

story = new Story(InkJSONFile.text);

// Save to file
StorySerialization.Serialize(story);

// Restore from file
story = StorySerialization.Deserialize();

Saving and Restoring only StoryState

In the case of larger stories, serializing everything could potentially be a very expensive and ultimately wasteful process. For projects consisting of thousands of lines or more of ink source code, every use of the Story class toJson() method would mean copying everything out of memory into a JSON string or stream. For most large projects, this is not needed. Instead, the toJson() method of the StoryState class can be used.

Every Story class instance has a state object instance of the StoryState class. Internally, this serves as a record of “global variables, read counts, the pointer to the current point in the story, the call stack (for tunnels, functions, etc), and a few other smaller bits and pieces” (ink/StoryState, 2021). This means every Story tracks things internal to story progression, but most of that important data, is part of StoryState internally.

Serialization of only state (StoryState class instance) might look like the following:

using UnityEngine;
using Ink.Runtime;
using System.IO;

public class StoryStateSerialization
{
    // Set a path to save and restore StoryState.
    static string savePath = Application.persistentDataPath + "/currentStoryState.json";

    // Convert a StoryState into a JSON string and save file.
    static public void Serialize(StoryState s)
    {
        // Either create or overwrite an existing story file.
        File.WriteAllText(savePath, s.ToJson());
    }

    // Update referenced Story object based on saved StoryState (if it exists)
    static public Story Deserialize(ref Story s)
    {
        // Create internal JSON string.
        string JSONContents;

        // Does the file exist?
        if (File.Exists(savePath))
        {
            // Read the entire file.
            JSONContents = File.ReadAllText(savePath);
            // Overwrite current Story based on saved StoryState data.
            s.state.LoadJson(JSONContents);
        }

        // Return either referenced or restored story.
        return s;
    }
}

In the above example, unlike the StorySerialization example, a Story object must be passed as reference to the StoryStateSerialization.Deserialize() method. This is because StoryState must be instantiated with an existing Story class instance and, in turn, Story needs a compiled JSON string. This requirement means a Story must already exist, but that the result of calling the method and passing the object will overwrite its current values.

A very simplified usage might be the following:

story = new Story(InkJSONFile.text);

// Save to file
StoryStateSerialization.Serialize(story.state);

// Restore from file
story = StoryStateSerialization.Deserialize(ref story);