Skip to main content

๐ŸŽ›๏ธ Lesson 2.4: ScriptableObject Event Channels (Mini-Project)

Time to combine everything from this module. C# events are fast but code-only and awkward across scenes. ScriptableObjects are shared assets that live outside scenes. Fuse the two and you get the ScriptableObject event channel โ€” a GameEvent asset that anyone can raise and any GameEventListener can react to, wired entirely in the Inspector. It's the pattern Unity's own teams demo, and by the end of this lesson you'll have built a fully decoupled score/health system with it.

๐ŸŽฏ Learning Objectives

By the end of this mini-project, you will be able to:

  • Explain what a ScriptableObject event channel is and why it beats a plain C# event across scenes
  • Build a GameEvent ScriptableObject that maintains a listener list and Raise()s them
  • Build a GameEventListener MonoBehaviour that registers with a channel and fires a UnityEvent
  • Wire raisers and listeners through channel assets โ€” no direct references between systems
  • Assemble a score/health demo where gameplay, UI, and audio never reference each other
  • Recognize the trade-offs and when to reach for this pattern

Estimated Time: 50 minutes  ยท  Prerequisite: Lessons 2.2 (Events) and 2.3 (ScriptableObjects) โ€” this project builds directly on both

In This Lesson

The Big Idea: Data-Driven Events

In Lesson 2.2, a subscriber needed a direct reference to the publisher: [SerializeField] Health health; then health.OnDied += โ€ฆ. That's fine within one scene, but it breaks down when the publisher and subscriber live in different scenes (loaded additively) or are spawned at runtime โ€” you can't drag a reference across a boundary that doesn't exist yet.

The fix is to put the event itself into a shared asset. Both sides reference the asset, never each other. The raiser says "raise this channel"; the listener says "when this channel raises, do my thing." Because the channel is a ScriptableObject in the Project, everyone can reference it โ€” across scenes, across prefabs, across spawn timing.

๐Ÿ“– Definition

A ScriptableObject event channel is an event stored as a project asset. A GameEvent SO holds a list of listeners and a Raise() method; a GameEventListener component registers itself with the channel and invokes a UnityEvent in response. Systems communicate only through the shared channel asset.

๐Ÿ’ก Why not just a static event or a Singleton? A static event or a global GameManager.Instance also lets anyone talk to anyone โ€” but it re-introduces a hidden hard dependency on that one class, and it doesn't show up in the Inspector. Channels are visible assets a designer can create, name, and wire, and there can be as many as you like (OnPlayerDied, OnScoreChanged, OnBossSpawned).

Building GameEvent

The channel is a ScriptableObject that keeps its own list of listeners and offers Register, Unregister, and Raise. Note we walk the list backwards in Raise so a listener that removes itself mid-broadcast can't corrupt the loop:

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "NewGameEvent", menuName = "Events/Game Event")]
public class GameEvent : ScriptableObject
{
    readonly List<GameEventListener> listeners = new();

    public void Register(GameEventListener listener)
    {
        if (!listeners.Contains(listener)) listeners.Add(listener);
    }

    public void Unregister(GameEventListener listener)
    {
        listeners.Remove(listener);
    }

    public void Raise()
    {
        // iterate backwards: a listener may unregister during its response
        for (int i = listeners.Count - 1; i >= 0; i--)
            listeners[i].OnEventRaised();
    }
}

Each named channel is one asset. Create Assets โ–ธ Create โ–ธ Events โ–ธ Game Event and make OnPlayerDied, OnGameOver, and so on โ€” one file per kind of announcement.

โš ๏ธ Clear the list, or it leaks between Play sessions

Because SO state can persist in the editor, a listener registered in one Play session could linger into the next. Listeners registering in OnEnable and unregistering in OnDisable (below) keeps the list correct โ€” but if you ever see "ghost" listeners, add an OnEnable to the GameEvent that clears the list, or guard with the Contains check shown above.

Building GameEventListener

The listener is a MonoBehaviour you drop on any GameObject. It holds a reference to the channel asset and a UnityEvent that a designer wires in the Inspector. It registers in OnEnable and unregisters in OnDisable โ€” the exact discipline from Lesson 2.2:

using UnityEngine;
using UnityEngine.Events;

public class GameEventListener : MonoBehaviour
{
    [SerializeField] GameEvent gameEvent;   // which channel to listen on
    [SerializeField] UnityEvent response;   // what to do, wired in Inspector

    void OnEnable()  => gameEvent.Register(this);
    void OnDisable() => gameEvent.Unregister(this);

    public void OnEventRaised() => response.Invoke();
}

And raising is trivial โ€” any script that has a reference to the channel just calls Raise():

public class PlayerHealth : MonoBehaviour, IDamageable
{
    [SerializeField] int maxHealth = 100;
    [SerializeField] GameEvent onPlayerDied;   // the channel to raise
    int current;

    void Awake() => current = maxHealth;
    public int CurrentHealth => current;

    public void TakeDamage(int amount)
    {
        current = Mathf.Max(0, current - amount);
        if (current == 0) onPlayerDied.Raise();   // shout into the channel
    }
}

PlayerHealth references only the channel asset, not the UI, not the audio, not the game-over screen. It has no idea anyone is listening.

The Channel Architecture

Here's the whole shape. The GameEvent asset sits in the middle like a radio frequency: raisers broadcast on it, listeners tune in, and the two sides never touch:

flowchart LR Raiser["๐ŸŽฎ PlayerHealth
calls Raise()"] ==> Channel Channel[("๐Ÿ“ป OnPlayerDied
GameEvent asset")] Channel == "OnEventRaised()" ==> L1["๐ŸŽง GameEventListener
โ†’ UI: show Game Over"] Channel == "OnEventRaised()" ==> L2["๐ŸŽง GameEventListener
โ†’ Audio: play sting"] Channel == "OnEventRaised()" ==> L3["๐ŸŽง GameEventListener
โ†’ Score: submit run"] Channel == "OnEventRaised()" ==> L4["๐ŸŽง GameEventListener
โ†’ Spawner: stop waves"]

Figure 1: The event-channel architecture. Every arrow points at the asset, never at another system. Raisers and listeners can live in different scenes and still connect.

Compare this with the observer diagram from Lesson 2.2: it's the same one-to-many broadcast, but the publisher has been replaced by a shared asset. That single move is what lets the connection survive scene boundaries and runtime spawning.

Channels That Carry Data

A bare Raise() is enough for "the player died," but many events carry a value โ€” a new score, a health fraction. Make a generic-feeling variant that passes an int (Unity serializes concrete types, so we make a concrete IntGameEvent). The listener uses a UnityEvent<int>:

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "NewIntEvent", menuName = "Events/Int Game Event")]
public class IntGameEvent : ScriptableObject
{
    readonly List<IntGameEventListener> listeners = new();

    public void Register(IntGameEventListener l)   { if (!listeners.Contains(l)) listeners.Add(l); }
    public void Unregister(IntGameEventListener l) => listeners.Remove(l);

    public void Raise(int value)
    {
        for (int i = listeners.Count - 1; i >= 0; i--)
            listeners[i].OnEventRaised(value);
    }
}
using UnityEngine;
using UnityEngine.Events;

public class IntGameEventListener : MonoBehaviour
{
    [SerializeField] IntGameEvent gameEvent;
    [SerializeField] UnityEvent<int> response;   // receives the int

    void OnEnable()  => gameEvent.Register(this);
    void OnDisable() => gameEvent.Unregister(this);

    public void OnEventRaised(int value) => response.Invoke(value);
}

Now a ScoreManager can onScoreChanged.Raise(newScore) and a score-label UI can receive the number straight into its SetScore(int) method โ€” with neither knowing the other exists.

โœ… Pro Tip

You'll want a small family of these: VoidGameEvent, IntGameEvent, FloatGameEvent. Rather than copy-paste, advanced projects use a generic base GameEvent<T> with tiny concrete subclasses so each type stays serializable. For this project the two above are plenty.

Mini-Project: A Score & Health System on Channels

Let's assemble the real thing. Every system talks only through channels โ€” pull any one system out and the rest keep compiling. Here's the plan:

  1. Create three channel assets under Assets โ–ธ Create โ–ธ Events: OnScoreChanged (Int Game Event), OnHealthChanged (Int Game Event), and OnPlayerDied (Game Event).
  2. Gameplay: a Pickup raises OnScoreChanged when collected; PlayerHealth raises OnHealthChanged when hurt and OnPlayerDied at zero.
  3. UI: a score label and a health bar each hold a IntGameEventListener whose response calls their own SetScore/SetFill. A game-over panel holds a plain GameEventListener on OnPlayerDied.
  4. Audio: an AudioSource with a GameEventListener on OnPlayerDied whose response is AudioSource.Play.

Wiring a listener is all Inspector work. Here is the GameEventListener on the Game-Over panel, listening to OnPlayerDied and calling two responses โ€” drawn faithfully:

The Unity Inspector showing a GameEventListener component wired to a channel A recreation of Unity's Inspector for a GameEventListener component. A Game Event field references the OnPlayerDied asset. Below, a Response (UnityEvent) foldout holds two persistent listener rows: the first targets a GameOverPanel calling GameObject.SetActive with a checked boolean, the second targets an AudioSource calling AudioSource.Play. Plus and minus buttons sit at the bottom right. Inspector # Game Event Listener (Script) Game Event โ–ค OnPlayerDied (Game Event) Response () Runtime Only GameObject.SetActive โ–ฃ GameOverPanel (active = true) Runtime Only AudioSource.Play โ™ช DeathAudio (AudioSource) + โˆ’
Figure 2: A GameEventListener wired in the Inspector (faithfully recreated). It listens on the OnPlayerDied channel asset; when raised, its Response shows the Game-Over panel and plays a death sound โ€” no code references between the player and these reactions.

Press Play and take damage until you die. The health bar drains, the score persists, the Game-Over panel appears, and the sting plays โ€” yet PlayerHealth imports none of those systems. Delete the audio GameObject and everything else still works: proof of full decoupling.

โœ… The test that proves it worked

Open PlayerHealth.cs, ScoreManager.cs, and your UI scripts. None of them should contain a reference to another system's type โ€” only to GameEvent/IntGameEvent channel assets. If that's true, you can move any system to another scene and it still connects through the channel.

Trade-offs & When to Use It

Channels are powerful, not free. Be honest about the costs:

  • Indirection. Following "who reacts to OnPlayerDied?" means searching the scene for listeners rather than reading one method. Name channels clearly and keep them in a dedicated folder.
  • Setup overhead. Creating an asset and wiring listeners is more clicks than a direct call. For two objects in one scene that will never move, a plain C# event (Lesson 2.2) is simpler โ€” use it.
  • Debugging. Because the link is data, a typo in wiring fails silently. Consider a debug Raise button or logging inside Raise() while building.

Reach for channels when systems are far apart โ€” different scenes, spawned at runtime, or owned by different team members โ€” or when designers need to rewire reactions without you. For tightly-scoped, same-scene messaging, the lighter tools you already learned win.

๐Ÿ’ก Module 2, in one sentence. Interfaces decouple the caller, events decouple the reaction, ScriptableObjects decouple the data โ€” and event channels combine events and SOs so even whole scenes can talk without knowing each other's names.

Extend the Project

๐Ÿ‹๏ธ Challenge 1: Add a combo channel without touching gameplay

Objective: Prove the architecture is truly extensible.

  1. Create a new OnComboReached IntGameEvent asset.
  2. Have the ScoreManager raise it with the combo count when a streak hits a threshold.
  3. Add a floating "COMBO x5!" UI text with an IntGameEventListener whose response sets its label โ€” and a second listener that plays a rising audio pitch.
  4. Confirm you added an entirely new feature (UI + audio reaction) by creating one asset and wiring listeners, editing no existing gameplay script.
๐Ÿ’ก Hint: my listener never fires

Three usual suspects: (1) the listener's Game Event field points at the same asset the raiser uses โ€” not a different one with a similar name; (2) the listener GameObject is enabled so OnEnable ran and registered it; (3) the Response has at least one persistent listener wired with a target and method selected.

โœ… Success check

The combo text and sound trigger on a streak, and a diff of your gameplay scripts shows no new references โ€” only the raise call inside the existing ScoreManager, which references a channel asset.

๐Ÿ‹๏ธ Challenge 2: Cross-scene game over

Split the project into two additively-loaded scenes: a Gameplay scene (player, pickups) and a UI scene (score label, health bar, game-over panel). Keep all channel assets in the Project. Confirm that raising OnPlayerDied from the Gameplay scene triggers the game-over panel in the UI scene โ€” something a direct [SerializeField] reference could never do. (We cover additive scene loading properly in Module 5; for now just load both in the editor and press Play.)

๐ŸŽฏ Quick Quiz

Question 1: What is the key advantage of a ScriptableObject event channel over a plain C# event?

Question 2: In the GameEventListener, where does it register and unregister with the channel?

Question 3: When is a plain C# event the better choice over a channel?

Summary

๐ŸŽ‰ Key Takeaways

  • A ScriptableObject event channel stores the event as a shared asset, so raisers and listeners reference the channel, never each other.
  • GameEvent keeps a listener list with Register/Unregister/Raise; iterate backwards in Raise for safety.
  • GameEventListener registers in OnEnable, unregisters in OnDisable, and fires a designer-wired UnityEvent.
  • Make data-carrying variants (IntGameEvent + UnityEvent<int>) for scores, health, and similar values.
  • Channels connect systems across scenes and across runtime spawning โ€” impossible with a direct serialized reference.
  • Trade-offs: indirection, setup, and silent wiring failures. Use channels for far-apart systems; use plain C# events for same-scene messaging.

๐Ÿš€ What's Next?

That completes Module 2 โ€” you can now build games out of decoupled systems that talk through interfaces, events, ScriptableObjects, and channels. Every module from here leans on these foundations. Next we retire the legacy input handling you learned in Fundamentals: Lesson 3.1: Why the New Input System & Installing It kicks off Module 3, where device-agnostic controls replace the old Input class for good.

๐ŸŽ›๏ธ Your whole game can talk through channels

Gameplay raises, UI and audio listen, and nothing holds a reference to anything it shouldn't. That's architecture โ€” and you just built it end to end.