8.2 - Events
In game development, many systems need to communicate with each other without being tightly coupled. For example, when a player collects a coin, multiple systems might need to know: the score system, the audio system for playing a sound effect, and perhaps a particle system for visual feedback. Events provide an elegant solution to this problem.
What Are Events?
Events are a built-in mechanism in C# that implements the publisher-subscriber pattern (also known as the observer pattern). They're essentially a special type of multicast delegate with additional safety features.
In this pattern:
- The publisher (or subject) is the object that contains and raises the event
- The subscribers (or observers) are the objects that register methods to be called when the event is raised
The key advantage is that publishers and subscribers don't need to know about each other - they only need to know about the event itself.
Events vs. Plain Delegates
Events are built on top of delegates, but they add important restrictions:
- Events can only be invoked (raised) from within the class that declares them
- Outside code can only subscribe or unsubscribe from events, not invoke them
- Outside code cannot remove all subscribers at once
These restrictions prevent external code from interfering with event handling, making your code more robust and maintainable.
Declaring Events
To declare an event, you use the event
keyword followed by a delegate type:
// First, define a delegate type (or use a predefined one)
public delegate void PlayerDeathEventHandler(string causeOfDeath);
// Then declare an event of that delegate type
public event PlayerDeathEventHandler OnPlayerDeath;
You can also use the predefined EventHandler
delegates from the .NET Framework:
// Using the standard EventHandler (no data)
public event EventHandler OnGameStart;
// Using the generic EventHandler<T> (with custom data)
public event EventHandler<int> OnScoreChanged;
Subscribing to Events
To subscribe to an event, you use the +=
operator to add a method to the event's invocation list:
public class AudioManager
{
public void Initialize(GameManager gameManager)
{
// Subscribe to the OnPlayerDeath event
gameManager.OnPlayerDeath += PlayDeathSound;
}
private void PlayDeathSound(string causeOfDeath)
{
if (causeOfDeath == "falling")
Console.WriteLine("Playing falling scream sound");
else
Console.WriteLine("Playing generic death sound");
}
}
Unsubscribing from Events
To unsubscribe, you use the -=
operator:
// Unsubscribe from the event
gameManager.OnPlayerDeath -= PlayDeathSound;
It's important to unsubscribe from events when you no longer need them, especially when the subscriber might be destroyed before the publisher. Failing to do so can lead to memory leaks or null reference exceptions.
Raising (Invoking) Events
To raise an event, you invoke it like a delegate, but with some additional null checking:
public class GameManager
{
public event PlayerDeathEventHandler OnPlayerDeath;
public void KillPlayer(string causeOfDeath)
{
// Check if anyone is subscribed before invoking
OnPlayerDeath?.Invoke(causeOfDeath);
}
}
The ?.
operator (null conditional operator) ensures that the event is only invoked if there are subscribers.
Event Arguments Pattern
A common pattern in C# is to use a specialized class derived from EventArgs
to carry data about the event:
// Custom event args class
public class PlayerDeathEventArgs : EventArgs
{
public string CauseOfDeath { get; }
public int RemainingLives { get; }
public PlayerDeathEventArgs(string causeOfDeath, int remainingLives)
{
CauseOfDeath = causeOfDeath;
RemainingLives = remainingLives;
}
}
// In the GameManager class
public event EventHandler<PlayerDeathEventArgs> OnPlayerDeath;
public void KillPlayer(string causeOfDeath)
{
int remainingLives = CalculateRemainingLives();
var args = new PlayerDeathEventArgs(causeOfDeath, remainingLives);
// Raise the event with the custom args
OnPlayerDeath?.Invoke(this, args);
}
Subscribers would then handle the event like this:
gameManager.OnPlayerDeath += HandlePlayerDeath;
private void HandlePlayerDeath(object sender, PlayerDeathEventArgs e)
{
Console.WriteLine($"Player died by {e.CauseOfDeath}. Remaining lives: {e.RemainingLives}");
}
This pattern provides a clean, extensible way to pass data with events.
Practical Example: Item Collection System
Let's create a simple item collection system using events:
// Event args for item collection
public class ItemCollectedEventArgs : EventArgs
{
public string ItemName { get; }
public int ItemValue { get; }
public ItemCollectedEventArgs(string itemName, int itemValue)
{
ItemName = itemName;
ItemValue = itemValue;
}
}
// Player class that collects items
public class Player
{
public event EventHandler<ItemCollectedEventArgs> OnItemCollected;
public void CollectItem(string itemName, int itemValue)
{
Console.WriteLine($"Collected {itemName}!");
// Notify all subscribers
OnItemCollected?.Invoke(this, new ItemCollectedEventArgs(itemName, itemValue));
}
}
// Score system that listens for item collection
public class ScoreSystem
{
private int _score = 0;
public void Initialize(Player player)
{
// Subscribe to the player's item collection event
player.OnItemCollected += UpdateScore;
}
private void UpdateScore(object sender, ItemCollectedEventArgs e)
{
_score += e.ItemValue;
Console.WriteLine($"Score updated: {_score}");
}
}
// Achievement system that also listens for item collection
public class AchievementSystem
{
private Dictionary<string, int> _itemsCollected = new Dictionary<string, int>();
public void Initialize(Player player)
{
// Subscribe to the same event
player.OnItemCollected += TrackItemCollection;
}
private void TrackItemCollection(object sender, ItemCollectedEventArgs e)
{
if (!_itemsCollected.ContainsKey(e.ItemName))
_itemsCollected[e.ItemName] = 0;
_itemsCollected[e.ItemName]++;
// Check for achievements
if (_itemsCollected[e.ItemName] >= 10)
Console.WriteLine($"Achievement unlocked: Collected 10 {e.ItemName}s!");
}
}
// Usage
public class Game
{
public void Start()
{
Player player = new Player();
ScoreSystem scoreSystem = new ScoreSystem();
scoreSystem.Initialize(player);
AchievementSystem achievementSystem = new AchievementSystem();
achievementSystem.Initialize(player);
// Player collects items, which automatically notifies both systems
player.CollectItem("Coin", 10);
player.CollectItem("Gem", 50);
player.CollectItem("Coin", 10);
}
}
In this example, both the ScoreSystem
and AchievementSystem
subscribe to the player's OnItemCollected
event. When the player collects an item, both systems are automatically notified without the player needing to know about either system.
Custom Event Accessors
For more control over how events are exposed, you can define custom event accessors:
private PlayerDeathEventHandler _onPlayerDeath;
public event PlayerDeathEventHandler OnPlayerDeath
{
add
{
// Custom logic when a subscriber is added
Console.WriteLine("Someone subscribed to OnPlayerDeath");
_onPlayerDeath += value;
}
remove
{
// Custom logic when a subscriber is removed
Console.WriteLine("Someone unsubscribed from OnPlayerDeath");
_onPlayerDeath -= value;
}
}
This allows you to add logging, validation, or other custom behavior when subscribers are added or removed.
Events in Unity
Unity has its own event system, but understanding C# events is still valuable for several reasons:
- You can use C# events alongside Unity's event system
- Many Unity packages and third-party libraries use C# events
- For non-UI interactions, C# events are often simpler than Unity's event system
Unity's UnityEvent
class (used in the Inspector) and the UnityAction
delegate are built on similar principles to C# events, but with additional serialization support. Understanding C# events will make it easier to work with Unity's event system.
Best Practices for Events
-
Always check for null before raising an event:
OnPlayerDeath?.Invoke(this, eventArgs);
-
Unsubscribe from events when you're done with them to prevent memory leaks:
private void OnDestroy()
{
gameManager.OnPlayerDeath -= HandlePlayerDeath;
} -
Use meaningful event names that clearly indicate what happened, typically starting with "On" followed by a past-tense verb:
OnPlayerDied
OnLevelCompleted
OnItemCollected
-
Keep event handlers lightweight - if you need to do heavy processing in response to an event, consider offloading it to a separate method or a coroutine.
-
Consider thread safety if your events might be raised from different threads.
Conclusion
Events are a powerful mechanism for creating loosely coupled systems in your games. They allow different parts of your code to communicate without direct dependencies, making your code more modular, maintainable, and extensible.
In the next section, we'll explore anonymous methods, which provide a convenient way to define simple event handlers inline without creating separate named methods.