Dependency Injection for Games

filippoceffa

Filippo Ceffa

Posted on September 11, 2023

Dependency Injection for Games

Whether you are a game programmer or a game engine developer, a well-organized architecture is crucial to keep your project maintainable, readable, and safe— especially if you work with a team on a large codebase.

This article aims to present the concept of Dependency Injection, and to show how it can help you to easily structure your game architecture in a robust and flexible way, without any performance overhead.

Using a dummy game application as a practical example, we will start by defining the problem we intend to solve. We will try different strategies, and demonstrate how Dependency Injection emerges as the ideal solution.

We will use C++, the go-to language for high-performance applications, but the ideas discussed are universal, and applicable in other languages.

The code presented in this article is available on GitHub.

Problem Definition

Games are complex applications, consisting of multiple unique systems, such as Rendering or Physics. In a typical game application, systems are initialized on startup, they interact with each other for the lifetime of the application, and are destroyed when the application terminates.

Some systems are specific to the game being developed, while others are more general, and are typically wrapped into a game engine:

Image description

In our discussion we will not consider this separation, and refer to any system in the application as a game system.

Let’s now introduce the example that will accompany us for the rest of this article — a dummy 2D game application where two red circles bounce around the screen. Our game consists of just a handful of systems:

Image description

  • State holds the state of the game: the circle positions and velocities.

  • Simulation moves circles in the update() method, modifying State.

  • Physics is used by Simulation in update() to resolve circle collisions.

  • Rendering reads State, and draws circles on screen with render().

  • OpenGL wraps the OpenGL API, required by Rendering to render().

  • Loop runs the game loop, which calls update() and then render().

Let’s identify the dependencies between our systems:

Image description

The design of our application is nicely represented by a graph, where nodes represent systems, and edges represent dependencies. In particular, this is a directed acyclic graph, where there is no circular dependency.

Now that we have a solid theoretical understanding of how to organize our architecture, it is time to introduce the concrete problem at the core of this article: how do we turn this theory into practice?

We wish to find a way to accurately express our theoretical architecture in code, with a focus on clarity, safety, modularity and performance.

Instead of trying to derive our solution from these high-level goals, in the next section we will proceed bottom-up, and dive into implementation. As we try different strategies, we will get a practical understanding of what problems we wish to avoid, and we will define rules that we wish to follow.

Eventually, we will converge on an ideal solution: Dependency Injection.


Further reading:
For simplicity, we ignore the situation where systems implement and depend on interfaces, quite common in real game applications. If you are interested to know more, refer to the appendix on Dependency Inversion after finishing this article.

Problem Solution

Attempt 1: Systems are global variables

Based on the problem definition, we know that game systems must be unique, created at startup, and destroyed at shutdown. These requirements are satisfied by creating a global variable for each system:

// State.h
class State{...};     // class definition
extern State g_state; // global variable declaration

// State.cpp
State g_state{};      // global variable definition
Enter fullscreen mode Exit fullscreen mode

Systems access dependencies directly from global state:

#include <State.h>
Simulation::Simulation()
{
    g_state.read();
}
Enter fullscreen mode Exit fullscreen mode

One problem with global variables is that we do not control their construction order. If g_simulation happens to be constructed before g_state, the above code causes an uninitialized memory access.

We wish to have a way to guarantee that g_simulation is constructed after g_state. And, to avoid the same issue in the destructor, we also wish to guarantee that g_simulation is destroyed before g_state.

Let’s formalize our wish in a rule:

Rule 1: Systems must be created after / destroyed before dependencies.

Another problem with global variables is that their access is not limited to constructors — they can be accessed from anywhere:

void Simulation::update() 
{ 
    g_state.write();
}
Enter fullscreen mode Exit fullscreen mode

This freedom has a side effect — it becomes difficult to understand the dependencies of a system, because they are not listed in a single place.

Unclear dependencies make it hard to understand the architecture, to refactor, and to make changes. Let’s define a rule to prevent this situation:

Rule 2: Dependencies must be explicit.

The final issue with global variables is the potential to easily introduce circular dependencies between systems:

void Physics::foo() 
{
    g_rendering.bar();
}

void Rendering::foo()
{
    g_physics.bar();
}
Enter fullscreen mode Exit fullscreen mode

To ensure that our codebase remains modular and maintainable, we wish to forbid circular dependencies altogether:

Rule 3: Introducing circular dependencies must be prevented.

Attempt 2: Systems own dependencies

Let’s make systems responsible for managing their own dependencies.
The dependencies are member variables, created in the constructor:

 Loop::Loop()
 : m_simulation{} // dependency created here
 , m_rendering{}
 {}

Loop::~Loop(){} // dependency destroyed here
Enter fullscreen mode Exit fullscreen mode

Using constructor recursion, a system creates its entire dependency subgraph. Consequently, constructing the root system Loop triggers the creation of every system.

We construct our application by creating Loop on the stack of main():

 int main()
 {
     Loop loop{}; // recursively construct every system
 }
Enter fullscreen mode Exit fullscreen mode

A problem arises when multiple systems, like Simulation and Rendering, depend on the same system, such as State. Both Simulation and Rendering create separate copies of State, but systems must be unique.

To resolve this, we can create State within Loop, the parent system of Simulation and Rendering, and have Loop share State with their constructors by reference:

 Loop::Loop()
 : m_state{} // create here
 , m_simulation{m_state} // share reference
 , m_rendering{m_state} // share reference
 {}
Enter fullscreen mode Exit fullscreen mode

This solution patches the problem, but it introduces an extra dependency. We have failed to exactly map our theoretical architecture into code:

Image description

There is a fundamental problem with this approach that prevents us from converging on an ideal solution. In order to understand it, we should take a step back, and start thinking in terms of responsibilities.

Systems are burdened with two responsibilities:

  1. Functionality: executing the logic that defines system behavior.

  2. Structure: creating, destroying, organizing, passing dependencies.

The dependency graph should only reflect dependencies that occur due to functionality, such as Simulation depending on Physics for collision resolution. However, the need for organizing the application structure introduces additional dependencies, such as Loop depending on State.

To resolve this problem, we wish to follow the single responsibility principle, and give systems a single responsibility: functionality.

Rule 4: Systems must not be responsible to structure the application.

Successful attempt: Systems receive dependencies

In this final attempt, the responsibility for structuring the application is moved to main(), where every system is created in the correct order, and passed as dependency to other systems:

 int main()
 {
     State state{};
     Physics physics{};
     Simulation simulation {state, physics};
     OpenGL openGL{};
     Rendering rendering{state, openGL};
     Loop loop{simulation, rendering};  
 } // out of scope: system destructed in reverse order
Enter fullscreen mode Exit fullscreen mode

Systems are no longer responsible for structuring the application. They receive their dependencies through the constructor:

 Loop::Loop(Simulation& simulationRef, Rendering& renderingRef)
 , m_simulationRef{simulationRef}
 , m_renderingRef{renderingRef}
 {}
Enter fullscreen mode Exit fullscreen mode

This approach follows all our rules:

Rule 1: Systems must be created after / destroyed before dependencies.

✅ System is outlived by its dependency, as it is created later on the stack.

Rule 2: Dependencies must be explicit.

✅ The signature of the system constructor lists all dependencies.

Rule 3: Introducing circular dependencies must be prevented.

✅ It is impossible to order system creation to cause a cycle.

Rule 4: Systems must not be responsible to structure the application.

✅ Application structure is entirely determined in main().

Success!

We converged on a solution that precisely implements our theoretical architecture, while being simple, safe, modular and efficient.

As promised, this ideal solution is none other than an implementation of Dependency Injection.

Dependency Injection is a programming technique in which an object [..] receives other objects [..] that it depends on.
(Wikipedia)


Further reading:
In applications with a large number of systems, the cost to write and modify the code in main() is significant. If you wish to learn of an automated solution to reduce the boilerplate code, refer to the appendix on Injection Container.

Conclusions

I hope this article was able to make you excited about Dependency Injection, and its application in game development.

By adopting Dependency Injection, you will quickly realize how radically it improves your everyday game development experience:

  • You will easily understand, reason and discuss your architecture.

  • You will quickly refactor and make changes with peace of mind.

  • You will never worry about accessing uninitialized dependencies.

I suggest that you try adopting Dependency Injection in your own project, and I believe you will not be able to look back!

Next steps

💖 💪 🙅 🚩
filippoceffa
Filippo Ceffa

Posted on September 11, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related