In the last part, we talked about how scripts communicate 1 on 1. Today we will see how one script can communicate indirectly with as many scripts as they want. Let’s start with an example
We have a player that can move around and collect boxes. When we collect a box, we will add score 1 to the score manager. We will also increment the score on UI. Let’s say a script called GameUI does that.
So basically our code on Player will look somewhat like this.
public void BoxCollected()
{
_scoreManager.AddScore();
_gameUI.AddScoreText();
}
Although it works, this structure of coding will give us long-term pain. We are unnecessarily making our Player class depend on ScoreManager and GameUI class. That means if we remove these scripts from the scene for any reason, the game will break. We are also manipulating these classes from Player although it’s not the Player’s responsibility to do that. It is breaking the Single Responsibility principle.
This is where the Observer pattern comes in. In our correct example, the observer pattern states that the ScoreManager and GameUI should observe the state change of the Player and then react to it. If we can achieve that, the Player won’t have any dependency on them. In fact, we can scale it infinitely, meaning without changing the code on Player we can add as many listeners as we want.
We can use any type of event system to achieve this. Let’s start with UnityEvent
UnityEvent
The implementation is pretty straightforward. In the Player script, we will declare a UnityEvent named BoxCollected. Then when the player collects the box, we will invoke it.
That’s it. We don’t need to worry about any other scripts. From the inspector, we can simply drag and drop the listeners and assign their functions. This also takes us back to the same problem we had when resolving dependencies from the inspector in part 1. It can easily break! But for simple projects, it works pretty well
We can still not use the avoid the Editor and simply create the connection using the script like this. But it lets
But this leaves an unnecessary space in the inspector like this for UnityEvent. It also leaves space for assumption. Was I supposed to bind something here? When collaborating someone else can use this and then we are back to square one.
C# Actions
This is also a straightforward implementation. We define an Action on our player class and invoke it where necessary
Then we listen to it in our relevant classes.
This solves the breaking our reference problem that comes with UnityEvent. It also solves our dependency problem for players. But now our ScoreManager and GameUI become dependent on Player. If you think about it, both classes actually do depend on the information sent by the Player, thus the dependency.
Ideally, we would still like to avoid dependencies in cases where a listener script does not have to depend on the Player class.
Currently, our events are hosted in the class where they are invoked. It does not have to be. We can create a central event system where we can host (define) our events. Then, we can invoke or listen to them from anywhere we like. Let’s see how we can do that using C# Actions
centralized actions
We can have one central class named GameEvents and define our events there. Let’s make it a singleton because we want to have access from many places. A better option would be using Zenject as discussed in the previous part. But we’ll get there
Now, we can simply listen to it wherever we need it.
Thus, instead of having direct dependencies, we have soft dependencies. We do have dependencies on the GameEvents class. But as a rule, we can keep GameEvents in our hierarchy as part of the scene setup. This structure will help us scale our project greatly
Zenject Signals
Zenject already has a centralized event system called Signal. A SignalBus-named class is used as a central brain to define and use signals similar to GameEvents. But the advantage is that as long as Zenject is used in the scene, there’s no need to have the class present in the hierarchy. It will automatically created by Zenject in the back-end.
Think of SignalBus as a bus that transports all of the signals. Each signal is a class that represents an event that happens in the game
Let’s create the signal class defining the box collected event. We can simply create a nested class named BoxCollectedSignal under Player. We can create it anywhere. I’m creating it nested under the player for simplicity
//Defining a signal
public class BoxCollectedSignal
{
}
Now, we can go into the Zenject Installer as we saw in part 1. First, we will let Zenject know that we will be using the SignalBus using Install. Then we will declare the signal that we created earlier.
The setup is complete. Now we can invoke and listen to it.
Thus, we have a cool centralized event system namely SignalBus to use. Read more here
Conclusion and My Take
Attribute | UnityEvent | C# Action | Centralized C# Action | Zenject Signals |
---|---|---|---|---|
Adding events | Simple | Simple | Simple | Complex, the first time |
Adding Listeners | Simple. Hierarchy Drag and drop | Takes a bit of time. Requires code | Takes a bit of time. Requires code | Takes a bit of time. Requires code |
Extensibility | Bad. Can easily break and become unmanageable | Great | Great | Great |
Dependency | Bad. No script dependencies. But hierarchy dependencies tend to break | Bad. Direct script dependency | OK. Dependency on a static class | Great. Dependency on a preset class. |
Realistically, we can use a combination of the systems above depending on our use cases. What I do generally is, for events where the invoker and the listener are in the same prefab, I use C# Actions. For more global events that span between prefabs, I use Zenject Signals. To quickly set up something that is specific to one scene only and not repeated in other scenes, like tutorials I use UnityEvents. I’d only use centralized C# actions if, for any specific reason, I am not using Zenject.
There are other solutions too. A great talk comes to mind that explains how a scriptable object-based event system works. It’s an interesting watch. Do let me know what you use and why!