C# has built-in events and they work fine in Unity projects. End of story, right? Not so fast! Have you ever wondered why the Unity API doesn’t have any C# events in it? Or why Unity made their own UnityEvent class for UGUI? Maybe there are some valid reasons to avoid C#’s events. Today’s article discusses an alternative with some serious upsides. Read on to learn more!

C# has the event keyword and it seems to make events trivial. Here’s a little example where the game dispatches an “enemy spawned” event and the sound system listens for it to know when to play a sound:

// Define a class containing all the event arguments
class EnemySpawnedEventArgs : EventArgs
{
	public int EnemyId { get; private set; }
	public Vector3 Location { get; private set; }
 
	public EnemySpawnedEventArgs(int enemyId, Vector3 location)
	{
		EnemyId = enemyId;
		Location = location;
	}
}
 
class Game
{
	// Declare an event
	public event EventHandler<EnemySpawnedEventArgs> OnEnemySpawned;
 
	public void SpawnEnemy(int enemyId, Vector3 location)
	{
		// The event is is null if there are no listeners
		if (OnEnemySpawned != null)
		{
			// Bundle up the parameters into an object
			var eventArgs = new EnemySpawnedEventArgs(enemyId, location);
 
			// Call all the listeners of the event
			OnEnemySpawned(eventArgs);
		}
	}
}
 
class SoundManager
{
	private Game game;
 
	public SoundManager(Game game)
	{
		this.game = game;
 
		// Listen to be notified when an enemy spawns
		game.OnEnemySpawned += HandleEnemySpawned;
	}
 
	// Called when an enemy spawns
	private void HandleEnemySpawned(object sender, EnemySpawnedEventArgs eventArgs)
	{
		// Use the event arguments
		Debug.LogFormat("Enemy with ID {0} spawned at {1}", eventArgs.EnemyId, eventArgs.Location);
 
		// Stop listening so this function is no longer called when enemies spawn
		game.OnEnemySpawned -= HandleEnemySpawned;
	}
}

This code is almost purely boilerplate. While the event functionality of starting and stopping listening and dispatching is reusable, we still had to define an EventArgs, check for null, listen, and stop listening. All this boilerplate code is the first cost that events impose on us.

Technically we could skip the EventArgs class and make our event have a type like Action<int, Vector3>. That too comes at a cost because the code will now be a lot harder to understand. What are these parameters? Since they don’t have names, you’ll have to type a comment, keep it updated, and go read it. When you make your listener functions (e.g. HandleEnemySpawned) you’ll need to type out all of the arguments. If the number, type, or meaning of the arguments changes, you’ll need to go update all the listener functions. So you have a tradeoff between the usability of EventArgs and the boilerplate and garbage reduction of Action<int, Vector3>. A middle-ground is to define your own delegate with named parameters.

Let’s move on from the boilerplate concerns and talk about performance. In this article I showed that normal function calls are about 10x faster than delegates, which are essentially C# events. Then in this article I showed C# events outperforming Unity’s events in both CPU and garbage creation. What I didn’t point out is that creating the delegates to listen to the event with often creates garbage, such as in the case of lambdas or non-static functions. In short, events are 10x slower than normal functions and involve creating garbage to use them.

The next concern with events is with debugging them. Events are basically a list of functions. That list is built at runtime by adding listeners and cleaned up by removing listeners. As such, you can’t look at the code and easily know what functions get called when the event is dispatched because you need to run the code to build the list of functions. That list naturally changes over time, so sometimes functions A and B get called and other times B and C get called. Or maybe no functions get called. To debug, you need to inspect the state of this list in a debugger. The functions in this list probably won’t have useful names since they’re delegate objects, so it’s even harder to tell what’ll get called. It really is a painful debugging experience. This article discusses these issues in more depth and gives some alternatives.

This is also the greatest upside for events. Since you’re dynamically modifying a list of functions that should be called when the event is dispatched, you have a ton of flexibility. There could be no functions to call, one function, or thousands. You can even change the order the listener functions are called in. All of this is at runtime and determined by however complex of code you want to write. C# and Unity events are reusable code that handle all of this listener function list management so you never have to write or maintain any of it.

Those are some of the main “pros” and “cons” of events. Now how about an alternative? In The Problems with Events I showed some simple alternatives for when you just have one listener function. What if you wanted an arbitrary number of listeners? Let’s tackle that problem today.

The main purpose of events is to call zero or more listener functions when we dispatch the event. We don’t want the code that dispatches the event to be tightly coupled to the code that listens for the event, so we need a middle-man. Just like with events, we need the dispatching code to call the middle-man’s function and the middle-man to call the listener functions. We don’t need the middle-man to keep a dynamic list of functions though. That’s just how the C# and Unity event systems happen to work. What if we skipped the list of delegates and just typed out which functions should be called? Let’s try that!

Here’s a version of the above example with the C# event removed and a new EnemySpawnedEvent added:

// Define a class containing all the event arguments
class EnemySpawnedEventArgs : EventArgs
{
	public int EnemyId { get; private set; }
	public Vector3 Location { get; private set; }
 
	public EnemySpawnedEventArgs(int enemyId, Vector3 location)
	{
		EnemyId = enemyId;
		Location = location;
	}
}
 
// Define a class for the event itself
class EnemySpawnedEvent
{
	public SoundManager SoundManager;
 
	public void Dispatch(object sender, EnemySpawnedEventArgs eventArgs)
	{
		if (SoundManager != null)
		{
			SoundManager.HandleEnemySpawned(sender, eventArgs);
		}
	}
}
 
class Game
{
	// Declare an event
	public EnemySpawnedEvent OnEnemySpawned { get; private set; }
 
	public Game()
	{
		// Create the event
		OnEnemySpawned = new EnemySpawnedEvent();
	}
 
	public void SpawnEnemy(int enemyId, Vector3 location)
	{
		// Bundle up the parameters into an object
		var eventArgs = new EnemySpawnedEventArgs(enemyId, location);
 
		// Call all the listeners of the event
		OnEnemySpawned.Dispatch(this, eventArgs);
	}
}
 
class SoundManager
{
	private Game game;
 
	public SoundManager(Game game)
	{
		this.game = game;
 
		// Listen to be notified when an enemy spawns
		game.OnEnemySpawned.SoundManager = this;
	}
 
	// Called when an enemy spawns
	public void HandleEnemySpawned(object sender, EnemySpawnedEventArgs eventArgs)
	{
		// Use the event arguments
		Debug.LogFormat("Enemy with ID {0} spawned at {1}", eventArgs.EnemyId, eventArgs.Location);
 
		// Stop listening so this function is no longer called when enemies spawn
		game.OnEnemySpawned.SoundManager = null;
	}
}

There are only a few differences in this version. First, there’s a new EnemySpawnedEvent and the Game has one instead of the C# event. The EnemySpawnedEvent class has an explicit reference to the SoundManager rather than the implicit reference that a C# event would have. It allows for the SoundManager to start and stop listening simply by setting the SoundManager field to this or null. Its Dispatch function does the null check instead of the Game. Lastly, HandleEnemySpawned needs to be public (or otherwise accessible) so the EnemySpawnedEvent can call it.

Already the EnemySpawnedEventArgs is seeming a little silly. We don’t normally package up our function parameters into a class and create garbage to make an instance of it, so why do we do it here? It’s also weird to pass a plain object “sender” to functions you call. So let’s remove those and see how it looks:

// Define a class for the event itself
class EnemySpawnedEvent
{
	public SoundManager SoundManager;
 
	public void Dispatch(int enemyId, Vector3 location)
	{
		if (SoundManager != null)
		{
			SoundManager.HandleEnemySpawned(enemyId, location);
		}
	}
}
 
class Game
{
	// Declare an event
	public EnemySpawnedEvent OnEnemySpawned { get; private set; }
 
	public Game()
	{
		// Create the event
		OnEnemySpawned = new EnemySpawnedEvent();
	}
 
	public void SpawnEnemy(int enemyId, Vector3 location)
	{
		// Call all the listeners of the event
		OnEnemySpawned.Dispatch(enemyId, location);
	}
}
 
class SoundManager
{
	private Game game;
 
	public SoundManager(Game game)
	{
		this.game = game;
 
		// Listen to be notified when an enemy spawns
		game.OnEnemySpawned.SoundManager = this;
	}
 
	// Called when an enemy spawns
	public void HandleEnemySpawned(int enemyId, Vector3 location)
	{
		// Use the event arguments
		Debug.LogFormat("Enemy with ID {0} spawned at {1}", enemyId, location);
 
		// Stop listening so this function is no longer called when enemies spawn
		game.OnEnemySpawned.SoundManager = null;
	}
}

That was really easy to do. We no longer need to type out the boilerplate EventArgs or sender parameters and the code is a lot more natural. Unlike with an Action<int, Vector3> C# event, the parameters are explicitly named in the Dispatch function. It’s more like we defined our own delegate type, except we didn’t have to type out that boilerplate.

So how do we add more listeners? Easy! We just add them to the EnemySpawnedEvent class. The listeners don’t even need to have the same function signature as each other, which is even more flexible than with normal events. Let’s add a class that tracks game statistics and have it listen to our “enemy spawned” event:

// Define a class for the event itself
class EnemySpawnedEvent
{
	public SoundManager SoundManager;
	public StatsManager StatsManager;
 
	public void Dispatch(int enemyId, Vector3 location)
	{
		if (SoundManager != null)
		{
			SoundManager.HandleEnemySpawned(enemyId, location);
		}
		if (StatsManager != null)
		{
			StatsManager.HandleEnemySpawned(enemyId);
		}
	}
}
 
class Game
{
	// Declare an event
	public EnemySpawnedEvent OnEnemySpawned { get; private set; }
 
	public Game()
	{
		// Create the event
		OnEnemySpawned = new EnemySpawnedEvent();
	}
 
	public void SpawnEnemy(int enemyId, Vector3 location)
	{
		// Call all the listeners of the event
		OnEnemySpawned.Dispatch(enemyId, location);
	}
}
 
class SoundManager
{
	private Game game;
 
	public SoundManager(Game game)
	{
		this.game = game;
 
		// Listen to be notified when an enemy spawns
		game.OnEnemySpawned.SoundManager = this;
	}
 
	// Called when an enemy spawns
	public void HandleEnemySpawned(int enemyId, Vector3 location)
	{
		// Use the event arguments
		Debug.LogFormat("Enemy with ID {0} spawned at {1}", enemyId, location);
 
		// Stop listening so this function is no longer called when enemies spawn
		game.OnEnemySpawned.SoundManager = null;
	}
}
 
class StatsManager
{
	private Game game;
 
	public StatsManager(Game game)
	{
		this.game = game;
 
		// Listen to be notified when an enemy spawns
		game.OnEnemySpawned.StatsManager = this;
	}
 
	// Called when an enemy spawns
	public void HandleEnemySpawned(int enemyId)
	{
		// Use the event arguments
		Debug.LogFormat("Enemy with ID {0} spawned", enemyId);
 
		// Stop listening so this function is no longer called when enemies spawn
		game.OnEnemySpawned.StatsManager = null;
	}
}

Notice how the critical separation of the dispatcher (Game) and the listeners (SoundManager, StatsManager) is preserved by the middle-man (EnemySpawnedEvent). We can add and remove listeners without changing either side.

That’s about all there is to this pattern, so let’s look at its “pros” and “cons” compared to C# events. We’ll judge it by the same criteria as before: boilerplate, performance, garbage, debugging, and flexibility.

First up is boilerplate. With C# events you don’t need to type an event class, but you probably do need to type an EventArgs class. You could skip it, but only with a sizable usability hit. With this alternative system—let’s call them pseudo-events—you need to type the event class but you can skip the EventArgs class without a usability hit. Both systems have to check for null listeners, it’s just moved to the event class from the dispatch points. I’ll call this category a tie.

Next is performance. Pseudo-events just use normal function calls and are therefore 10x faster. You could opt to use interfaces (e.g. IEnemySpawnedListener) instead, but you’d take a speed hit to do so. This category is a clear win for pseudo-events.

Now for garbage creation. C# events are a class containing a dynamic list of listener function delegates. You usually create garbage for the delegate, for the list, and for the event itself. With pseudo-events there are no delegates and no list, so that’s zero garbage. You could even make the event class a struct and have no garbage for that, either. This is another clear win for pseudo-events.

When it comes to debugging, C# events are quite opaque. You’ve got to use a debugger and even then you’ll see a cryptic list of delegates that might not have good names. With pseudo-events you can see named fields referencing your listeners. There is an actual .cs file with the event source code that you can step into and inspect. Or you can add log statements if you prefer. It’s much more debug-friendly than C# events, so that’s a win for pseudo-events.

Finally there’s flexibility. Both systems allow for the same types of functions as listeners: static, non-static, lambda, or delegate. Both allow for starting and stopping listening at runtime. C# events arguably have two advantages though. First, you can add listeners according to runtime variables. For example, you could make a for loop to add N listeners where N is computed at runtime. Second, you can reorder the list of delegates in C# events based on information you don’t have at compile time. In practice, both of these are pretty unusual. When’s the last time you cared about the order that listeners were called in? Is it even a good idea to write code that relies on that ordering? And when’s the last time you added an arbitrary number of listeners? Usually you just add one, like in the examples. Either of these can be replicated with pseudo-events using a List or priority values, but you probably won’t need to. On the other hand, C# events force all listener functions to have the same signature. Pseudo-events listeners can have different signatures, as seen in the example. C# events are probably a bit more flexible overall though.

To summarize, here’s a table comparing C# events to pseudo-events:

Category C# Events Pseudo-Events
Performance Slow Fast
Garbage Yes No
Boilerplate EventArgs Event
Debugging Hard Easy
Flexibility Great Good

What do you think about this alternative to C# and Unity events? Let me know in the comments!