Notifications
Article
A Better Event System
Published 2 months ago
12
0
Creating a generic Event System that's more complete than the one from the live training tutorial.
Since 2015 there is a live training tutorial in Unity where the instructor creates a simple messaging system script (https://unity3d.com/learn/tutorials/topics/scripting/events-creating-simple-messaging-system). Truth is, I’ve never really liked this system and have ever since created my own, solving some of the faults I’ve found with it. I wanted to share this with the community since, with the advent of Unity Connect, sharing became easier.
The faults of that system, in my opinion, are:
  • Usage of Singleton pattern is unnecessary as no Unity API is ever used.
  • String based identifiers leads to human error and consequentially run-time errors that doesn’t show at compile time and will be difficult to detect in code.
  • Events without any arguments prevent the transfer of data to the receivers.
  • Usage of UnityEvent is also unnecessary as regular C# delegates works fine and the creation and usage of events will always be though code and never on the editor.
In order to correct those, I took the following measures:
  • Usage of Static script.
  • Enumerator as identifier for the event.
  • Added two arguments, an object sender, abstract class for arguments.
  • C# delegates.
As for the name, I chose to go with “Signal” because it´s short and describes the system quite well. One object emits a signal and whichever object wishes to receive that signal, it does and then do whatever it wants with it, without the sender’s knowledge. Feel free to change the name to whatever you like, though.
Let´s go to the code part but first things first. Create a new C# script and name it SignalName.cs . There will be only the enum that identifies the event in this file, so whenever you are creating a new signal (let´s call it signal instead of event or message from now on), you must open the file and add the name manually. I’ll create two events for testing purposes.
public enum SignalName { OnPlayerHit, OnEnemyDeath }
Now create another C# script and name it Signal.cs . This will be the meat and bones of the system. There are several Debug.Log lines for testing, comment them out after you’ve tested your scripts.
using UnityEngine; using System.Collections.Generic; public static class Signal { public delegate void SignalDelegate(object sender, SignalArgs dataToSend); static Dictionary<SignalName, List<SignalDelegate>> signals = new Dictionary<SignalName, List<SignalDelegate>>(); public static void Create(SignalName signalName) { if (signals.ContainsKey(signalName)) { Debug.Log(string.Format("{0} already exists. Signal wasn't created.", signalName)); return; } List<SignalDelegate> sd = new List<SignalDelegate>(); signals.Add(signalName, sd); } public static void Delete(SignalName signalName) { signals.Remove(signalName); } public static void Reset() { signals.Clear(); } public static void Subscribe(SignalName signalName, SignalDelegate subscribeMethod, bool createIfNotFound = false) { bool exists = signals.ContainsKey(signalName); if (exists) { signals[signalName].Add(subscribeMethod); Debug.Log(string.Format("A method was subscribed in {0} Signal.", signalName)); } else if (!exists && createIfNotFound == true) { Create(signalName); } else { Debug.Log(string.Format("{0} signal does not exits and createIfNotFound set to false", signalName)); } } public static void UnSubscribe(SignalName signalName, SignalDelegate subscribeMethod) { if (signals.ContainsKey(signalName)) { List<SignalDelegate> sd = signals[signalName]; sd.Remove(subscribeMethod); Debug.Log(string.Format("A method was Unsubscribed from {0} Signal.", signalName)); } else Debug.Log(string.Format("{0} doesn't exists or delegate is null.", signalName)); } public static void Emit(SignalName signalName, object sender = null, SignalArgs dataToSend = null) { List<SignalDelegate> sd; if (signals.TryGetValue(signalName, out sd)) { if (sd != null) { for (int i = 0; i < sd.Count; i++) { sd[i](sender, dataToSend); } } } } } public abstract class SignalArgs { }
If you got confused or didn’t understand anything, don’t worry, I’ll give a simple example in order to learn how to use it as it’s very simple. For any event system like this you will have a sender and the receiver or receivers. We will create two GameObjects to test the enrollment of the signal and it´s emission.
Create an object in any scene and add a C# script to it. The name is not relevant, choose anything like TestSender.cs for example. Please note the code commentaries after “//”.
using UnityEngine; public class TestSender : MonoBehaviour { void Awake() { /*Create Signals on awake to avoid execution order problems. Remember that creating the SignalName on the enum doesn’t create the signal. It´s usage is only to avoid strings and it’s mistyping.*/ Signal.Create(SignalName.OnPlayerHit); Signal.Create(SignalName.OnEnemyDeath); } void Update() { if (Input.GetButtonDown("Fire1")) { /*If you don’t need to transfer any data though the signal, the standard value for object sender and the SignalArgs class will be null. All you need is the SignalName.*/ Signal.Emit(SignalName.OnEnemyDeath); } if (Input.GetButtonDown("Fire2")) { /* if you need to pass data, use a class that inherits SignalArgs as third argument. The example class is in the end of this code. If you want to pass who is emitting the signal, use any object in the second argument, either if it's the gameObject or the component script, since you’ll have to do the casting in the receiver class anyways. By using the component script you’ll save a GetComponent<> call later. The term “this” will be the component script while this.gameObject will pass the GameObject reference.*/ int dmg = 1; // data to pass to the receiver. Signal.Emit(SignalName.OnPlayerHit, this, new DamageData(dmg)); } } void OnDestroy() { /*Since it´s a static class, you must remove the signal when destroying the GameObject, otherwise the reference to the object will remain, not triggering garbage collection and leading to memory leak. You can also use Signal.Reset() to erase everything. It´s a good idea to do that when changing scenes.*/ Signal.Delete(SignalName.OnPlayerHit); Signal.Delete(SignalName.OnEnemyDeath); } } /*When creating the class to pass data use as little data as possible because after using it the class triggers Garbage collection. Only send the minimum amount of data you need.Also, it’s a good idea to use readonly variables to ensure that one of the receivers cannot change the contents of the argument before the next receives it.*/ public class DamageData : SignalArgs { public readonly int dmg; public DamageData(int value) { dmg = value; } }
Done. We’ve created an object that emits two Signals, one with and other without arguments and sender, but if there is nobody to receive it, all of this will be meaningless. So now we shall create another object and add a script to it. Once again, the name is not important. We’ll call it TestReceiver.cs.
using UnityEngine; public class TestReceiver : MonoBehaviour { int score = 0; int health = 10; void Start() { /*Subscribe on Start and not on Awake to avoid execution order problems. Everytime you subscribe to an event you’ll have to create a method with the same arguments as the delegate, this means it must have and object sender and SignalArgs. But in case you’re not going to use them, just don’t do anything with them in the method. Check the methods bellow for the examples.*/ Signal.Subscribe(SignalName.OnPlayerHit, UpdateHealth); Signal.Subscribe(SignalName.OnEnemyDeath, UpdateScore); } void Update() { /*You can test if the methods are unsubscribing by directly deleting the GameObject with this script attached(because of the OnDestroy()) or you can just press A as bellow.*/ if( Input.GetKeyDown(KeyCode.A)) { Signal.UnSubscribe(SignalName.OnPlayerHit, UpdateHealth); Signal.UnSubscribe(SignalName.OnEnemyDeath, UpdateScore); } } void OnDestroy() { /*Remember to always unsub or the Signal script will keep a reference to this object and cause memory leak (it won’t be deleted even after Destroy(GameObject).*/ Signal.UnSubscribe(SignalName.OnPlayerHit, UpdateHealth); Signal.UnSubscribe(SignalName.OnEnemyDeath, UpdateScore); } void UpdateHealth(object sender, SignalArgs data) { /*now you can cast the sender object and SignalArgs however you want. Since we sent a TestSender and DamageData references, we must cast them.*/ TestSender ts = sender as TestSender; DamageData dd = data as DamageData; //Checking if it´s not null ensures that the casting was successful. if (ts == null || data == null) return; //Now do whatever you like with the either of them. In this case I’ll simply subtract from the health value. health -= dd.dmg; Debug.Log(health); } void UpdateScore(object sender, SignalArgs data) { //Do whatever you want when receiving the signal since no casting is needed. In this case I’ll increase score by a fixed amount of 1. score++; Debug.Log(score); } }
It’s done. Press play and every time you press the Fire1, the UpdateHealth method on the TestReceiver will be called and the health variable will lose the dmg value, which is one in this example, and when Fire2 is pressed the UpdateScore will be called and the score variable will be add by 1. While this might look like a lot of work for something simple, this will really help in the long run. This approach helps with keeping your GameObjects independent without all those FindObject<T>() and GetComponent<T>() methods, leading to cleaner code and avoiding the famous spaghetti code filled with dependencies that can break at any moment.
But even knowing that I’m being redundant, remember that this system brings a great danger: If you don’t remove the method with Signal.Unsubscribe from the listener when it´s deleted, the Garbage Collector will not trigger and that object will be kept in memory until the game closes or crashes. So be sure to always add the unsub line on the OnDestroy() callback and when changing scenes call the Signal.Reset() to erase all the references and start fresh.
One more thing I want to add is that since this is a public static class(would be the same with singleton) any object will be able to emit a signal, even if it was not created by it. When you’re calling the Signal.Emit method in this manner be sure to create and delete the signal in a manager class so you don´t make any runtime mistakes. For example, a signal named OnMonsterKill: every Monster GameObject will have a script that emits this signal when it "dies", so if you keep the Signal.Delete() and Signal.Create on it´s class it will delete the entry and no other Monster GameObject will be able to call it again. The best way would be create or delete it in a GameManager class, for example, but only calling the Emit on other scripts. Just calling the Signal.Emit(OnMonsterKill) on other gameobjects won’t create any dependencies or store it’s reference; and it’s very simple. In short, if the Signal.Emit will be called just in one class, create and delete it in the same class, but if the Signal.Emit will be called elsewhere use a manager class to create and delete it.
I use this system in every project I build. It’s it very useful and handy, keeping every class self contained and independent when trying to communicate with other scripts. Hope this was helpful. Cheers!

HUGO FERNANDES
Game Dev - Programmer
1
Comments