๐๏ธ 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
GameEventScriptableObject that maintains a listener list andRaise()s them - Build a
GameEventListenerMonoBehaviourthat registers with a channel and fires aUnityEvent - 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? Astaticevent or a globalGameManager.Instancealso 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:
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:
- Create three channel assets under Assets โธ Create โธ Events: OnScoreChanged (Int Game Event), OnHealthChanged (Int Game Event), and OnPlayerDied (Game Event).
- Gameplay: a
Pickupraises OnScoreChanged when collected;PlayerHealthraises OnHealthChanged when hurt and OnPlayerDied at zero. - UI: a score label and a health bar each hold a
IntGameEventListenerwhose response calls their ownSetScore/SetFill. A game-over panel holds a plainGameEventListeneron OnPlayerDied. - Audio: an
AudioSourcewith aGameEventListeneron OnPlayerDied whose response isAudioSource.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:
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
Raisebutton or logging insideRaise()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.
- Create a new OnComboReached
IntGameEventasset. - Have the
ScoreManagerraise it with the combo count when a streak hits a threshold. - Add a floating "COMBO x5!" UI text with an
IntGameEventListenerwhose response sets its label โ and a second listener that plays a rising audio pitch. - 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.
GameEventkeeps a listener list withRegister/Unregister/Raise; iterate backwards inRaisefor safety.GameEventListenerregisters inOnEnable, unregisters inOnDisable, and fires a designer-wiredUnityEvent.- 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.