Skip to main content

πŸ“ 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 and Action to broadcast from a publisher to many subscribers
  • Apply the OnEnable/OnDisable subscribe/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.

graph TD subgraph Coupled["❌ Tight coupling"] P1["Player"] --> HB1["HealthBar"] P1 --> SM1["ScoreManager"] P1 --> AM1["AudioManager"] P1 --> CS1["CameraShake"] end subgraph Decoupled["βœ… Event-driven"] P2["Player
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:

TermMeaning
ActionA built-in delegate type for a method returning void. Action<int> takes one int argument.
eventA keyword that wraps a delegate so outsiders may only +=/-= (subscribe/unsubscribe), not invoke or overwrite it.
PublisherThe class that declares and raises the event (the Health component).
SubscriberA 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}");
    }
}
graph LR OE["OnEnable()"] -->|"+="| SUB["Subscribed & reacting"] SUB -->|"-="| OD["OnDisable()"] OD --> CLEAN["Unsubscribed β€” no leak,
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 in OnEnable/OnDisable and 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# eventUnityEvent
Wired byCode (+=)Designers in the Inspector (or code)
Visible in Inspector❌ Noβœ… Yes
PerformanceFastestSlight overhead (reflection/serialization)
Best forSystem-to-system, high-frequency, programmer-facingDesigner-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");
}
graph LR PUB["Player
(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.

MechanismReach for it when…
C# event / ActionSystems talk in code, the subscriber can reference the publisher, and you want speed and simplicity. The default.
UnityEventDesigners should wire the reaction in the Inspector β€” buttons, triggers, one-off hooks.
SO event channelPublisher 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:

  1. Write the Health publisher (Section 2) with a public event Action<int> Damaged; raised in TakeDamage.
  2. Write HealthBarUI that subscribes in OnEnable, unsubscribes in OnDisable, and logs the new health.
  3. Write a second listener HurtAudio that subscribes the same way and logs "Ouch!" β€” it must not know about HealthBarUI.
  4. Call TakeDamage (e.g. on a key press) and confirm both listeners react from one event.
  5. 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 (often Action/Action<T>) lets one publisher broadcast to many subscribers that it knows nothing about. Raise with Event?.Invoke(...).
  • Subscribe in OnEnable, unsubscribe in OnDisable β€” every += needs a matching -=, or you leak memory and risk calls on destroyed objects.
  • UnityEvent is 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

πŸš€ 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.