Zenject Introduction
Zenject is what is called a Dependency Injection (DI) Framework, and Beat Saber's code uses it extensively. You can read more about DI on Microsoft's docs and on Wikipedia.
What Is Dependency Injection
Trying to explain dependency injection usually makes it sound a lot more complex than it is. In short, it's when you delegate the responsibility certain functionality in your code to "dependencies" and "injecting" them into objects upon their creation.
That is all DI is, but let's look at a simple C# example:
public interface IService
{
public int GetNumber();
}
internal class ServiceImplementation : IService
{
public int GetNumber()
{
// Implement this method
}
// Some other private behaviour
}
Now, we have an interface that provides the result of GetNumber()
. Let's say we needed this behaviour in another object:
internal class SomeObject
{
private readonly IService service;
private readonly List<int> numbers = [];
public SomeObject(IService service)
{
this.service = service;
}
void Update()
{
if (numbers.Count > 5)
{
numbers.Clear();
}
int number = _service.GetNumber();
numbers.Add(number);
}
}
As we can see, when we create SomeObject
we have to provide an instance of IService
because it depends on the service. The field _numbers
is not a dependency in this case; it is just data that belongs to SomeObject
.
This is essentially all you need to know to understand dependency injection, but the Zenject README goes a bit more in-depth about the what and why of DI.
By using dependency injection, you are able to more easily define the behaviour that each feature needs. If you need to make changes in the future, your code will have enough abstractness that you should not have to go into every part of the code to make everything work together.
What Is Zenject
Now that you have some idea of what DI looks like, all Zenject does is makes the process of maintaining DI easy. Zenject has a lot of different features but it would be pointless to cover them all here, but you can always check the GitHub README to learn more about all of its features.
Zenject lets create us objects by declaring their "contract binding" in what they call an Installer
. We can give keys to dependencies, we can provide specific methods to create objects, we can declare multiple implementations of the same interface, and more.
Using Zenject In Mods
In order to easily access the game's implementation of Zenject, we use a library called SiraUtil. This is used in a wide variety of mods and it allows us to take full advantage of dependency injection without much extra effort.
Before doing anything, add an assembly reference to SiraUtil
, Zenject
, and Zenject-usage
. Make sure you add SiraUtil
as a dependency in your plugin metadata.
Implementing Zenject
First, add a Zenjector
param to your plugin class [Init]
method:
[Init]
public Plugin(Zenjector zenjector)
{
}
The Zenjector
will allow you to access the game's Installer
s and let you make your own bindings with them.
Let's now look at the class we will be using to test Zenject:
internal class Test : IInitializable
{
private readonly SiraLog log;
public Test(SiraLog log) { this.log = log; }
public void Initialize() => log.Info("Initializable test");
}
We pass a SiraLog
instance to this object in the constructor. This is a service provided by SiraUtil, and acts as an instance-based logger.
This class implements IInitializable, which is an interface provided by Zenject. The Initialize()
method gets called after all objects have been created, and on Unity's Start event. This is ideally where initialization logic for your object would go. In this test case, all it does is log a message when created.
Let's make a binding to test this behaviour - we provide an installer and use the callback with the DiContainer
to make a binding:
public Plugin(Zenjector zenjector)
{
zenjector.Install<StandardGameplayInstaller>(container =>
{
container.Bind<IInitializable>().To<Test>().AsSingle();
});
}
What we are doing here is binding Test
with its IInitializable
interface on the DiContainer for the StandardGameplayInstaller
. The AsSingle
method ensures only one instance of Test
can be bound.
If you build this now and play any map in solo, you will see the "Initializable test" message appear in the console when the scene transition ends.
However, the SiraLog
that we used doesn't have a base logger to use, so the source appears as ???
. In order to fix this, we can just provide the Zenjector
with IPA's logger:
zenjector.UseLogger(logger);
Cleaning Up
It's recommended to organize your bindings in your own installers. Create an installer, and override the InstallBindings()
method:
internal class TutorialInstaller : Installer
{
public override void InstallBindings()
{
Container.BindInterfacesTo<Test>().AsSingle();
}
}
We have also made use of the BindInterfacesTo method here, which is just a shortcut so you don't have to remember what interfaces your type implements. It is good to know the full expression in case you want to make it clear that you are implementing an interface that will be used as a dependency throughout your code.
Now, we just specify the installer to the Zenjector
with either a base installer to install upon, or by using the location
enum argument to specify a common location:
public Plugin(Zenjector zenjector, IPALogger logger)
{
zenjector.UseLogger(logger);
zenjector.Install<TutorialInstaller>(Location.StandardPlayer);
}
By doing this we have made the Plugin
class just responsible for defining the contexts in which the plugin operates in, whilst the installers declare the interface of the code.
Types Of Injection
So far we've only covered injecting dependencies through a constructor, however, there are multiple ways to achieve this goal with Zenject.
Constructors
As covered before, constructor injection is the main form of injection. They force the dependencies to only be resolved at object creation, the dependencies are immediately apparent, and they guarantee no circular dependencies which encourages better design.
internal class SomeObject
{
private readonly IService service;
public SomeObject(IService service) { this.service = service; }
}
internal record SomeOtherObject(IService Service);
Unfortunately, MonoBehaviours cannot have constructors, so you are left with method and field injection for those.
Methods
The Inject
attribute can be used on methods, and with it we can treat methods just like constructors by supplying the dependencies in the params for the method.
internal class SomeBehavior : MonoBehaviour
{
private IService service = null!;
[Inject]
public void Init(IService service) { this.service = service; }
}
As you can see, this example is using a MonoBehaviour
. Since MonoBehaviours cannot have constructors, this is the preferred way to do injection on them. It looks a lot like a constructor which makes the intention of this code slightly more clear. That being said, you can use field injection on MonoBehaviours too.
A problem with this approach is that you can't make the field readonly. This can make the code's intent less clear, as a field that isn't readonly implies it might be open to changing; you usually aren't going to be changing the value of dependencies.
Fields And Properties
Field and property injections occur directly after the constructor finishes. This is achieved by adding [Inject]
to any field or property.
internal class SomeBehavior : MonoBehaviour
{
[Inject]
private readonly IService service = null! // assigned by Zenject
}
Since Zenject uses reflection to set these fields, you can make them private and readonly. This is great for demonstrating the intention of the code, but field injection can look a bit cryptic for others looking at the code.
Common DiContainer Methods
There are dozens of methods to create a binding as seen in the documentation, so let's highlight a few ways of creating bindings that you will be mostly using.
Name | Description |
---|---|
Bind<T> | Registers the type T for injection for itself and other types |
BindInstance | Registers the type of the provided existing object instance |
BindInterfacesTo<T> | Registers the interfaces for the type T for injection |
BindInterfacesAndSelfTo<T> | A combination of Bind<T> and BindInterfacesTo<T> |
AsCached | The same instance of the object will be reused |
AsSingle | The same as AsCached but ensures only one binding can be made for the result type |
AsTransient | Instances of the result type will not be reused; a new one will be created each time it's requested |
FromNewComponentOnNewGameObject | Create an empty GameObject and add a new component of the result type on it |
FromNewComponentAsViewController | Provided by SiraUtil; creates a new view controller - result type must inherit ViewController |
Zenject With UI
Once you have your SiraUtil setup, you can easily declare all menu-related code in a installer in the menu.
zenjector.Install<MenuInstaller>(Location.Menu);
Binding View Controllers
SiraUtil provides a way to create view controllers easily using FromNewComponentAsViewController
. You can also bind a flow coordinator, but since it is a MonoBehaviour
, you should use FromNewComponentOnNewGameObject
, or any compatible construction method.
internal class MenuInstaller : Installer
{
public override void InstallBindings()
{
Container.Bind<TutorialViewController>().FromNewComponentAsViewController().AsSingle();
Container.Bind<TutorialFlowCoordinator>().FromNewComponentOnNewGameObject().AsSingle();
Container.BindInterfacesTo<MenuButtonManager>().AsSingle();
}
}
Now, we would be able to inject our view controllers into the flow coordinator, and we can also inject the MainFlowCoordinator
to make use of it for the menu button.
Additionally, as seen before with the SiraLog
, we can use bindings made by other mods. Another case is the MenuButtons
class from BSML:
internal class MenuButtonManager : IInitializable
{
private readonly MenuButtons menuButtons;
private readonly MainFlowCoordinator mainFlowCoordinator;
private readonly TutorialFlowCoordinator tutorialFlowCoordinator;
private readonly MenuButton menuButton;
public MenuButtonManager(MenuButtons menuButtons, MainFlowCoordinator mainFlowCoordinator, TutorialFlowCoordinator tutorialFlowCoordinator)
{
this.menuButtons = menuButtons;
this.mainFlowCoordinator = mainFlowCoordinator;
this.tutorialFlowCoordinator = tutorialFlowCoordinator;
menuButton = new("Tutorial Mod", ShowFlowCoordinator);
}
public void Initialize()
{
menuButtons.RegisterButton(menuButton);
}
private void ShowFlowCoordinator()
{
mainFlowCoordinator.PresentFlowCoordinator(tutorialFlowCoordinator);
}
}
This seems more complex than it would be without Zenject, however, Zenject will call Initialize
for us on the first frame of the menu scene being loaded. Most importantly, this class is only responsible for doing one thing: managing the menu button.
Registering Custom Tags
If you have some custom UI tags that you want to use, it's recommended to bind them using Zenject. You would bind them like this in a menu installer:
Container.Bind<BSMLTag>().To<MyCustomTag>().AsSingle();
Container.Bind<TypeHandler>().To<MyCustomHandler>().AsSingle();
Affinity Patching
SiraUtil provides a way to make non-static Harmony patches using the "Affinity API". Being able to make patch methods not static lets you make use of dependency injection for your patches.
The syntax is mostly the same, however, Affinity is a lot more limited than Harmony. For the attributes, you must specify a AffinityPatch
attribute on every patch method, and you need to specify a patch type using either AffinityPostfix
, AffinityPrefix
, or AffinityTranspiler
. Do note - if you don't provide a patch type attribute then affinity will default to a postfix.
How To Affinity
Below is an example of an affinity patch taken from the SiraUtil documentation. It injects the PauseController
and causes the game to pause every 10 misses and cancels the miss by using a prefix.
internal class PauseOnXMisses : IAffinity
{
private readonly PauseController pauseController;
public PauseOnXMisses(PauseController pauseController)
{
this.pauseController = pauseController;
}
private int misses = 0;
[AffinityPrefix]
[AffinityPatch(typeof(ScoreController), nameof(ScoreController.HandleNoteWasMissed))]
private bool HandleNoteWasMissedPrefix(NoteController noteController)
{
if (noteController.colorType == ColorType.None && misses++ < 10)
{
return true;
}
pauseController.Pause();
misses = 0;
return false;
}
}
As you can see, you just need to add the IAffinity
interface to the patch class, then you need to bind it in a gameplay related installer so that you have access to the PauseController
.
Container.BindInterfacesTo<PauseOnXMisses>().AsSingle();
Affinity's Limitations
Affinity is maintained separately from Harmony, so it doesn't have nearly as many features as Harmony does.
The main problem is the timing of the patch. Your patch will only be effective after the object graph is constructed, so you can't patch Awake
methods or constructors, for instance.
Secondly, your patches will be unapplied automatically when the DiContainer it was bound to is disposed, but this should be fine in almost all cases.
Custom Sabers
SiraUtil provides a unified way to replace the vanilla saber model, such that mods do not fight over which saber model gets shown.
Registering A Saber Model
Create a class which inherits from a SaberModelController
, create the saber model registration, and bind it in a game installer. You will have to provide a priority too so SiraUtil can decide which registration to use when there are multiple.
internal class CustomSaberModelController : SaberModelController { }
var registration = SaberModelRegistration.Create<CustomSaberModelController>(0);
Container.BindInstance(registration).AsSingle();
Additional Interfaces
IColorable
will provide a property which receives a color when one is set by SiraUtil. This is primarily used by Chroma to set the color of sabers to the color of Chroma-colored notes.
internal class CustomSaberModelController : SaberModelController, IColorable
{
public Color Color { get; set; } // Add behaviour on the setter
}
IPreSaberModelInit
and IPostSaberModelInit
provide methods which will be called before and after the Init()
method of the SaberModelController
and also provide a reference to the original Saber
and saber parent Transform
.
The return type of PreInit()
is bool
, and it works just like Harmony prefixes; you should return true
if you want the original Init
to run, otherwise return false
.
internal class CustomSaberModelController
: SaberModelController, IPreSaberModelInit, IPostSaberModelInit
{
public bool PreInit(Transform parent, Saber saber) => true;
public void PostInit(Transform parent, Saber saber) { }
}
Object Redecorating
Similarly to registering saber models, SiraUtil provides a way to modify the prefabs for various GameObjects before they are bound in their installers.
As well as a priority, you can decide if it should be chained, which is useful if your redecoration doesn't causes conflicts. SiraUtil will start at the registration with the highest priority, and if it has chaining, it will continue to the next highest priority registration until it encounters a registration that doesn't have chaining.
The following example simply takes the GameObject
of the BombController
provided by the param of the BombNoteRegistration
, and adds a CustomBombBehaviour
to it.
var bombNoteRegistration = new BombNoteRegistration(
redecorateCall: bomb =>
{
bomb.gameObject.AddComponent<CustomBombBehaviour>();
return bombNoteController;
},
priority: int.MaxValue,
chain: true);
Container.RegisterRedecorator(bombNoteRegistration);
Below is a collection of all possible redecorators provided by SiraUtil as of v3.1.14.
Notes
Name | Backing Prefab Type |
---|---|
BasicNoteRegistration | GameNoteController |
ProModeNoteRegistration | GameNoteController |
BurstSliderHeadNoteRegistration | GameNoteController |
BombNoteRegistration | BombNoteRegistration |
BurstSliderNoteRegistration | BurstSliderGameNoteController |
LongSliderNoteRegistration | SliderController |
MediumSliderNoteRegistration | SliderController |
ShortSliderNoteRegistration | SliderController |
ConnectedPlayerNoteRegistration | MultiplayerConnectedPlayerGameNoteController |
Debris
Name | Backing Prefab Type |
---|---|
NormalNoteDebrisHDRegistration | NoteDebris |
NormalNoteDebrisLWRegistration | NoteDebris |
BurstSliderHeadNoteDebrisHDRegistration | NoteDebris |
BurstSliderHeadNoteDebrisLWRegistration | NoteDebris |
BurstSliderElementNoteHDRegistration | NoteDebris |
BurstSliderElementNoteLWRegistration | NoteDebris |
Multiplayer
Name | Backing Prefab Type |
---|---|
LocalActivePlayerRegistration | MultiplayerLocalActivePlayerFacade |
LocalActivePlayerDuelRegistration | MultiplayerLocalActivePlayerFacade |
ConnectedPlayerRegistration | MultiplayerConnectedPlayerFacade |
ConnectedPlayerDuelRegistration | MultiplayerConnectedPlayerFacade |
LobbyAvatarPlaceRegistration | MultiplayerLobbyAvatarPlace |
LobbyAvatarRegistration | MultiplayerLobbyAvatarController |
LocalInactivePlayerRegistration | MultiplayerLocalInactivePlayerFacade |