๐ฃ Lesson 2.2: Events & Delegates
Interfaces let a caller talk to many implementers. Events flip the relationship: they let one object announce that something happened and let anyone who cares listen โ with the announcer never knowing who is on the other end. This is the single most powerful decoupling tool in Unity, and it's built from a small C# idea called a delegate.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a C# delegate is โ a variable that holds a method
- Use the built-in
ActionandFuncdelegate types - Use the
eventkeyword to expose a safe broadcast point - Describe the observer pattern: publishers and subscribers
- Build a
Healththat raisesOnDiedandOnHealthChanged - Subscribe in
OnEnableand unsubscribe inOnDisableto avoid leaks - Wire an inspector-friendly
UnityEventwithout code
Estimated Time: 45 minutes ยท Prerequisite: Lesson 2.1 (Decoupling & Interfaces)
In This Lesson
The Problem Events Solve
When the player's health hits zero, a lot needs to happen: the health bar empties, a death sound plays, the score is tallied, a "You Died" screen fades in, maybe an achievement fires. The naive approach makes Health reach out and call each one:
// โ Health now depends on the UI, audio, score, and screen systems
void Die()
{
FindObjectOfType<HealthBar>().SetEmpty();
FindObjectOfType<AudioManager>().PlayDeath();
FindObjectOfType<ScoreManager>().AddKill();
FindObjectOfType<GameOverScreen>().Show();
}
We're right back to spaghetti โ Health knows about four unrelated systems, and every new reaction means editing Health. Events invert this: Health simply shouts "I died!" into the room and moves on. Whoever wants to react was already listening.
Delegates: Methods in a Variable
The whole system is built on the delegate. You already store numbers in an int and text in a string; a delegate is a variable that stores a method โ a reference you can pass around and call later.
๐ Definition
A delegate is a type that represents a method with a specific signature. A variable of that type can hold any matching method and be invoked with (). Because it holds a method, you can pass behavior around like data.
// declare a delegate type: "a method that takes an int, returns nothing"
public delegate void DamageHandler(int amount);
DamageHandler onHit; // a variable of that type
void Start()
{
onHit = LogDamage; // store a method (no parentheses!)
onHit(10); // call it -> LogDamage(10) runs
}
void LogDamage(int amount) => Debug.Log($"Took {amount} damage");
A delegate can also hold several methods at once. Use += to add one and -= to remove one; invoking the delegate calls them all in order. That "one call, many methods" behavior is the seed of everything in this lesson.
Action & Func
You rarely need to declare your own delegate type, because .NET ships two general-purpose ones you'll use everywhere:
Actionโ a method that returnsvoid.Actiontakes no arguments;Action<int>takes an int;Action<int, string>takes an int and a string.Funcโ a method that returns a value. The last type parameter is the return type:Func<bool>returns a bool;Func<int, int, int>takes two ints and returns an int.
using System;
Action greet = () => Debug.Log("Hi!"); // no args, no return
Action<int> report = n => Debug.Log($"HP: {n}"); // one int arg
Func<int, int, int> add = (a, b) => a + b; // two ints -> int
greet(); // "Hi!"
report(42); // "HP: 42"
int sum = add(3, 4); // 7
For game events you'll almost always reach for Action โ most events are "something happened," not "compute and return a value."
โ Pro Tip
Match the payload to the news. "The player died" carries no data, so Action is perfect. "Health changed" wants the numbers, so Action<int, int> (current, max) lets a health bar compute its fill without asking anyone.
The event Keyword
A plain public delegate is dangerous: any outside class could overwrite it with = (wiping every other listener) or even invoke it. The event keyword locks that down.
๐ Definition
An event is a delegate wrapped so that outside code may only subscribe (+=) and unsubscribe (-=). Only the declaring class can invoke it. This makes it a safe, one-way broadcast point.
using System;
public class Bell : MonoBehaviour
{
public event Action OnRing; // outsiders can only += / -=
public void Ring()
{
OnRing?.Invoke(); // only Bell can raise it
}
}
The ?.Invoke() is important: if nobody has subscribed, the event is null, and calling it directly would throw. The null-conditional ?. means "invoke only if there's at least one listener." Always raise events this way.
The Observer Pattern
What you just built has a formal name: the observer pattern. A publisher owns an event and raises it; any number of subscribers attach their own methods and react. The publisher never holds a reference to the subscribers and never knows how many there are.
(Publisher)
raises OnDied"] Pub -- "OnDied" --> S1["๐ฅ๏ธ HealthBarUI
hides the bar"] Pub -- "OnDied" --> S2["๐ AudioManager
plays death SFX"] Pub -- "OnDied" --> S3["๐ ScoreManager
adds a kill"] Pub -- "OnDied" --> S4["๐ GameOverScreen
fades in"]
Figure 1: One publisher, many subscribers. Health raises OnDied once; four unrelated systems react. Add or remove a subscriber and Health never changes.
Compare this to Figure 1 from the last lesson: interfaces pointed the caller at its targets; events point the targets at the source. Both remove hard wires, and real projects use them together.
A Health That Broadcasts
Here is the publisher. It exposes two events โ a data-carrying OnHealthChanged and a bare OnDied โ and raises them at the right moments. Notice it imports no UI, audio, or score code whatsoever:
using System;
using UnityEngine;
public class Health : MonoBehaviour, IDamageable
{
[SerializeField] int maxHealth = 100;
int current;
// (current, max) so listeners can compute a fill fraction
public event Action<int, int> OnHealthChanged;
public event Action OnDied;
public int CurrentHealth => current;
void Awake()
{
current = maxHealth;
OnHealthChanged?.Invoke(current, maxHealth);
}
public void TakeDamage(int amount)
{
current = Mathf.Max(0, current - amount);
OnHealthChanged?.Invoke(current, maxHealth); // tell the UI
if (current == 0) OnDied?.Invoke(); // tell everyone
}
}
And here is one subscriber โ a health bar that listens for changes and updates its fill. It never calls into Health; it only reacts:
using UnityEngine;
using UnityEngine.UI;
public class HealthBarUI : MonoBehaviour
{
[SerializeField] Health health; // the publisher to watch
[SerializeField] Image fill;
void OnEnable() => health.OnHealthChanged += UpdateBar;
void OnDisable() => health.OnHealthChanged -= UpdateBar;
void UpdateBar(int current, int max)
{
fill.fillAmount = (float)current / max;
}
}
An AudioManager could subscribe its PlayDeath to OnDied in exactly the same shape. Neither subscriber knows the other exists.
Unsubscribe or Leak
Look again at those two lines: subscribe in OnEnable, unsubscribe in OnDisable. This pairing is not optional โ it prevents a real and nasty bug.
โ ๏ธ The forgotten-unsubscribe leak
When you += a method to an event, the publisher now holds a reference to your object. If you destroy the subscriber but never -=, that reference keeps it alive in memory (a leak) and the event still tries to call the dead object's method โ throwing MissingReferenceException every time it fires. Always pair every += with a matching -=.
The reliable habit: subscribe in OnEnable, unsubscribe in OnDisable. These fire together every time the object is enabled/disabled or destroyed, so the two calls always balance. Never subscribe in Awake/Start and forget the other half.
๐ก Symmetry is the trick. Read yourOnEnableandOnDisableside by side: every+=in one should have a mirror-=in the other. If they aren't mirror images, you have a leak waiting to happen.
UnityEvent: Wiring in the Inspector
C# events are code-only โ a designer can't see or change them in the Editor. Unity offers a serializable cousin, UnityEvent, that shows up as a wireable field in the Inspector. You (or a designer) drag target objects in and pick methods from a dropdown, no code required.
using UnityEngine;
using UnityEngine.Events;
public class Interactable : MonoBehaviour
{
// shows up in the Inspector with a + button and a list
public UnityEvent OnActivated;
public UnityEvent<int> OnScored; // can carry a payload too
public void Activate()
{
OnActivated.Invoke(); // note: no ?. needed, it's never null
}
}
In the Inspector, OnActivated renders as a list of persistent listeners. Each row is one reaction: an object, and a method on it to call. Here's that panel, drawn faithfully:
UnityEvent in the Inspector (faithfully recreated). Each persistent listener row picks a target object and a method to call. Here activating the object opens a Door and plays an AudioSource โ wired with zero code, using the function dropdown.โ When to use which
Use a plain C# event for system-to-system messaging in code (fast, type-safe, no allocation). Use a UnityEvent when a designer should wire reactions in the Inspector without touching code โ great for buttons, triggers, and one-off level scripting. Many teams expose both.
Hands-on Challenge
๐๏ธ Exercise 1: Health that broadcasts
Objective: Build the publisher/subscriber pair from scratch.
- Write the
Healthcomponent above withOnHealthChanged(Action<int,int>) andOnDied(Action). - Write
HealthBarUIthat subscribes inOnEnable, unsubscribes inOnDisable, and sets anImage.fillAmount. - Add a second subscriber โ a
DeathAudiothat plays a clip onOnDied. - Damage the object repeatedly and watch both the bar and the sound react, with
Healthreferencing neither.
๐ก Hint: my bar never moves
Confirm the fill Image's Image Type is set to Filled in the Inspector, and that you subscribed before the first OnHealthChanged fires. Since Awake raises it once, have the UI read the current value in OnEnable too, or ensure the UI is enabled first.
โ Success check
The bar drains and the death sound plays, yet Health.cs contains no reference to HealthBarUI or DeathAudio. Deleting either subscriber throws no errors in Health.
๐๏ธ Exercise 2: No-code reactions with UnityEvent
Add a public UnityEvent OnActivated; to a Lever script with an Activate() method that invokes it. In the Inspector, wire two persistent listeners: one that calls Door.Open and one that plays an AudioSource โ matching Figure 2. Confirm that pulling the lever fires both, and that you added a third reaction (e.g. a particle system) without writing any code.
๐ฏ Quick Quiz
Question 1: What does the event keyword add on top of a plain public delegate?
Question 2: Where should you unsubscribe (-=) from an event?
Question 3: A designer needs to wire a button to open a door and play a sound, without asking you to code it. What fits best?
Summary
๐ Key Takeaways
- A delegate is a variable that holds a method;
+=/-=let it hold many. Actionreturns void;Funcreturns a value (last type parameter). Games mostly useAction.- The
eventkeyword makes a delegate a safe one-way broadcast: outsiders only+=/-=; the owner invokes with?.Invoke(). - The observer pattern: one publisher, many subscribers, no back-references โ the deepest form of decoupling.
- A
Healthcan raiseOnHealthChangedandOnDiedso UI, audio, and score react independently. - Subscribe in
OnEnable, unsubscribe inOnDisableโ every+=needs a mirror-=or you leak. UnityEventis the inspector-wireable cousin, edited by designers as persistent listeners.
๐ What's Next?
Events decouple behavior. But games are also full of data โ weapon stats, enemy definitions, item lists โ that today lives tangled inside components and scenes. In Lesson 2.3: ScriptableObjects we lift that data out into standalone project assets that designers can edit and many objects can share.
๐ฃ Your systems can talk without touching
Publishers shout, subscribers listen, and neither holds the other. Master the OnEnable/OnDisable subscribe-pair and you've got the cleanest messaging tool in Unity.