Skip to main content

Dependency Injection (Zenject)

Source Tier Only

This page documents internal engine code available exclusively to Source tier licensees.

MCE uses Zenject (via the Extenject fork) for dependency injection. This page covers the conventions, patterns, and all of the engine's installers so you can extend the DI graph with your own systems.

Why Zenject?

MCE is a large, modular engine where systems like battles, monsters, saves, audio, and UI need to reference each other without hard coupling. Zenject provides:

  • Constructor injection for MonoBehaviours (via [Inject] on Construct()) without relying on FindObjectOfType or singletons.
  • ScriptableObject-based installers that can be configured in the Inspector and swapped between projects.
  • Factory pattern for runtime prefab instantiation with automatic injection.
  • Scoped containers (ProjectContext vs SceneContext) so global systems and per-scene systems coexist cleanly.

ProjectContext

MCE's global bindings live on the ProjectContext prefab at Assets/Resources/ProjectContext.prefab. Zenject automatically loads this prefab before any scene runs. All ScriptableObjectInstaller assets referenced on this prefab are installed once and persist for the entire application lifetime.

This is where the engine's core systems are bound: the monster database, save system, audio, input, rendering, battle launcher, evolution manager, localization, and more.

The Installer Pattern

MCE uses ScriptableObjectInstaller subclasses almost exclusively. Each installer is a ScriptableObject asset (created via CreateAssetMenu) that holds serialized references to the systems it installs.

Base Convention: LazySingletonScriptableInstaller

Most MCE installers extend a convenience base class:

public class LazySingletonScriptableInstaller<T> : ScriptableObjectInstaller
where T : ScriptableObject
{
[SerializeField]
protected T Reference;

public override void InstallBindings()
{
Container.QueueForInject(Reference);
Container.Bind<T>().FromInstance(Reference).AsSingle().Lazy();
}
}

This does three things:

  1. Queues the ScriptableObject for injection so its own [Inject] fields are resolved.
  2. Binds it as a singleton so any class that declares [Inject] private T myRef; receives the same instance.
  3. Marks the binding as .Lazy() so the object is not eagerly resolved at container build time.

There is also a LazySingletonMonoInstaller<T> variant for MonoBehaviour-based systems.

The .AsSingle().Lazy() Convention

Every MCE binding uses .AsSingle().Lazy(). Here is why:

  • .AsSingle() -- Ensures exactly one instance exists per container. This prevents accidental duplicate managers.
  • .Lazy() -- Defers resolution until first use. Since MCE has 20+ installers, eager resolution would cause initialization order issues where a system tries to use another system that has not finished setting up yet.

All MCE Installers

Here is the complete list of installers in the engine:

Core Runtime Installers

InstallerLocationWhat It Binds
GameFlowInstallerRuntime/GameFlow/NewGameInitializer, PlayerTeleporter, TimeManager
MonsterDatabaseInstallerRuntime/MonsterDatabase/DependencyInjection/MonsterDatabaseInstance, ExperienceLookupTable
PlayerDataInstallerRuntime/Player/Roster, Bag, MonsterStorage, Dex, CharacterData, GlobalGameData, PlayerSettings, QuestManager
BattleLauncherInstallerRuntime/Battle/IBattleLauncher (via BattleLauncher)
SinglePlayerPlayerControlManagerInstallerRuntime/Battle/PlayerControl/Battle player control manager
SavegameInstallerRuntime/Saves/SavegameManager, ISerializer<string> (via MCESavesSerializer)
DialogManagerInstallerRuntime/UI/Dialogs/DialogManager.Factory
PlayerCharacterInstallerRuntime/Characters/PlayerCharacter.Factory
EvolutionManagerInstallerRuntime/Monster/Evolution/EvolutionManager
HatchingManagerInstallerRuntime/Monster/Breeding/HatchingManager
TradeManagerInstallerRuntime/Monster/Trade/TradeManager
InputManagerInstallerRuntime/Input/IInputManager
ConfigurationManagerInstallerRuntime/Configuration/IConfigurationManager
AudioSettingsInstallerRuntime/Audio/Audio settings manager
RenderingInstallerRuntime/Rendering/RenderingManager
UIElementsInstallerRuntime/UI/UI element references
MapSceneLauncherInstallerRuntime/UI/Map/Map scene launcher
GlobalGridManagerInstallerRuntime/World/GlobalGridManager
GridInstallerRuntime/World/GridController
SceneInfoInstallerRuntime/World/SceneInfo
TileDataInstallerRuntime/World/TileData
WorldDatabaseInstallerRuntime/World/WorldDatabase

Foundation Installers

InstallerLocationWhat It Binds
AudioInstallerFoundation/Runtime/Audio/IAudioManager
LocalizationInstallerFoundation/Runtime/Localization/ILocalizer
SceneManagerInstallerFoundation/Runtime/SceneManagement/ISceneManager
ConfigurationManagerInstallerFoundation/Runtime/Configuration/Foundation-level config
EventBusInstallerFoundation/Runtime/Events/Event bus system

Injection Patterns

Pattern 1: [Inject] on Construct() (MonoBehaviours)

This is the preferred pattern for MonoBehaviours. Zenject calls Construct() automatically after the object is created:

public class MCEInitialization : OpenMonBehaviour<MCEInitialization>
{
[Inject]
public void Construct(DialogManager.Factory dialogManagerFactory,
RenderingManager renderingManager,
MonsterDatabaseInstance monsterDatabase,
WorldDatabase worldDatabase,
ILocalizer localizer) =>
StartCoroutine(Initialize(dialogManagerFactory,
renderingManager,
monsterDatabase,
worldDatabase,
localizer));
}

Why Construct() over field injection:

  • Explicit dependencies -- You can see everything the class needs in one method signature.
  • Immutability -- Store injected references in private readonly fields; they cannot be accidentally reassigned.
  • Testability -- You can call Construct() directly in unit tests with mock objects.

Pattern 2: [Inject] Field Injection (ScriptableObjects)

ScriptableObjects that are QueueForInject'd use field injection since they do not have a Construct() lifecycle:

public class EvolutionManager : OpenMonScriptable<EvolutionManager>
{
[Inject] private ILocalizer localizer;
[Inject] private ISceneManager sceneManager;
[Inject] private IInputManager inputManager;
[Inject] private GlobalGridManager globalGridManager;
[Inject] private TimeManager timeManager;
[Inject] private MCESettings settings;
[Inject] private MonsterDatabaseInstance database;
}

The installer must call Container.QueueForInject(reference) before binding for this to work.

Pattern 3: Factory Pattern (Runtime Instantiation)

For prefabs that need to be instantiated at runtime with full DI, MCE uses Zenject's PlaceholderFactory:

// In DialogManagerInstaller:
Container.BindFactory<DialogManager, DialogManager.Factory>()
.FromComponentInNewPrefab(Prefab);

// In PlayerCharacterInstaller:
Container.BindFactory<PlayerCharacter, PlayerCharacter.Factory>()
.FromComponentInNewPrefab(Prefab)
.AsSingle()
.Lazy();

Any class can then inject the factory and create instances:

[Inject]
public void Construct(DialogManager.Factory dialogManagerFactory)
{
DialogManager dialog = dialogManagerFactory.Create();
// 'dialog' has all its [Inject] fields resolved automatically
}

Pattern 4: Conditional Injection with WhenInjectedInto

PlayerDataInstaller restricts player data bindings to classes that implement IPlayerDataReceiver:

Container.Bind<Roster>()
.FromInstance(PlayerRoster)
.AsSingle()
.WhenInjectedInto<IPlayerDataReceiver>()
.Lazy();

This prevents accidental injection of player-specific data into systems that should not depend on it.

Pattern 5: Interface Binding

When systems have an abstraction layer, the installer binds the interface to the concrete instance:

// BattleLauncherInstaller
Container.Bind<IBattleLauncher>().FromInstance(Reference).AsSingle().Lazy();

Consumers inject the interface, not the concrete class, enabling substitution for testing or online play.

Creating Your Own Installer

Step 1: Create the ScriptableObject Installer

using UnityEngine;
using OpenMon.Foundation.DependencyInjection;

[CreateAssetMenu(menuName = "MCE/Dependency Injection/MySystemInstaller",
fileName = "MySystemInstaller")]
public class MySystemInstaller : LazySingletonScriptableInstaller<MySystem>
{
// If your system needs additional bindings, override InstallBindings:
public override void InstallBindings()
{
base.InstallBindings(); // Binds MySystem as singleton

// Add extra bindings here
Container.Bind<IMyService>().To<MyServiceImpl>().AsSingle().Lazy();
}
}

Step 2: Create the Installer Asset

Right-click in the Project window and use your CreateAssetMenu path to create the asset. Assign the ScriptableObject references in the Inspector.

Step 3: Register on ProjectContext

  1. Open Assets/Resources/ProjectContext.prefab.
  2. Select the ProjectContext component.
  3. Add your installer asset to the ScriptableObjectInstallers list.

Step 4: Inject in Your Classes

public class MyConsumer : OpenMonBehaviour<MyConsumer>
{
[Inject]
public void Construct(MySystem mySystem, IMyService myService)
{
// Ready to use
}
}

Common Pitfalls

ProblemCauseSolution
ZenjectException: Unable to resolveBinding missing or installer not on ProjectContextCheck the installer is added to ProjectContext and the type matches exactly
NullReferenceException on [Inject] fieldQueueForInject not called for the ScriptableObjectAdd Container.QueueForInject(reference) before the .Bind() call
Object injected too earlySystem uses injected dependency in Awake()Move logic to Construct() or Start(); Zenject injects after Awake()
Duplicate binding errorSame type bound in two installersUse .WhenInjectedInto<>() to scope, or bind to an interface
Factory creates objects without injectionUsed Instantiate() directlyUse the Zenject factory pattern (BindFactory + Factory.Create())

Testing with Mock Containers

For unit tests that need DI, create a DiContainer manually:

[Test]
public void MySystem_ShouldDoSomething()
{
var container = new DiContainer();
var mockLocalizer = Substitute.For<ILocalizer>();

container.Bind<ILocalizer>().FromInstance(mockLocalizer);
container.Bind<MySystem>().AsSingle();

var system = container.Resolve<MySystem>();
// Assert behavior
}

For integration tests that need the full MCE container, use [UnityTest] with a scene that has the ProjectContext configured.