π Lesson 3.2: Events and Decoupling
When the player takes damage, the health bar, the score, the audio, and the screen shake all need to know β but the player shouldn't have to hold a reference to every one of them. Events let a component announce that something happened and let anyone interested react, without the two ever knowing about each other.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain tight coupling and why it makes games hard to change
- Use C#
events andActionto broadcast from a publisher to many subscribers - Apply the
OnEnable/OnDisablesubscribe/unsubscribe pattern correctly (Lesson 1.2, completed) - Wire reactions in the Inspector with
UnityEvent - Use a ScriptableObject event channel to decouple systems across scenes
- Choose the right event mechanism for a given situation
Estimated Time: 60 minutes
Project: A Health component that raises an event on damage, with independent listeners (UI, audio) reacting β none referencing each other.
In This Lesson
The Coupling Problem
Here's the naive way to make things react when the player is hurt: give the Player a reference to everything that cares.
// β Tightly coupled: Player must know about every reactor.
public class Player : MonoBehaviour
{
[SerializeField] private HealthBar healthBar;
[SerializeField] private ScoreManager scoreManager;
[SerializeField] private AudioManager audioManager;
[SerializeField] private CameraShake cameraShake;
void TakeDamage(int amount)
{
health -= amount;
healthBar.Refresh(health); // Player now depends on HealthBarβ¦
scoreManager.LosePoints(amount); // β¦and ScoreManagerβ¦
audioManager.PlayHurt(); // β¦and AudioManagerβ¦
cameraShake.Shake(); // β¦and CameraShake.
}
}
π Definition
Tight coupling means one class directly depends on the concrete details of others. The Player above can't compile or be reused without all four of those classes. Add a fifth reactor and you must edit Player. That's fragile and doesn't scale.
The fix is to invert the relationship. Instead of the Player calling each reactor, it simply announces "I took damage." Reactors subscribe to that announcement. The Player no longer knows β or cares β who's listening.
raises OnDamaged"] --> EV["event"] EV -.->|"subscribes"| HB2["HealthBar"] EV -.->|"subscribes"| SM2["ScoreManager"] EV -.->|"subscribes"| AM2["AudioManager"] end style P1 fill:#fdecea,stroke:#c0392b,stroke-width:2px style P2 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style EV fill:#eff6ff,stroke:#3b82f6,stroke-width:2px
C# Events in Unity
You met delegates and events in the Intermediate C# course; here they earn their keep. A quick recap of the vocabulary:
| Term | Meaning |
|---|---|
Action | A built-in delegate type for a method returning void. Action<int> takes one int argument. |
event | A keyword that wraps a delegate so outsiders may only +=/-= (subscribe/unsubscribe), not invoke or overwrite it. |
| Publisher | The class that declares and raises the event (the Health component). |
| Subscriber | A class that registers a method to run when the event fires (the UI, audioβ¦). |
Here's Health as a publisher. It exposes an event and raises it when damage happens β with no idea who is listening:
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
private int current;
// The announcements this component can make. Others subscribe; only Health raises them.
public event Action<int> Damaged; // passes the new health value
public event Action Died; // no data needed
void Awake() => current = maxHealth;
public void TakeDamage(int amount)
{
current -= amount;
// Raise the event. The ?.Invoke guards against "nobody is subscribed" (null).
Damaged?.Invoke(current);
if (current <= 0)
Died?.Invoke();
}
}
π‘ Why ?.Invoke(...)?
An event with no subscribers is null. Calling Damaged(current) on a null event throws. The null-conditional Damaged?.Invoke(current) only fires if there's at least one subscriber β the standard, safe way to raise a C# event. (This is a plain-C# reference, not a Unity object, so ?. is correct here β unlike the Unity-object caveat from Lesson 1.3.)
Notice how small and self-contained Health is now. It depends on nothing in your game. You could drop it into a totally different project unchanged.
The Subscribe/Unsubscribe Pattern
Back in Lesson 1.2 we promised this: subscribe in OnEnable, unsubscribe in OnDisable. Now you can see why it matters. A subscriber registers a method and must remove it, or it leaks β and worse, a destroyed object's method may still get called, throwing errors.
public class HealthBarUI : MonoBehaviour
{
[SerializeField] private Health health; // the publisher to listen to
void OnEnable()
{
health.Damaged += UpdateBar; // start listening
}
void OnDisable()
{
health.Damaged -= UpdateBar; // ALWAYS stop listening (mirror image)
}
private void UpdateBar(int newHealth)
{
Debug.Log($"Health bar now shows {newHealth}");
}
}
no calls on dead objects"] style OE fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style OD fill:#eff6ff,stroke:#3b82f6,stroke-width:2px
β οΈ Forgetting to unsubscribe is a real bug
If you subscribe but never unsubscribe: (1) the publisher keeps a reference to your method, so your object can't be garbage-collected β a memory leak; and (2) if your object is destroyed while still subscribed, the next event calls a method on a dead object, throwing a MissingReferenceException. The OnEnable/OnDisable pair prevents both because Unity guarantees they mirror each other (Lesson 1.2), including at destroy time (OnDisable runs before OnDestroy).
π‘ The golden rule: every+=needs a matching-=. Put them inOnEnable/OnDisableand you never have to think about it again.
UnityEvents in the Inspector
C# events are code-only β great for programmers, invisible to designers. UnityEvent is Unity's serializable event that shows up in the Inspector, so non-programmers can hook up reactions by dragging, exactly like a Button's On Click () list (which is a UnityEvent).
using UnityEngine;
using UnityEngine.Events; // UnityEvent lives here
public class Interactable : MonoBehaviour
{
// Appears as a drag-and-drop event list in the Inspector.
public UnityEvent onInteract;
public UnityEvent<int> onScoreAwarded; // typed variant, passes an int
void Interact()
{
onInteract.Invoke(); // note: UnityEvent uses .Invoke(), no ?.
onScoreAwarded.Invoke(10);
}
}
In the Inspector, onInteract becomes a list where a designer adds a target object and picks a public method to call β no code needed to connect a door to its "open" sound.
C# event | UnityEvent | |
|---|---|---|
| Wired by | Code (+=) | Designers in the Inspector (or code) |
| Visible in Inspector | β No | β Yes |
| Performance | Fastest | Slight overhead (reflection/serialization) |
| Best for | System-to-system, high-frequency, programmer-facing | Designer-facing hooks, buttons, one-off wiring |
π‘ You've used UnityEvents already
Every UI Button's On Click () is a UnityEvent. When you drag a GameObject in and choose a method, you're subscribing in the Inspector. Lesson 4.2 (UI Scripting) leans on this.
ScriptableObject Event Channels
C# events need the subscriber to hold a reference to the publisher ([SerializeField] private Health health;). That's fine within one object, but awkward when a system in one scene must talk to a system in another, or when you don't want any direct reference at all. The elegant Unity solution combines Lesson 3.1 with events: an event channel β a ScriptableObject that is the event.
using System;
using UnityEngine;
// The event lives as an ASSET. Publishers and subscribers both reference the asset,
// not each other.
[CreateAssetMenu(menuName = "Events/Game Event")]
public class GameEventChannel : ScriptableObject
{
public event Action Raised;
public void Raise() => Raised?.Invoke();
}
// Publisher: raises the channel. Knows nothing about listeners.
public class Player : MonoBehaviour
{
[SerializeField] private GameEventChannel playerDied; // drag the asset in
void Die() => playerDied.Raise();
}
// Subscriber: listens to the same channel asset. Knows nothing about the Player.
public class GameOverScreen : MonoBehaviour
{
[SerializeField] private GameEventChannel playerDied; // same asset
void OnEnable() => playerDied.Raised += Show;
void OnDisable() => playerDied.Raised -= Show;
private void Show() => Debug.Log("GAME OVER");
}
(publisher)"] -->|"Raise()"| CH["PlayerDied
(ScriptableObject channel asset)"] CH -.->|"Raised event"| S1["GameOverScreen"] CH -.->|"Raised event"| S2["AudioManager"] CH -.->|"Raised event"| S3["Analytics"] style CH fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style PUB fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
β Why this is powerful
The publisher and every subscriber reference only the channel asset β never each other. You can add or remove listeners, move them to other scenes, or drop the whole system into another project, and nothing else changes. It's the ultimate decoupling: even the reference is to shared data, not to a specific object. Same OnEnable/OnDisable rule still applies.
Which Should You Use?
Three mechanisms, three sweet spots. Reach for the simplest that fits.
| Mechanism | Reach for it when⦠|
|---|---|
C# event / Action | Systems talk in code, the subscriber can reference the publisher, and you want speed and simplicity. The default. |
UnityEvent | Designers should wire the reaction in the Inspector β buttons, triggers, one-off hooks. |
| SO event channel | Publisher and subscriber shouldn't reference each other at all β cross-scene, cross-system, highly reusable architecture. |
π‘ Don't over-engineer. Events are for decoupling things that genuinely benefit from it (one-to-many, or systems that shouldn't know each other). A component calling a method on its own child doesn't need an event β a direct call is clearer. Decouple where change hurts, not everywhere.
β οΈ Debugging trade-off
Decoupling has a cost: with events, "who reacts to this?" isn't visible from the publisher β you can't just Ctrl-click to the callee. That's the flip side of flexibility. Name events clearly (Damaged, PlayerDied), and don't scatter events where a plain call would read better.
Exercise & Quiz
ποΈ Exercise: Decouple the Damage Reactions
Objective: Make Health broadcast a damage event, and have two independent listeners react β with no listener referencing another.
Instructions:
- Write the
Healthpublisher (Section 2) with apublic event Action<int> Damaged;raised inTakeDamage. - Write
HealthBarUIthat subscribes inOnEnable, unsubscribes inOnDisable, and logs the new health. - Write a second listener
HurtAudiothat subscribes the same way and logs "Ouch!" β it must not know aboutHealthBarUI. - Call
TakeDamage(e.g. on a key press) and confirm both listeners react from one event. - Bonus: temporarily comment out the
-=in one listener, destroy its object during play, then damage again β observe the error, then restore the unsubscribe.
Starter Code:
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
private int current;
public event Action<int> Damaged;
void Awake() => current = maxHealth;
public void TakeDamage(int amount)
{
// TODO: reduce current, then raise Damaged with the new value.
}
}
π‘ Hint
Raise with Damaged?.Invoke(current); after subtracting. Each listener does health.Damaged += Method; in OnEnable and health.Damaged -= Method; in OnDisable. Because both subscribe to the same publisher, one TakeDamage call fans out to all subscribers automatically.
β Solution
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
private int current;
public event Action<int> Damaged;
void Awake() => current = maxHealth;
public void TakeDamage(int amount)
{
current -= amount;
Damaged?.Invoke(current);
}
}
using UnityEngine;
public class HealthBarUI : MonoBehaviour
{
[SerializeField] private Health health;
void OnEnable() => health.Damaged += UpdateBar;
void OnDisable() => health.Damaged -= UpdateBar;
private void UpdateBar(int newHealth) => Debug.Log($"[UI] Health: {newHealth}");
}
public class HurtAudio : MonoBehaviour
{
[SerializeField] private Health health;
void OnEnable() => health.Damaged += PlayOuch;
void OnDisable() => health.Damaged -= PlayOuch;
private void PlayOuch(int newHealth) => Debug.Log("[Audio] Ouch!");
}
One event, two reactors, zero coupling between them. Add a third listener (screen shake, analytics) by writing one more subscriber β Health never changes. That's the payoff.
π― Quick Quiz
Question 1: What is the main benefit of raising an event instead of directly calling each reactor?
Question 2: Where do you subscribe and unsubscribe a C# event on a MonoBehaviour, and why?
Question 3: You want a designer to hook up a reaction in the Inspector without writing code. Which mechanism fits best?
Summary
π Key Takeaways
- Tight coupling β a class holding references to every collaborator β is fragile and doesn't scale. Events invert it: publishers announce, subscribers react.
- A C#
event(oftenAction/Action<T>) lets one publisher broadcast to many subscribers that it knows nothing about. Raise withEvent?.Invoke(...). - Subscribe in
OnEnable, unsubscribe inOnDisableβ every+=needs a matching-=, or you leak memory and risk calls on destroyed objects. UnityEventis a serialized event designers wire in the Inspector (like a Button's On Click).- A ScriptableObject event channel decouples completely β publisher and subscribers reference only the channel asset, enabling cross-scene, cross-system communication.
- Pick the simplest mechanism that fits; decouple where change hurts, not everywhere.
π Additional Resources
- Microsoft β C# Events (delegates & the event keyword)
- Scripting Reference β UnityEvent
- Unity β Architecting with ScriptableObjects (event channels)
π What's Next?
Systems can now talk without tangling. Next we deal with time: Lesson 3.3, Coroutines and Async in Unity, covers spreading work across frames with IEnumerator/yield and how that compares to C#'s async/await β perfect for delays, sequences, and waiting on events like the ones you just built.
π Loosely coupled, cleanly built!
You've learned the pattern that keeps large games maintainable. Announce, don't command β your future self will thank you.