Welcome back one and all to our wacky adventure of design and code! Last time, we learned how to implement the core functionality of our blackboard tool, which allows us to handle data and events at a single spot, but you may have noticed that our solution so far left us with a few problems, most notably:

  • Scalability: At the moment, we have one switch that can toggle a door on and off. But what if the switch didn’t allow the player to turn it off? Well in that case, we would have to go reset it manually between each play session, like so:

It might not be too much of an issue if you only have one door, but imagine having dozens of different variables like that, each with it’s own initial state. It quickly gets out of hand, and you’ll end up spending most of your time resetting everything

  • We’ll likely want to add a save/load functionality in our game at some point, which will include saving the state of the blackboard and its variables. Unfortunately for us, you can’t export directly a ScriptableObject.

Let’s see how we can build upon our solution to accommodate our needs.

Adding optional persistence

We’ll start by updating our scene a bit, so we can see our changes better. We’ll add a second, yellow door that will have it’s own switch. Additionally, we’ll add a new BoolVariable asset called BV_DoorOpen2, and we’ll add a second row to our blackboard, call its key DoorOpen2 and set its value to the asset we just created. Finally, we’ll set the BlackboardEventName field on both the yellow door and switch to DoorOpen2.

Now that this is done, the remainder of our task will mostly consist of code. Open the BlackboardVariable class, and add the following field, as well as the following function declarations:

using UnityEngine;

public abstract class BlackboardVariable : ScriptableObject
{
    public bool shouldChangesPersist;
    
    public abstract void SnapshotState();
    public abstract void UndoChanges();
}

You’ll get some compilation errors, because that’s what unimplemented abstract functions do, so let’s fix that by going in the BoolVariable class and adding the following lines:

using UnityEngine;

[CreateAssetMenu(fileName = "New BoolVariable", menuName = "Blackboard/Variables/Bool", order = 0)]
public class BoolVariable : BlackboardVariable
{
    public bool value;
    private bool valueSnapshot;

    public override void SnapshotState()
    {
        valueSnapshot = value;
    }

    public override void UndoChanges()
    {
        value = valueSnapshot;
    }
}

Now let’s update the BlackboardController class to iterate over our blackboard entries and save a snapshot of each entry’s initial state:

using System;
using System.Collections.Generic;
using UnityEngine;

public class BlackboardController : MonoBehaviour
{
    // ...

    private void Awake()
    {
        if(instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else 
        {
            Destroy(gameObject);
        }
        
        LoadBlackboardState();
    }
    
    public BlackboardVariable GetBlackboardValue(string key)
    {
        // ...
    }

    public void StartListening(string eventName, Action<BlackboardVariable> listener)
    {
        // ...
    }

    public void StopListening(string eventName, Action<BlackboardVariable> listener)
    {
        // ...
    }

    public void TriggerEvent(string eventName)
    {
        // ...
    }

    private void LoadBlackboardState()
    {
        foreach (KeyValuePair<string,BlackboardVariable> entry in blackboard.AsList())
        {
            if (entry.Value != null) entry.Value.SnapshotState();
        }
    }

    private void OnDestroy()
    {
        foreach (KeyValuePair<string, BlackboardVariable> entry in blackboard.AsList())
        {
            if (entry.Value != null && !entry.Value.shouldChangesPersist) entry.Value.UndoChanges();
        }
    }
}

You’ll notice that the AsList function does not exist yet in the Blackboard class, so we’ll need to add that:

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

[CreateAssetMenu(fileName = "New Blackboard", menuName = "Blackboard/Blackboard", order = 0)]
public class Blackboard : ScriptableObject
{
    public int rowsCount;
    
    [Serializable] public class DictionaryOfStringAndBV : SerializableDictionary<string, BlackboardVariable> {}

    [SerializeField] private DictionaryOfStringAndBV blackboardEntries = new DictionaryOfStringAndBV();

    ///<summary> Use this when iterating over the whole blackboard. To get a single blackboard entry,
    /// use GetBlackboardValue (when dealing with the blackboard controller) or GetValue instead. </summary>
    public List<KeyValuePair<string, BlackboardVariable>> AsList() => blackboardEntries.ToList();
    
    // ...
}

We’re using a function instead of just declaring the blackboardEntries dictionary as public because we want good encapsulation, even though creating a list might be a little less performant.

Regardless, all that’s left to do is to hop back in the editor, tick the box in the BV_DoorOpen asset that says Should Changes Persist and then test it out!

As we can see, the green door remembers that it's been opened, but the yellow door does not. Success!

Exporting the state to a save file

You may already know this, but there are multiple ways to save data in Unity so that it can be reused later. In our case, we’ll handle this by exporting the state of all the blackboard entries that we want to persist to a binary file.

Setting up the (new) scene

To be able to test this out, we’ll make a few changes to our project:

  • We’ll make a new scene named “Title” and put both scenes in the build (by going in build settings)
  • We’ll open our new scene and place two buttons, like this:
  • Because we’re using the new Input System, make sure to inspect the EventSystem GameObject that was just generated and press “Replace with InputSystemUIInputModule”
  • Add a script called TitleButtons and place it as a component to both of our buttons. Here’s what the inside of our script will look like:
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleButtons : MonoBehaviour
{
    [SerializeField] private bool isNewGame;

    public void LoadMain()
    {
        if (isNewGame) Debug.Log("Deleting save file");
        SceneManager.LoadScene("Main");
    }
}

(Make sure to tick the Is New Game box on NewGameButton’s inspector)

  • Add a call to the LoadMain function on the click of each button (here’s what the NewGameButton's inspector will look like when you’re done)
  • Create a new type of BlackboardVariable, named PlayerStatsVariable. Here’s what you’ll find inside the script:
using UnityEngine;

[CreateAssetMenu(fileName = "New PlayerStatsVariable", menuName = "Blackboard/Variables/PlayerStats", order = 0)]
public class PlayerStatsVariable : BlackboardVariable
{
    public Vector3 playerPosition;
    private Vector3 playerPositionSnapshot;
    
    public override void SnapshotState()
    {
        playerPositionSnapshot = playerPosition;
    }

    public override void UndoChanges()
    {
        playerPosition = playerPositionSnapshot;
    }
}
  • Create an asset from this new variable type, calling it BV_PlayerStats
  • Add a row to your blackboard, name the key "PlayerStats" and set its value to our BV_PlayerStats that we just made
  • Finally, update the PlayerMovement script with the following lines:
public class PlayerMovement : MonoBehaviour
{
    private BlackboardController bc;
    private PlayerStatsVariable playerStats;
    
    // ...
    
    // Start is called before the first frame update
    void Start()
    {
        bc = GameObject.FindWithTag("BlackboardController").GetComponent<BlackboardController>();
        playerStats = (PlayerStatsVariable)bc.GetBlackboardValue("PlayerStats");
        transform.position =
            new Vector3(playerStats.playerPosition.x, transform.position.y, playerStats.playerPosition.z);
        
        // ...
    }
    
    public void Move(InputAction.CallbackContext context)
    {
        // ...
    }

    // Update is called once per frame
    void Update()
    {
        // ...

        playerStats.playerPosition = transform.position;
    }

That was a lot of steps, but if everything went well, you should see that updating the BV_PlayerStats asset makes it so the player spawns somewhere else.

Creating the save file

Now for the actual save file generation part, we'll start by adding a new folder to our hierarchy: Scripts/Blackboard/Persistence. Inside this folder will be the following two classes, each in their own files:

using System;
using System.Collections.Generic;

[Serializable]
public class BlackboardStateSave
{
    public List<KeyValuePair<string, BVarSave>> persistedEntries = 
        new List<KeyValuePair<string, BVarSave>>();
}
[System.Serializable]
public abstract class BVarSave
{
        
}

While BlackboardStateSave is simply a serializable container for BVarSave, the latter serves a much bigger purpose. Since ScriptableObjects cannot be directly serialized, we have to create some new classes that don’t inherit from ScriptableObject and that we’ll be able to serialize as we wish. And so, let’s create our “saved” versions of both our variable types. I’ll call them BVSBoolVariable and BVSPlayerStatsVariable

[System.Serializable]
public class BVSBoolVariable : BVarSave
{
    public bool value;

    public BVSBoolVariable(bool value)
    {
        this.value = value;
    }
}
using UnityEngine;

[System.Serializable]
public class BVSPlayerStatsVariable : BVarSave
{
    public float x;
    public float y;
    public float z;

    public BVSPlayerStatsVariable(Vector3 playerPosition)
    {
        x = playerPosition.x;
        y = playerPosition.y;
        z = playerPosition.z;
    }
}

Next, we’ll update the BlackboardVariable class with a few function declarations, as well as by replacing the shouldChangesPersist boolean by a persistenceType enum:

using UnityEngine;

public abstract class BlackboardVariable : ScriptableObject
{
    public PersistenceType persistenceType;

    public abstract BVarSave CreateSave();
    public abstract void LoadFrom(BVarSave source);
    public abstract System.Type GetSaveType();
    public abstract void SnapshotState();
    public abstract void UndoChanges();
}

[System.Serializable]
public enum PersistenceType
{
    NeverPersist,
    AlwaysPersist,
    SavedToFile
}

And of course, we'll update the variable scripts accordingly:

using System;
using UnityEngine;

[CreateAssetMenu(fileName = "New BoolVariable", menuName = "Blackboard/Variables/Bool", order = 0)]
public class BoolVariable : BlackboardVariable
{
    public bool value;
    private bool valueSnapshot;

    public override BVarSave CreateSave()
    {
        return new BVSBoolVariable(value);
    }

    public override void LoadFrom(BVarSave source)
    {
        value = ((BVSBoolVariable) source).value;
    }

    public override Type GetSaveType()
    {
        return typeof(BVSBoolVariable);
    }

    public override void SnapshotState()
    {
        valueSnapshot = value;
    }

    public override void UndoChanges()
    {
        value = valueSnapshot;
    }
}
using System;
using UnityEngine;

[CreateAssetMenu(fileName = "New PlayerStatsVariable", menuName = "Blackboard/Variables/PlayerStats", order = 0)]
public class PlayerStatsVariable : BlackboardVariable
{
    public Vector3 playerPosition;
    private Vector3 playerPositionSnapshot;

    public override BVarSave CreateSave()
    {
        return new BVSPlayerStatsVariable(playerPosition);
    }

    public override void LoadFrom(BVarSave source)
    {
        BVSPlayerStatsVariable sourceAsBVS = (BVSPlayerStatsVariable) source;
        playerPosition = new Vector3(sourceAsBVS.x, sourceAsBVS.y, sourceAsBVS.z);
    }

    public override Type GetSaveType()
    {
        return typeof(BVSPlayerStatsVariable);
    }

    public override void SnapshotState()
    {
        playerPositionSnapshot = playerPosition;
    }

    public override void UndoChanges()
    {
        playerPosition = playerPositionSnapshot;
    }
}

And finally, we'll update the BlackboardController to handle the save creation and loading:

using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using UnityEngine;

public class BlackboardController : MonoBehaviour
{
    // ...

    public void TriggerEvent(string eventName)
    {
        // ...
    }

    private BlackboardStateSave SaveBlackboardState()
    {
        BlackboardStateSave save = new BlackboardStateSave();
        
        foreach (KeyValuePair<string, BlackboardVariable> entry in blackboard.AsList())
        {
            if (entry.Value == null || entry.Value.persistenceType == PersistenceType.AlwaysPersist) 
                continue;
            
            if (entry.Value.persistenceType == PersistenceType.SavedToFile)
                save.savedEntries.Add(new KeyValuePair<string, BVarSave>(entry.Key, entry.Value.CreateSave()));
            
            entry.Value.UndoChanges();
        }

        return save;
    }

    // Just replace what was already in this function
    private void LoadBlackboardState()
    {
        if (File.Exists(Application.persistentDataPath + "/SaveFile"))
        {
            BlackboardStateSave save;
            try
            {
                BinaryFormatter bf = new BinaryFormatter();
                FileStream file = File.Open(Application.persistentDataPath + "/SaveFile", FileMode.Open);
                save = (BlackboardStateSave)bf.Deserialize(file);
                file.Close();
            }
            catch (Exception e)
            {
                Debug.LogError(e);
                Debug.LogError("You most likely need to delete the SaveFile file at '" + 
                               Application.persistentDataPath + "', start/stop the game and look at the exception " +
                               "thrown on playmode exit");
                throw;
            }
            
            foreach (KeyValuePair<string, BVarSave> entry in save.savedEntries)
            {
                if (!blackboard.KeyExists(entry.Key))
                {
                    Debug.LogWarning($"Key {entry.Key} does not exist for the current blackboard. Skipping.");
                    continue;
                }

                BlackboardVariable finalValue = blackboard.GetValue(entry.Key);
                if (entry.Value.GetType() != finalValue.GetSaveType())
                {
                    Debug.LogWarning($"Value {entry.Value.GetType()} does not match the value type of current " +
                                     $"blackboard's {entry.Key} key, which is {finalValue.GetType()}. Skipping.");
                    continue;
                }
                
                if(finalValue.persistenceType == PersistenceType.SavedToFile) finalValue.LoadFrom(entry.Value);
            }

            foreach (KeyValuePair<string,BlackboardVariable> entry in blackboard.AsList())
            {
                if (entry.Value != null && entry.Value.persistenceType != PersistenceType.SavedToFile) 
                    entry.Value.SnapshotState();
            }
        }
        else
        {
            Debug.Log("No saved blackboard data found.");
        }
    }

    // Just replace what was already in this function
    private void OnDestroy()
    {
        // Prevents unnecessary saving if we're not destroying the original BlackboardController
        if (instance != this) return;
        
        BlackboardStateSave save = SaveBlackboardState();
        
        BinaryFormatter bf = new BinaryFormatter();
        FileStream file = File.Create(Application.persistentDataPath + "/SaveFile");
        bf.Serialize(file, save);
        file.Close();
    }
}

There’s a lot of code that was added here, but the gist of it is that

  • When we exit play mode (which destroys the BlackboardController), we call SaveBlackboardState, which loops through all the entries of our blackboard and writes the ones we marked as “saved to file” to a save file, then resets all entries that weren’t marked as “always persist”
  • When we load the main scene (which creates the BlackboardController), it looks for a save file on the user’s disk. If it finds one, it reads its contents and tries to match it with the contents of the blackboard, thus overriding the blackboard’s variables' values

Before testing, we’ll mark the BV_PlayerStats's Persistence Type as Saved To file, and BV_DoorOpen's as Always Persist like so:

and we'll do one little modification to the TitleButtons script:

using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleButtons : MonoBehaviour
{
    [SerializeField] private bool isNewGame;

    public void LoadMain()
    {
        if (isNewGame && File.Exists(Application.persistentDataPath + "/SaveFile")) 
            File.Delete(Application.persistentDataPath + "/SaveFile");
        SceneManager.LoadScene("Main");
    }
}

This ensures that we actually delete the save file when starting a new game.

Let’s test our final result

As you can see, the player’s position is both remembered when we press “continue” and reset when we press “new game” (because we marked it as Saved to file), while the green door always remembers its last state (because we marked it as Always persist)

And that’s it! All the blackboard’s demanded functionalities are implemented. In the next (and last) part of this blog series, we’ll look into how we can give the blackboard and blackboard controller inspectors a much needed UI overhaul.