Skip to main content

๐Ÿ“ฃ 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 Action and Func delegate types
  • Use the event keyword to expose a safe broadcast point
  • Describe the observer pattern: publishers and subscribers
  • Build a Health that raises OnDied and OnHealthChanged
  • Subscribe in OnEnable and unsubscribe in OnDisable to avoid leaks
  • Wire an inspector-friendly UnityEvent without 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 returns void. Action takes 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.

flowchart LR Pub["๐Ÿ“ฃ Health
(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 your OnEnable and OnDisable side 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:

The Unity Inspector showing a UnityEvent with persistent listeners A recreation of Unity's Inspector for an Interactable component. It shows an On Activated (UnityEvent) foldout containing two persistent listener rows. Each row has a Runtime Only dropdown, an object field with a target GameObject, and a function dropdown selecting a method. Row one targets a Door object calling Door.Open, row two targets an AudioSource calling AudioSource.Play. A plus and minus button sit at the bottom right for adding and removing listeners. Inspector # Interactable (Script) On Activated () Runtime Only Door.Open None expected โ–ฃ Door (Door) โ—Ž Runtime Only AudioSource.Play None expected โ™ช SFX (AudioSource) โ—Ž + โˆ’
Figure 2: A 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.

  1. Write the Health component above with OnHealthChanged (Action<int,int>) and OnDied (Action).
  2. Write HealthBarUI that subscribes in OnEnable, unsubscribes in OnDisable, and sets an Image.fillAmount.
  3. Add a second subscriber โ€” a DeathAudio that plays a clip on OnDied.
  4. Damage the object repeatedly and watch both the bar and the sound react, with Health referencing 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.
  • Action returns void; Func returns a value (last type parameter). Games mostly use Action.
  • The event keyword 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 Health can raise OnHealthChanged and OnDied so UI, audio, and score react independently.
  • Subscribe in OnEnable, unsubscribe in OnDisable โ€” every += needs a mirror -= or you leak.
  • UnityEvent is 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.