From order to chaos: Anti-patterns in Unity software design

From order to chaos: Anti-patterns in Unity software design

Unity is undeniably a powerful game engine, but there's a disheartening trend I can't ignore: Too many projects developed with Unity end up in a complete mess.

In the Unity project graveyard, many ventures meet their demise in silence. Unsuccessful initiatives are plenty, often abandoned due to a slow buildup in complexity that brings development to a painful, grinding halt.

Over the years, I've noticed that many of us (myself included) fall into bad habits while designing software with Unity. Today, I want to share these anti-patterns with you so you can avoid them in your projects.

In this article, I'll focus on Unity's MonoBehaviour paradigm.

Excessive use of global state

a group of people standing around a table, writing on the same document

The Hierarchy is a central concept in Unity, and it makes it extremely easy to access shared state. For instance, any MonoBehaviour can find and use any GameObject and its corresponding behaviors. Each MonoBehaviour can manage them as needed, including making changes or removing them.

It's a straightforward programming model: A GameObject represents a game entity that combines individual behaviors to perform complex tasks in a modular way.

The MonoBehaviour concept blurs the lines between software and data. When we add behaviors to a GameObject, we are composing functionality, but also configuration, such as which other GameObjects our behaviors will interact with.

This is easily overlooked - how all this configuration, all this data, is exposed to other entities in the Hierarchy. Unfortunately, this exposure can lead to breaking encapsulation principles.

What's wrong with that?

Let's take an example. In your Unity project, you're developing a user interface with nested GameObjects, each representing individual UI elements.

Some of these nested objects have MonoBehaviours with public fields like Width or FontColor. These are intended to be configured by their parent UI window.

Unity Hierachy
├── MyUserInterface
│   ├── FirstUiWindow
│   │   ├── APanel
│   │   ├── AButton
│   ├── SecondUiWindow
│   │   ├── SomeText
│   │   ├── AnAnimation

As the UI implementation grows, you notice that certain elements directly access public fields on other nested objects in the Hierarchy. For instance, when you press a button within one UI window, it directly modifies the FontColor property on a text element in a different UI window.

Unity Hierachy
├── MyUserInterface
│   ├── FirstUiWindow
│   │   ├── APanel
│   │   ├── AButton       >>| OnPress()
│   ├── SecondUiWindow      |
│   │   ├── SomeText      <<| FontColor = Color.Green
│   │   ├── AnAnimation

The button and text elements are now directly related, even though you would not expect them to be so. We could easily imagine how you could end up with several other UI elements modifying this same text element.

Direct access between nested objects creates tight coupling within the system, and unintended consequences start to emerge. The UI elements become harder to maintain, as any one component may be modified by many others, causing race conditions and making bug detection and resolution a challenging task.

As developers venture into the labyrinth of tightly coupled UI complexities, stress and frustration rear their mischievous heads 😡 The absence of clear separation and encapsulation casts a shadow on the code, slowing down progress to a snail's pace.

Tips to limit global state exposure

  1. If you're intending to expose a class field for setting in the Unity Inspector, but not for other behaviors to modify, consider making it [SerializeField] private instead of public.

  2. If changing a class field has side effects, consider exposing it through setter methods or properties that can validate the new value and apply side effects immediately, providing better observability when things go wrong.

  3. When a group of GameObjects are directly related, encapsulate them in a Prefab. If you are feeling particularly defensive, instantiate children at runtime, so they cannot be referenced by other entities at edit-time.

  4. Try to point Inspector references laterally or downward in the Hierarchy. Avoid referencing entities and behaviors that are not siblings or children of the GameObject you're working on.

  5. Avoid global search methods like FindObjectOfType when possible. Instead, opt for GetComponent or GetComponentInChildren to limit the search to the current entity or its children in the Hierarchy.

Weak dependency management

If you've worked with .NET's dependency injection features in other contexts, you'll likely share my frustration with the absence of robust dependency management in most Unity codebases.

Unity offers a helpful attribute called [RequireComponent], allowing us to state dependencies explicitly. By using this attribute, when we add a new component to a GameObject in the Editor, all the required dependencies are automatically included.

However, the real challenge lies in handling unstated dependencies that lurk beneath the surface. These hidden dependencies may not be immediately apparent, but they can wreak havoc at runtime, leading to perplexing and unexpected issues. A repeat offender is the use of GetComponent to find a MonoBehaviour, which introduces implicit dependencies that may cause problems down the road.

To complicate things further, we cannot make assumptions on the execution order of game loop callbacks for MonoBehaviours unless we manually manage them using the Script Execution Order settings or the less-known [DefaultExecutionOrder] attribute. For this reason, each behavior may need to double-check if its dependencies are ready before trying to use them.

So, what's the problem?

Let's expand upon the previous example to illustrate the scenario further. Imagine you have a UI window dedicated to displaying a player's inventory, organized in the Hierarchy as follows:

Unity Hierachy
├── MyUserInterface
│   ├── InventoryWindow
│   │   ├── AllItemsPanel
│   │   ├── SelectedItemImage
│   │   ├── EquipItemButton

Similar to before, the InventoryWindow contains several UI elements, each designed for specific purposes. To bring it together, we add a new script called InventoryWindowManager to the InventoryWindow entity:

public class InventoryWindowManager : MonoBehaviour
{
    private void Awake()
    {
        // Get the button component and subscribe to press events
        var button = GetComponentInChildren<MyButton>();
        button.OnPressed += OnEquipItemButtonPressed();
    }

    private void OnEquipItemButtonPressed()
    {
        // TODO: Equip the selected item from inventory...
    }
}

Initially, using GetComponentInChildren in InventoryWindowManager seems promising. It finds the MyButton component on EquipItemButton within the InventoryWindow hierarchy and handles its OnPressed event smoothly.

However, as the project evolves, challenges arise from this implicit dependency. UI changes, like altering the InventoryWindow layout or using a different button interaction can break the implicit link established by GetComponentInChildren. This may lead to null references or unexpected behavior during runtime.

Moreover, the current implementation tightly binds the InventoryWindowManager script to the specific hierarchy of the InventoryWindow, limiting its flexibility and reusability.

As the codebase expands, dealing with implicit dependencies becomes an exasperating task, hindering progress and creating a sense of burden in the development process.

Tips to manage your dependencies

  1. If it makes sense to store dependent behaviors on the same GameObject, use the [RequireComponent] attribute to automatically enforce the dependency.

  2. Instead of using search methods like GetComponentInChildren, consider if you can take a dependency explicitly in the Editor with a serialized private field instead.

  3. Gather and validate all your behavior's dependencies in Awake or Start. This makes it easier to debug problems caused by bad dependencies down the road.

  4. In situations where a dependency could be uninitialized or null, Assertions can be used to validate it in a performant way, as these are automatically stripped from production builds.

  5. Try out a dependency injection framework like Reflex or VContainer, or make your own IoC container and learn something new!

Hard-to-verify behavior

Many developers, including myself, tend to lean towards manual testing when working with Unity. However, I think it's worth questioning whether automated testing would be more appealing if our components weren't so tightly coupled.

Personally, I have found the most value in targeting the lower end of the Testing Pyramid, focusing on component tests that validate individual behaviors from the "outside-in" through their public interfaces. Oftentimes, MonoBehaviour methods (like Update) don't return a result. Instead, they produce side effects in the Hierarchy or their dependencies. These can be verified with automated tests.

Yet, the path to effective testing is often obstructed by coupling and dependency management issues. Additionally, most Unity apps rely heavily on Unity engine features, and these are often accessed through static methods or classes without interfaces. This makes it hard to test a behavior independently of the engine.

Without careful consideration, individual scripts become entangled in specific contexts, resulting in what I call "hard-to-verify behavior." Such behaviors heavily depend on their surroundings, breaking when they are moved away from certain components, Hierarchy objects, or if they are instantiated at the wrong time. As the number of such behaviors grows, manual testing becomes the only way to ensure app functionality, leading to an error-prone process as the app's complexity expands.

A concrete example

Imagine you are developing a 2D platformer game with a player character controlled by a MonoBehaviour script called PlayerController. The script handles essential functionalities such as movement, jumping, and collision detection:

[RequireComponent(typof(RigidBody))]
public class PlayerController : MonoBehaviour
{
    public delegate void PlayerEvent();
    public event PlayerEvent OnPlayerDied;

    private bool _isInvincible;

    public void SetInvincibility(bool shouldBeInvincible)
    {
        _isInvincible = shouldBeInvincible;
        // TODO: Do more stuff while invincible (e.g., visual effects)
    }

    private void OnCollisionEnter(Collision collision)
    {
        // Trigger death event on collission if not invincible
        if (!_isInvincible)
        {
            OnPlayerDied();
        }
    }

    // TODO: Movement, jumping, etc.
}

Initially, everything seems to work well, and manual testing confirms that the game behaves as expected. However, as the development progresses and more features are added, the codebase starts to become more complex.

Now, you want to introduce a new power-up that gives the player temporary invincibility. To achieve this, you create a PowerUpController script that manages the power-up behavior:

[RequireComponent(typof(PlayerController))]
public class PowerUpController : MonoBehaviour
{
    private PlayerController _playerController;

    private void Awake()
    {
        _playerController = GetComponent<PlayerController>();
    }

    public async Task ApplyInvincibilityPowerUp()
    {
        // Make player invincible for 5 seconds
        _playerController.SetInvincibility(true);
        await Task.Delay(5000);
        _playerController.SetInvincibility(false);
    }
}

However, you soon discover that interacting with the existing PlayerController script is not as straightforward as you thought. This script depends on Unity's physics system, making it challenging to test in isolation. When trying to verify the behavior of the PowerUpController through automated testing, difficulties arise again due to these interactions with physics, which cannot be easily mocked or simulated.

As more features are added, and the game becomes more intricate, manual testing becomes increasingly time-consuming and prone to human error. Bugs related to the interactions between different scripts, such as the PowerUpController and PlayerController may go unnoticed until playtesting, leading to a frustrating development experience and potentially jeopardizing the project's success.

Tips to make your logic more testable

  • Instead of an all-or-nothing mindset, make a habit of writing automated tests for critical things. This will help nudge your designs towards testable behaviors, and these can be tested more extensively later if necessary.

  • If a class relies heavily on Unity engine features, consider creating wrappers with interfaces that you can replace for test doubles when needed.

  • Consider using the Humble Object pattern to segregate your business logic from engine code, making it a lot simpler to set up in tests.

  • Avoid singletons. If you are using dependency injection, consider registering a service instead, so it can be replaced with a test double when needed.

  • Check out Test Desiderata for a great overview of tradeoffs and valuable properties in automated tests.

In conclusion

Navigating the Unity software design landscape can be a difficult task, but understanding and avoiding common anti-patterns will lead to faster development progress and more maintainable projects.

Excessive use of global state leads to coupling and race conditions, making it hard to maintain and extend your app. By limiting global state exposure and embracing encapsulation principles, you can achieve a more modular and robust design.

Weak dependency management can introduce hidden links, causing runtime issues and restricting the flexibility of your codebase. Embracing explicit dependency declarations or utilizing dependency injection frameworks can help untangle these complexities.

Hard-to-verify behavior, arising from tightly coupled scripts and a heavy reliance on platform features, can become a testing nightmare. By prioritizing automated testing, segregating business logic from engine code, and employing designs like the Humble Object pattern, you can make your components more testable and easier to maintain.

In my view, understanding and addressing these anti-patterns is key to maintaining a well-organized and stable project. By employing best practices, embracing modular design, and prioritizing automated testing, you can create harmony in your Unity software designs, setting the stage for successful and enjoyable development experiences.