Welcome back to this series on the mysterious blackboard! Last time, we defined the broad lines of what a blackboard was and how we want to implement it, and now we’ll actually get to it!

Prerequisites

I’m using Unity 2020.1.15f1 with the Input System package installed. You can find the source code for the project here

Note: the new Input System for Unity is amazing, but there are quite a few variations on how to set it up and use it. I encourage you to look at the official documentation as well as this video to find out which method works best for you.

So the initial setup of our project is pretty simple: you have a character that can move around in a closed room, there’s a green switch to his left (which for now only displays a debug log) and a green door in front of him. We have the following scripts:

  • PlayerMovement: Moves the player
  • IInteractive: An interface that denotes which objects can be interacted with
  • Switch: A script that implements IInteractive and toggles a boolean
  • Interactor: Allows the player to interact with things

Creating the Blackboard Asset

Our first goal, as mentioned in the previous part, is to create a centralized object that facilitates the sharing and extraction of data. We could use a Monobehaviour, but that would limit our changes to the scene it’s instantiated in, so instead we’re going to be using a ScriptableObject, as any change to its data is shared between all instances of a same asset.

To keep everything organized as much as possible, we’re going to make a sub-folder to our Scripts folder for all our blackboard-related scripts, as well as a folder to hold our ScriptableObject assets. It’ll look a little something like this:

Next, in the Scripts/Blackboard folder, we’ll add a new C# script called (you guessed it) Blackboard. Let’s delete the default contents of the file and replace it with this:

using UnityEngine;

[CreateAssetMenu(fileName = "New Blackboard", menuName = "Blackboard/Blackboard", order = 0)]
public class Blackboard : ScriptableObject
{
    
}

It may not contain much yet, but at least it will allow us to create a new blackboard asset in the ScriptableObjects/Blackboard folder, which will allow us to see our changes in real time. Thanks to the CreateAssetMenu attribute that we’ve placed right above our class definition, we can create our asset by right-clicking inside the project window and by selecting Create->Blackboard->Blackboard. Let’s name it BB_Main

We’re also going to create a second type of ScriptableObject called BlackboardVariable. This time, however, it will be an abstract class, and we’ll put it in the Scripts/Blackboard/BlackboardVariables folder (which you’ll need to create as well). Here’s what the contents of the file will look like for now:

using UnityEngine;

public abstract class BlackboardVariable : ScriptableObject
{
    
}

We’ll definitely want a sort of list to keep track of all our variables, but we also want to be able to reference it quickly and easily, so our best bet is to use a dictionary with strings as keys and BlackboardVariables as values. There is a sizeable problem though: Dictionaries aren’t natively serialized by Unity, which means it won’t show up in the inspector. Worry not, though, because after a little bit of research, I’ve found this nifty little wrapper by Unity user christophfranke123 that fixes this exact problem. Let’s add that script in its own file called SerializableDictionary, in the Assets/Scripts/Utils folder, which we’ll create.

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

[Serializable]
public class SerializableDictionary<TKey, TValue> : Dictionary<TKey, TValue>, ISerializationCallbackReceiver
{
    [SerializeField]
    private List<TKey> keys = new List<TKey>();
     
    [SerializeField]
    private List<TValue> values = new List<TValue>();
     
    // save the dictionary to lists
    public void OnBeforeSerialize()
    {
        keys.Clear();
        values.Clear();
        foreach(KeyValuePair<TKey, TValue> pair in this)
        {
            keys.Add(pair.Key);
            values.Add(pair.Value);
        }
    }
     
    // load dictionary from lists
    public void OnAfterDeserialize()
    {
        Clear();
 
        if(keys.Count != values.Count)
            throw new Exception(
                $"there are {keys.Count} keys and {values.Count} values after deserialization. " +
                $"Make sure that both key and value types are serializable."
            );
 
        for(int i = 0; i < keys.Count; i++)
            Add(keys[i], values[i]);
    }
}

Now we can safely add a field in our Blackboard class to have our dictionary show up:

using System;
using UnityEngine;

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

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

That’s great and all, but right now if we were to try to increase the keys or values size of the dictionary through the inspector, we’ll be met with an exception because we can’t increase both at the same time. We’ll make it look good later, but for the moment let’s add a bit of temporary code to allow us to do just that:

using System;
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();

    public bool KeyExists(string eventName) => blackboardEntries.ContainsKey(eventName);
    public BlackboardVariable GetValue(string eventName) => blackboardEntries[eventName];

    private void OnValidate()
    {
        // Resize the blackboard, if need be
        int countDifference = rowsCount - blackboardEntries.Count;
        
        if (countDifference > 0)
        {
            int pairsAdded = 0;
            int i = 0;
            while (pairsAdded < countDifference)
            {
                string newKey = "newKey" + i;
                if (!KeyExists(newKey))
                {
                    blackboardEntries.Add(newKey, null);
                    pairsAdded++;
                }

                i++;
            }
        }
        else if (countDifference < 0)
        {
            for (int i = 0; i < Math.Abs(countDifference); i++)
            {
                blackboardEntries.Remove(blackboardEntries.Keys.Last());
            }
        }
    }
}

Finally, we want to be able to actually create some BlackboardVariables, so let’s inherit from it (and put the created script in Assets/Scripts/Blackboard/BlackboardVariables (name it BoolVariable)).

using UnityEngine;

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

Let’s also create an asset from it, which we’ll put in a Assets/ScriptableObjects/Blackboard/BlackboardVariables folder that we’ll just have created, and name it BV_DoorOpen.

And just like that, we have our blackboard set up! All we have to do now is to go back in our BB_Main blackboard, set our rows count to 1, name the row’s key to DoorOpen and set its value to our BV_DoorOpen asset.

Creating the Controller

Holding the data with the blackboard is alright, but being able to use it is even better. That’s why we’re going to create a BlackboardController class that’ll allow us to, well, control the blackboard and its data. Let’s place this new script in Assets/Scripts/Blackboard.

using UnityEngine;

public class BlackboardController : MonoBehaviour
{
    // We only ever want to have one controller at a time, so we'll make this a singleton
    private static BlackboardController instance;
    
    [SerializeField] private Blackboard blackboard;

    private void Awake()
    {
        if(instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else 
        {
            Destroy(gameObject);
        }
    }
    
    public BlackboardVariable GetBlackboardValue(string key)
    {
        if (!blackboard.KeyExists(key))
        {
            Debug.LogError($"key {key} does not exist for this blackboard");
            return null;
        }

        return blackboard.GetValue(key);
    }
}

And with that, we’ll be able to access the variables in our blackboard at runtime. But why stop there? Let’s instead reach our second goal, which was to

trigger events from the blackboard, so that a component doesn’t have to look again every frame

To do that, we’ll implement the Observer pattern, so that each blackboard variable won’t have to know about each of the objects that want to read its data.

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

public class BlackboardController : MonoBehaviour
{
    // We only ever want to have one controller at a time, so we'll make this a singleton
    private static BlackboardController instance;
    
    [SerializeField] private Blackboard blackboard;

    // Actions allow us to execute a series of functions when invoked
    private Action<BlackboardVariable> tempEventHolder;
    private Dictionary<string, Action<BlackboardVariable>> blackboardEvents =
        new Dictionary<string, Action<BlackboardVariable>>();

    private void Awake()
    {
        // ...
    }
    
    public BlackboardVariable GetBlackboardValue(string key)
    {
        // ...
    }

    public void StartListening(string eventName, Action<BlackboardVariable> listener)
    {
        if (!blackboard.KeyExists(eventName))
        {
            Debug.LogError($"key {eventName} does not exist for this blackboard");
            return;
        }

        tempEventHolder = null;
        // If we already have the specific key and its value in the dictionary
        // don't re-add the pair into dictionary
        if (blackboardEvents.TryGetValue(eventName, out tempEventHolder))
        {
            tempEventHolder += listener;
            // Because TryGetValue returned the found delegate to tempEventHolder as value type
            // Any change on the variable tempEventHolder is local
            // could not affect the delegate stored back in the dictionary
            // we need to Copy the newly aggregated delegate back into the dictionary.
            blackboardEvents[eventName] = tempEventHolder;
        }
        else
        {
            tempEventHolder += listener;
            blackboardEvents.Add(eventName, tempEventHolder);
        }
    }

    public void StopListening(string eventName, Action<BlackboardVariable> listener)
    {
        if (!blackboard.KeyExists(eventName))
        {
            Debug.LogError($"key {eventName} does not exist for this blackboard");
            return;
        }

        tempEventHolder = null;
        if (blackboardEvents.TryGetValue(eventName, out tempEventHolder))
        {
            tempEventHolder -= listener;
            // Because TryGetValue returned the found delegate to tempEventHolder as value type
            // Any change on the variable tempEventHolder is local
            // could not affect the delegate stored back in the dictionary
            // we need to Copy the newly deducted delegate back into the dictionary.
            blackboardEvents[eventName] = tempEventHolder;
        }
    }

    public void TriggerEvent(string eventName)
    {
        if (!blackboard.KeyExists(eventName))
        {
            Debug.LogError($"key {eventName} does not exist for this blackboard");
            return;
        }

        tempEventHolder = null;
        if (blackboardEvents.TryGetValue(eventName, out tempEventHolder))
        {
            tempEventHolder?.Invoke(blackboard.GetValue(eventName));
        }
    }
}

That’s pretty much all the code we need for the blackboard controller to work. Next, we’ll create a GameObject in our scene to hold said controller. It should look like this (don’t forget to tag it as BlackboardController, it will be important later):

Testing out our work

Looking into our Switch script (which we haven’t, actually opened yet), we’ll find the following lines:

using UnityEngine;

public class Switch : MonoBehaviour, IInteractive
{
    private bool isOn;
    
    public void Toggle()
    {
        isOn = !isOn;
        Debug.Log("switch is now " + isOn);
    }

    public void Interact()
    {
        Toggle();
    }
}

Not very impressive, if you ask me. Let’s spruce it up by integrating the blackboard we’ve worked so hard on (or more specifically, its controller):

using UnityEngine;

public class Switch : MonoBehaviour, IInteractive
{
    private BlackboardController bc;
    [SerializeField] private string blackboardEventName;

    private void Start()
    {
        bc = GameObject.FindWithTag("BlackboardController").GetComponent<BlackboardController>();
    }

    public void Interact()
    {
        BoolVariable boolVariable = (BoolVariable)bc.GetBlackboardValue(blackboardEventName);
        boolVariable.value = !boolVariable.value;
        bc.TriggerEvent(blackboardEventName);
    }
}

Quite the change, isn’t it? Let’s break down the important things to note:

  1. We start by getting a reference to our blackboard controller, so that we don’t have to get it again every time the player interacts with the switch
  2. We get the value from the blackboard, then modify it directly. Since BlackboardVariables are reference types, we don’t need a special function to set their values
  3. Updating the BoolVariable’s value doesn’t automatically trigger an event. This is because we might want to change several things on a BlackboardVariable before deciding it’s ready for an event, or we might not want to trigger one at all.

Once we’re done updating our Switch script, all that’s left is to make our door move. To do this, we’ll create a new script called Door that we’ll place in Assets/Scripts.

using UnityEngine;

public class Door : MonoBehaviour
{
    private BlackboardController bc;
    [SerializeField] private string blackboardEventName;
    
    private Vector3 closedPos;
    private Vector3 openPos;
    private Vector3 goal;
    private bool isOpen;
    
    // Start is called before the first frame update
    private void Start()
    {
        closedPos = transform.position;
        openPos = closedPos - new Vector3(2f, 0f, 0f);
        bc = GameObject.FindWithTag("BlackboardController").GetComponent<BlackboardController>();
        
        bc.StartListening(blackboardEventName, OnDoorToggle);
        
        OnDoorToggle(bc.GetBlackboardValue(blackboardEventName));
    }

    // Update is called once per frame
    private void Update()
    {
        if (Vector3.Distance(goal, transform.position) <= 2f)
        {
            transform.position = Vector3.MoveTowards(transform.position, goal, 0.1f);
        }
    }

    private void OnDoorToggle(BlackboardVariable data)
    {
        isOpen = ((BoolVariable) data).value;
        
        UpdateGoal();
    }

    private void UpdateGoal()
    {
        if (isOpen) goal = openPos;
        else goal = closedPos;
    }

    private void OnDestroy()
    {
        bc.StopListening(blackboardEventName, OnDoorToggle);
    }
}

Again, the important things to note:

  1. We have a function that takes a BlackboardVariable as parameter (in this case OnDoorToggle). This is the function that will react to the event being triggered
  2. We start listening to the blackboard entry in the Start function, and stop in the OnDestroy. The stopping part is important, otherwise exceptions will be thrown if you ever switch scenes at runtime
  3. Since no event is fired as soon as the game starts, we call OnDoorToggle once manually in Start so that we know what our starting state is.

And with that, all that’s left to do is to make sure the Blackboard Event Name field on both the switch’s and the door’s inspector windows are set to DoorOpen, and then we can test our final result!

Voilà, the basic implementation of our blackboard and controller is done! In the next part of this series, we’ll look at how we can offer the choice of which variables to persist, as well as how to package our variables as a save file.