gamedev-workshop

Decoupling

Requirements change. Software evolves. Stay flexible through separation of concerns.

Problem

Tight coupling between modules of a system means that it is

All of which make it difficult to understand, maintain, and evolve both individual components and the system as a whole.

Tangled strings

Solution

We want our components to be modular in order to be able to reason about, test, and reuse each of them individually. Modular components are easier to change or to replace, allowing our system to stay flexible and able to adapt to changing requirements. We achieve modularization by decoupling dependencies and defining minimal interfaces.

Lego bricks

Example

Suppose we’re making a game like Diablo where we have a player character and a UI showing the player’s current health and mana. How do we connect the health bar to the player’s health value? The two components are on totally different game objects!

Singletons

We realize there can only be one player character and one UI in our game. That means we can apply the singleton pattern to access the other component. So smart!

public sealed class Player : MonoBehaviour
{
    public static Player main { get; private set; }

    public float maxHealth;
    public float health;

    void Start()
    {
        main = this;
    }

    // ...
}
public sealed class HealthBar : MonoBehaviour
{
    [SerializeField] private Slider slider;

    void Update()
    {
        var player = Player.main;
        slider.maxValue = player.maxHealth;
        slider.value = player.health;
    }
}

Health bar

Done. So easy. Let’s move on to the next task. Soon enough we will have added inventory, equipment, items, gems, sockets, talent tree, character stats, spell book, buffs, and enemies to the game, and they all have to talk to each other. So we introduce more singletons. Piece of cake!

Dependency graph

Okay, perhaps not so easy anymore. It is then, that our game designer comes up with this new spell which doubles the character attribute effects of each equipped piece of armor. Oh, and also this new berserk buff which times out within 20 seconds unless the player keeps killing enemies. We cry a little on the inside and add more singletons. By the time we receive yet another bug report about character stats not resetting properly once a combination of buff times out we’re ready to quit our job to become baristas instead.

How about let’s not do that. What if I told you, each component can be isolated, modular, and still share information with other components? Enter dependency injection!

Dependency injection

So we start reading about dependency injection, separation of concerns, and inversion of control, and it’s a lot of fancy talk and it seems really complicated and we have to download extra frameworks to make it work or switch to a new language entirely (hah, hah, sigh).

The key insight here is to realize that the inspector in Unity is a dependency injector. In fact, we’ve already been using it to inject dependencies. In the example above the HealthBar component’s reference to the UI Slider is a dependency that we injected using the inspector. It’s as simple as assigning a field. Crazy, right? But that’s all there is to dependency injection, really.

public sealed class Player : MonoBehaviour
{
    public float maxHealth;
    public float health;

    // Look ma, no singleton!
    // ...
}
public sealed class HealthBar : MonoBehaviour
{
    [SerializeField] private Slider slider;
    [SerializeField] private Player player;

    void Update()
    {
        slider.maxValue = player.maxHealth;
        slider.value = player.health;
    }
}

Health bar version 2

Okay, so we replaced the singleton with an injected reference to the player component. Great. High fives all around! Except… did we really gain anything? Let’s evaluate against our problem definition.

Not much improvement, really. We can kind of guess what HealthBar does without looking at Player, but we still can’t test it in an empty scene without also putting a Player into the scene. We also can’t reuse HealthBar’s code for ManaBar or anything. We’ll have to duplicate the code and replace every health with mana to make it work. Disappointing.

Later on, we want to clean things up a bit by moving our UI into its own scene which gets loaded additively. That breaks our reference. How do we even connect the HealthBar to the Player now? They are in different scenes! By now, we’re fed up with dependency injection and ready to throw it on the pile of useless ideas that only sound good in theory.

Not so fast!

ScriptableObjects

Today’s second insight is, that we can inject not only references to other components, but also to ScriptableObjects, which are data containers we can use to store data, independent of class instances. Does that mean we can store the player’s health independent of the Player component? It does! Does that mean we can reference the player’s health independent of the Player component? Yes, it does! Let’s take it for a spin.

[CreateAssetMenu]
public sealed class BoundedFloat : ScriptableObject
{
    public float maxValue;
    public float value;
}
public sealed class StatusBar : MonoBehaviour
{
    [SerializeField] private Slider slider;
    [SerializeField] private BoundedFloat data;

    void Update()
    {
        slider.maxValue = data.maxValue;
        slider.value = data.value;
    }
}
public sealed class Player : MonoBehaviour
{
    [Header("Stats")]
    [SerializeField] private BoundedFloat health;
    [SerializeField] private BoundedFloat mana;

    // ...
}

ScriptableObjects

We move all the data into data containers and reference them from our components. ScriptableObjects live in the assets folder and can be referenced from any prefab, any instance, in any scene. We also unify the code for our health bar and our mana bar, because all they really care about is a maximum value and a value. Nothing in UI references our Player anymore and vice versa. Both just reference data. They don’t even care whether it’s the same data.

Decoupling

Are we there yet? Let’s evaluate again against our problem definition.

Reading the code, we can understand what the Player does without reading what the UI’s StatusBars are and vice versa. There is no dependency between them. We can throw the Player into an empty scene, hook it up to some fake health and mana data, and run that. No problem. Plain old data is easy to clone.

We can also throw the UI into an empty scene, hook it up to some fake data, and run that. We can even animate the values of our fake data and see how the health and mana bars change. Our UI designer can go nuts prettying it up without ever having to run the actual game. Iterating on UI design has never been faster!

We haven’t even started implementing all the other components for our game, but we’re already reusing code. We only need one script for both status bars and we have a feeling that our BoundedFloat will come in handy for lots of other stats and attributes in the future.

Refactoring

One beautiful thing about dependency injection is that we can apply it to existing projects as well. We don’t have to start over. We can gradually carve out individual components by moving dependent data into ScriptableObjects and replacing direct references between components with references to data, step by step. Once we have completely decoupled one component, we can test and refactor it easily in isolation.

Further reading

Translations

If you find this workshop useful and speak another language, I’d very much appreciate any help translating the chapters. Clone the repository, add a localized copy of the README.md, for example README-pt-BR.md, and send me a pull request.