Dependency injection (DI) is a technique often used to write classes that aren’t specifically tied to the source of their dependencies. This sounds complicated, but it essentially means that instead of your script grabbing its dependencies, the dependencies are given to (injected into) your script.
There are many ways of implementing this to benefit our architecture, some more complicated than others. But to understand this, let’s first start with the purpose of DI, and how it can benefit us in Unity
⚠️ Disclaimer
Admittedly, a DI-based architecture is a bit advanced. It is, however, useful in some scenarios, which is why I’m including it here for those who’d be interested. Don’t feel bad if it’s not very clear to you. More often than not, you don’t need to implement injectable features.
The purpose
What makes DI a very popular paradigm in various software domains, is that it allows you to write features that can be tested easily. If your feature grabs its values, then we might have a hard time when we want to test specific values. With DI, the feature expects to be given said values, so we can inject it with whatever values we want to test it with.
In Unity, we can utilize this to write systems that are loadable in different contexts. You can, for example, load a system and inject it with a specific state to replicate a certain bug (E.g. can still buy items even if slots are full).
I’ve personally implemented a DI architecture before where loading my systems in a specific context (gameplay, level editor, test data) was possible just based on the scene I was loading them from.
Characteristics
Simple inspector setups
References are injected, resulting in a simple inspector setup. Coupled with a layered architecture, these references can then be injected into the layers below.
Complete control over state and context
Your features don’t care about context. They’re typically written in a way where you can reuse them in multiple contexts. This allows for fast testing by fast-forwarding in applications that are very state-focused (E.g. when you need to skip a lot of dialogues, or have a bug at a specific sequence).
Clean persistence
By employing a DI paradigm, data can easily persist between scenes in a predictable manner without utilizing DontDestroyOnLoad or similar approaches
Criteria
Feature placement
This will heavily depend on implementation, but it’s often used with layered architecture
Reference handling
Important references are injected and can be injected through your hierarchy
Scalability
For scenarios where you have a lot of data and a few systems, utilizing DI can be pretty powerful. If you can’t justify it, however, then it can quickly be considered over-engineering that might hinder your scalability.
Being able to inject the state with custom data, for example, would make development of your card game a lot easier, as you can hit play into a specific configuration to test. But it’s hard to justify it for a racing game.
Let’s see it in action
Imagine we’re making an RPG game. In this game, the player could either be in the main gameplay, a shop, a battle sequence, or a cutscene.
We want to make our setup robust, so we make this crazy decision: every map is a prefab!
Basically, we want to keep our generic setup present in every scenario, so every “system” from the previously mentioned ones will live in its own scene. When it’s time to load map A, which we prepared as a prefab, we’ll load it through addressables (If you’re not familiar with addressables, it’s a way to load a prefab with an address/ID).
To control all of this, we’ll have a launch scene. We have a “GameController” in this scene, responsible for managing loading and initializing the proper system/data. It also has a generic setup, and it injects it (along with other data) into the system script that resides in the new scene. Let’s say these systems present in each scenes inherit from base class GameSystem.
So when we launch our game (from scene launch_scene):
- GameController initializes
- Configured to load map with address start_map and game system MapGameSystem
- Loads the MapGameSystem scene additively
- Uses FindObjectOfType<GameSystem>() and initializes (injects) it with references from the generic setup (E.g. camera system, save manager, sound manager, etc), and also with relevant data to this run, and also loads the map in that scene
When this finishes loading, it might look like this:
What just happened?
We’ll break this down in code. But basically, the GameController loaded the appropriate map game system scene, initialized the GameSystem with the references it needs, then spawned the first map.
What happens next?
When the player enters a shop, an event will be fired. This will tell the GameController that the game system ended, and it should next load the shop game system, with the matching prefab ID for the shop.
Implementing GameSystem
Here’s an example of how our base GameSystem class might look like
public abstract class GameSystem : MonoBehaviour
{
// When this GameSystem wants to finish, invoke this event
// GameSystemResult is just a class reporting where to go next
public event Action<GameSystemResult> OnFinished;
public GameSystemArgs Arguments { get; private set; }
// Initialize = Inject
public virtual void Initialize(GameSystemArgs args)
{
Arguments = args;
}
// Cleanup when unloading
public virtual void Terminate()
{
}
// Call this to finish and move to another GameSystem
public void Finish(GameSystemResult result)
{
OnFinished?.Invoke(result);
Terminate();
}
}
So after GameController finds the GameSystem in this scene, it’ll call Initialize with the arguments, effectively injecting all the references/data. We’ll talk about GameSystemArgs next, but basically, since we could add more arguments later, instead of going back and modifying that in every GameSystem, I simply made a wrapper class.
When the GameSystem is done and the game should go somewhere else (e.g. a specific shop), Finish(result) is called. The GameSystemResult is just an object passed back to the game controller to let it know which room and game system it should load next (in this example, say shop_12 and shop_game_system)
The GameSystem then invokes OnFinished, and passes back the GameSystemResult to our GameController, which will unload this GameSystem and loads the next one based on the result object it received.
Let’s take a look at an example of the arguments we could be passing to our game system
public class GameSystemArgs
{
public CameraController Camera { get; set; }
public InputSystem Input { get; set; }
public SoundSystem Sound { get; set; }
public GameConfig GameConfig { get; set; }
public DataService DataService { get; set; }
}
As you can see, it’s just the necessary references. The DataService contains a map of keys for each item the player has, which doors are unlocked, events that happened so far, etc.
public class GameSystemResult
{
public string RoomName { get; set; }
public GameSystemType GameSystemType { get; set; }
}
The GameSystemResult returned from our GameSystem simply just indicates what room to load next and the system type for it.
By inheriting from GameSystem, you can have MonoBehaviours that the GameController can manage.
Implementing GameController
The GameController is going to be an ordinary class that has references to the generic setup in the launch scene. The most important bit probably happens in the method that loads and injects into a game system.
Here’s our coroutine for doing so, along with comments.
private IEnumerator LoadGameSystemAsync(string roomName, GameSystemType gameSystemType)
{
// Cache the previous game system so we can unload it if available
GameSystem prevGameSystem = currentGameSystem;
// Get the desired scene name. This is stored in our config based on the type.
var sceneName = gameConfig.GetSceneName(gameSystemType);
// Unload the previous game system
if (prevGameSystem != null)
{
yield return UnloadGameSystem(prevGameSystem);
}
// Load the new game system's scene additively
yield return SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
// Set active scene to new game system scene so we can start operating on it
SceneManager.SetActiveScene(SceneManager.GetSceneByName(sceneName));
// Find the new game system from the loaded scene
currentGameSystem = FindObjectOfType<GameSystem>();
// No game system = something must be wrong
if (currentGameSystem == null)
{
Debug.LogError("No GameSystem instance found in this scene");
yield break;
}
// Listen to when the new game system is finished
currentGameSystem.OnFinished += CurrentGameSystemFinishHandler;
// New arguments object
var gameSystemArgs = new GameSystemArgs()
{
GameConfig = gameConfig,
Input = input,
Camera = camera,
DataService = dataService,
Sound = sound
};
// Inject the arguments to our new game system
currentGameSystem.Initialize(gameSystemArgs);
// Wait until room is loaded
yield return LoadRoomAsync(roomName);
}