Zenjex EN - antonprv/LoneBrawler GitHub Wiki
GitHub: antonprv/Zenjex · Docs: antonprv.github.io/zenjex-website
- What is Zenjex
- Core Concepts
- Injection Passes
- ZenjexBehaviour
- Binding API
- Scene-Scoped Containers
- IInitializable
- Runtime Instantiation
- Editor Debugger
Zenjex is a custom DI framework written from scratch for this project and later open-sourced. It sits on top of the Reflex container and adds a Zenject-style API plus Unity-specific injection mechanics that Reflex doesn't cover natively:
- Field, property, and method injection via the
[Zenjex]attribute - Three-pass automatic scene injection with deduplication
-
ZenjexBehaviourbase class - guaranteed injection beforeAwake -
IInitializablelifecycle hook -
ZenjexSceneContextfor scene-scoped sub-containers - Built-in editor debugger
The framework ships as a separate Unity package and is reused across projects.
Put [Zenjex] on any private readonly field, property, or method to request injection:
public class PlayerMove : ZenjexBehaviour
{
[Zenjex] private readonly IInputService _input;
[Zenjex] private readonly ITimeService _time;
[Zenjex] private readonly IPlayerDataSubervice _playerData;
protected override void OnAwake()
{
// _input, _time, _playerData are injected here
}
}ZenjexInjector caches the reflection scan per type in Dictionary<Type, TypeZenjexInfo>, so GetFields, GetProperties, and GetMethods run exactly once per type over the application's lifetime.
RootContext is a static facade over ProjectRootInstaller.RootContainer:
RootContext.Runtime // live Container - for post-init bindings
RootContext.HasInstance // check before resolving
RootContext.Resolve<T>() // typed resolve
RootContext.Resolve(Type) // untyped resolve (used by ZenjexInjector internally)ZenjexRunner hooks into three events at [RuntimeInitializeOnLoadMethod(BeforeSceneLoad)] and handles all scene-level injection automatically.
Fires synchronously inside ProjectRootInstaller.Awake() at ExecutionOrder -280. By the time any other Awake() in the scene runs (execution order above -280), ZenjexRunner has already walked every root GameObject in every loaded scene and filled all [Zenjex] members.
This is the standard path for the vast majority of components.
Fires after InstallGameInstanceRoutine() completes and LaunchGame() has been called. Covers objects that depend on bindings added during async setup - for example, services registered only after Addressables finish loading.
Fires for scenes loaded additively after launch. Unity has already called Awake() on those objects by this point, so injection happens after Awake(). ZenjexRunner prints a ZNX-LATE warning to the console for each affected component - a signal to switch to ZenjexBehaviour.
Each injected instance is stored by GetInstanceID() in a HashSet<int>. Before injecting, ZenjexRunner checks the set - if the ID is already there, the object is skipped. ZenjexBehaviour.Awake() calls ZenjexRunner.MarkInjected(this) to pre-register itself, so no subsequent pass touches it again.
Pass 1 injects: Component A (plain MonoBehaviour)
ZenjexBehaviour.Awake injects: Component B -> MarkInjected(B)
Pass 2 injects: Component C (depends on late binding)
Component B -> skipped (already in HashSet)
Pass 3 injects: Component D (additive scene) -> ZNX-LATE warning
ZenjexBehaviour is the recommended base class for any MonoBehaviour that uses [Zenjex]. It runs at ExecutionOrder -100 - later than ProjectRootInstaller at -280 but ahead of the default 0, so the container is always populated when it ticks.
[DefaultExecutionOrder(-100)]
public abstract class ZenjexBehaviour : MonoBehaviour
{
private void Awake()
{
if (RootContext.HasInstance)
{
ZenjexInjector.Inject(this);
ZenjexRunner.MarkInjected(this);
}
OnAwake();
}
protected virtual void OnAwake() { }
}Override OnAwake() instead of Awake() - every [Zenjex] field is guaranteed non-null by the time OnAwake() runs.
For plain MonoBehaviour classes not inheriting ZenjexBehaviour, Pass 1 handles injection when the component is in the scene at startup. For dynamically created ones, call ZenjexRunner.InjectGameObject(go) right after Instantiate.
Bindings go into ProjectRootInstaller.InstallBindings(ContainerBuilder builder) through the fluent BindingBuilder<T>.
// Interface -> concrete type, lazy singleton
builder.Bind<ISaveLoadService>()
.To<SaveLoadService>()
.AsSingle();
// Eager singleton - created when the container builds, not on first resolve
builder.Bind<IGameStateMachine>()
.To<GameStateMachine>()
.BindInterfacesAndSelf()
.AsEagerSingleton();var config = Resources.Load<GameConfig>("GameConfig");
builder.Bind<GameConfig>()
.FromInstance(config)
.AsSingle();builder.Bind<CameraFollow>()
.BindInterfacesAndSelf()
.FromComponentInNewPrefab(cameraFollowPrefab)
.WithGameObjectName("Camera")
.UnderTransformGroup("Infrastructure")
.NonLazy();UnderTransformGroup creates a named parent GameObject and moves the instantiated prefab under it, keeping the scene hierarchy tidy.
Some services need both container-resolved dependencies and explicit values - say, a reference to a spawned prefab. WithArguments handles the explicit side; the rest come from the container:
builder.Bind<InputService>()
.BindInterfacesAndSelf()
.WithArguments(playerInput, cinemachineProvider)
.AsSingle();CopyIntoDirectSubContainers() marks a binding as scoped - each scene sub-container built by SceneInstaller gets its own instance:
builder.Bind<LevelProgressWatcher>()
.BindInterfacesAndSelf()
.CopyIntoDirectSubContainers()
.AsSingle();| Method | Lifetime | Resolution |
|---|---|---|
AsSingle() / AsSingleton()
|
Singleton | Lazy |
NonLazy() / AsEagerSingleton()
|
Singleton | Eager |
AsTransient() |
Transient | New instance per resolve |
CopyIntoDirectSubContainers().AsSingle() |
Scoped | Lazy per sub-container |
After the container is built, RootContext.Runtime lets you register values found at runtime:
// Inside InstallGameInstanceRoutine, after Addressables finish:
RootContext.Runtime.RegisterValue(loadedConfig, new[] { typeof(ILevelConfig) });ZenjexSceneContext keeps a Dictionary<int, Container> keyed by Scene.handle. SceneInstaller - a MonoBehaviour in each gameplay scene - builds a sub-container that inherits from the root and registers it:
// From any global service or state machine state:
var sceneContainer = ZenjexSceneContext.GetActive();
var watcher = sceneContainer.Resolve<LevelProgressWatcher>();
// Or in one call:
ZenjexSceneContext.Resolve<LevelProgressWatcher>();On scene unload, SceneInstaller.OnDestroy() calls ZenjexSceneContext.Unregister(scene), which disposes the sub-container and releases all scoped instances.
Services that need to run code after injection but before LaunchGame() implement IInitializable:
public class GameStateMachine : IInitializable
{
private readonly StateFactory _stateFactory;
public GameStateMachine(StateFactory stateFactory) =>
_stateFactory = stateFactory;
public void Initialize() => EnterState<BootstrapperState>();
}For ProjectRootInstaller to discover the service, it must expose IInitializable as a contract:
builder.Bind<GameStateMachine>()
.BindInterfacesAndSelf() // registers IInitializable, IGameStateMachine, GameStateMachine
.AsEagerSingleton();ProjectRootInstaller calls container.All<IInitializable>() and iterates through the results, calling Initialize() on each. Order within the group follows registration order.
For GameObjects created after launch via Instantiate(), call ZenjexRunner.InjectGameObject(go) right after:
var enemy = Object.Instantiate(enemyPrefab);
ZenjexRunner.InjectGameObject(enemy);This walks all MonoBehaviour components in the hierarchy, including inactive ones, and injects those with [Zenjex] members. Components already in the _injected set are skipped. GameFactory calls this internally after every InstantiateAsync, so individual systems don't need to do it themselves.
Open via Window -> Analysis -> Reflex Debugger.
Reflex tab - tree view of all bindings in the root container, filterable by type name. Good for checking that a service is registered under the right interface.
Zenjex tab - table of every injected object with columns for type name, GameObject name, scene, injection pass, and a late-injection flag. A search field filters both the type list on the left and the records on the right. ZNX-LATE rows are highlighted, making it straightforward to spot components that need to be switched to ZenjexBehaviour.