Summary
A ScriptableObject event channel is a small asset whose only job is to carry a C# event. The script that detects something (the broadcaster) holds a reference to the asset and calls Raise(). Every script that cares (the listeners) holds a reference to the same asset and subscribes to its event. Neither side knows the other exists: they only share the asset, which you drag into their Inspector fields. It is the observer-pattern with the subject moved out of the scene and into the Project window.
Unity’s own architecture guidance presents event channels as a way to decouple systems, to make events visible and wireable in the Inspector, and often to replace a singleton (Unity Technologies, Use ScriptableObjects as event channels in game code, one of the mini-guides that accompany the e-book Create modular game architecture in Unity with ScriptableObjects, see source-unity-scriptableobjects-modular-architecture). Learn the plain C# event first (unity-object-communication, csharp-delegates); a channel is the same idea with a different home for the event.
Implementation
1. The channel asset
using System;
using UnityEngine;
[CreateAssetMenu(menuName = "Events/Void Event Channel", fileName = "NewVoidEventChannel")]
public class VoidEventChannelSO : ScriptableObject
{
// 'event' means other scripts can only += and -= ; they cannot Invoke it or wipe the list
public event Action OnRaised;
public void Raise() => OnRaised?.Invoke();
}Create one asset per event in the Project window (Create > Events > Void Event Channel), for example PlayerDied.asset. “Void” means the event carries no data.
2. The broadcaster
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
[SerializeField] private VoidEventChannelSO playerDiedChannel; // drag PlayerDied.asset here
[SerializeField] private int health = 3;
public void TakeDamage(int amount)
{
if (health <= 0) return; // already dead: don't raise the event twice
health -= amount;
if (health <= 0)
playerDiedChannel.Raise(); // PlayerHealth has no idea who is listening
}
}3. A listener
using UnityEngine;
public class GameOverScreen : MonoBehaviour
{
[SerializeField] private VoidEventChannelSO playerDiedChannel; // the same PlayerDied.asset
[SerializeField] private GameObject panel; // a child panel, hidden at start
private void OnEnable() => playerDiedChannel.OnRaised += ShowPanel;
private void OnDisable() => playerDiedChannel.OnRaised -= ShowPanel;
private void ShowPanel() => panel.SetActive(true);
}A death sound, a camera shake and an achievement tracker would each be their own small listener on the same asset. Adding or removing one never touches PlayerHealth.
4. Channels that carry data
When the event needs a payload (a score, a player ID, a position), use a generic base class and one tiny concrete class per type:
using System;
using UnityEngine;
public abstract class EventChannelSO<T> : ScriptableObject
{
public event Action<T> OnRaised;
public void Raise(T value) => OnRaised?.Invoke(value);
}
// IntEventChannelSO.cs (a separate file, named after the class)
[CreateAssetMenu(menuName = "Events/Int Event Channel")]
public class IntEventChannelSO : EventChannelSO<int> { }Unity needs each concrete ScriptableObject or MonoBehaviour class in a file with the same name, so give IntEventChannelSO, FloatEventChannelSO and so on a file each.
5. A codeless listener for designers
using UnityEngine;
using UnityEngine.Events;
public class VoidEventListener : MonoBehaviour
{
[SerializeField] private VoidEventChannelSO channel;
[SerializeField] private UnityEvent response; // wire sounds, particles, SetActive... in the Inspector
private void OnEnable() => channel.OnRaised += response.Invoke;
private void OnDisable() => channel.OnRaised -= response.Invoke;
}This turns any GameObject into a listener without new code: pick the channel asset, then wire the response with the UnityEvent list in the Inspector.
How Unity’s sample does it
The code above is a simplified version of what Unity ships in the PaddleBall sample (GitHub repository UnityTechnologies/PaddleGameSO), which accompanies the e-book. That project has a VoidEventChannelSO with a RaiseEvent() method, a GenericEventChannelSO<T> base with typed channels for int, float, bool, string, Vector2 and player IDs, and a VoidEventChannelListener that runs a UnityEvent response, optionally after a delay. Its GameManager is not a singleton: it listens on one set of channels (goal hit, game reset, pause, quit) and broadcasts on another (game started, point scored, game ended), subscribing in OnEnable and unsubscribing in OnDisable (Unity Technologies, PaddleGameSO sample source, Assets/Core/EventChannels/ and Assets/PaddleBall/Scripts/Managers/GameManager.cs; Unity Technologies, Use ScriptableObjects as event channels in game code).
Two differences are deliberate. Unity’s sample exposes OnEventRaised as a public UnityAction field; the version here uses the event keyword, so a listener cannot accidentally write OnRaised = Handler and silently remove everyone else. And Unity’s how-to page recommends UnityAction for an artist-friendly workflow and System.Action otherwise; both work the same way for this pattern (Unity Technologies, Use ScriptableObjects as event channels in game code).
Trade-offs
Static C# event vs ScriptableObject channel vs UnityEvent
All three implement the observer-pattern. The table compares a public static event Action OnPlayerDied on PlayerHealth (the worked example in unity-object-communication), the channel above, and a public UnityEvent onDied field on PlayerHealth wired in the Inspector.
| Static C# event | ScriptableObject event channel | UnityEvent on the sender | |
|---|---|---|---|
| Where the event lives | In the sender’s class (code) | In an asset in the Project window | In the sender’s component (scene or prefab data) |
| What a listener must know | The sender’s class name (PlayerHealth.OnPlayerDied) | Only the channel asset | Nothing: the sender’s Inspector lists the listeners |
| Inspector wiring | None; all in code | Drag the same asset into sender and listeners | Pick object and method in the sender’s Inspector |
| Across scenes and in spawned prefabs | Yes (static, global) | Yes: assets can be referenced from any scene or prefab | Limited: a scene object can only reference objects in its own scene, and a prefab cannot reference scene objects at all |
| Several instances (two players) | One event shared by every instance; pass an ID to tell them apart | One asset per instance, or pass an ID | Each instance has its own list |
| Testability | Global state; must be reset between tests | Create a fresh channel with ScriptableObject.CreateInstance and raise it directly | Wiring lives in scene data, so harder to exercise from code |
| Debugging | IDE “find references” shows every subscriber | Flow is spread across assets; add a Raise button or listener list (as PaddleBall does) | Visible at a glance in the Inspector |
| Typical pitfalls | Forgetting to unsubscribe; one global event for all instances | Wrong or missing asset in a field (silent no-op or NullReferenceException); many small assets to name and organise | Persistent calls are stored by method name, so renaming a method can quietly break the wiring; designers can wire logic nobody can find in code |
Evidence behind the table: Unity’s how-to page says event channels “are globally available, so they can connect anything with anything”, persist through scene loads, show up in the Inspector, are easier to test in isolation, and that static C# events “may not be very accessible to your game designers” because they cannot be serialised in the Editor (Unity Technologies, Use ScriptableObjects as event channels in game code). The Unity Manual describes UnityEvents as a serialisable, Inspector-configurable alternative to C# events that hold references to their targets, which stops those targets being garbage collected (Unity Technologies, Unity Manual: UnityEvents). The rows on prefabs, instances and debugging are this wiki’s interpretation of how those properties play out.
So is a ScriptableObject channel the cleanest way to handle “Player Died”?
Arguably, it depends on the project, and Unity’s own guidance agrees: “Each pattern has pros and cons. Choose only the ones that meaningfully benefit your specific project”, and it admits the setup “may seem like a lot of extra work” for small tasks (Unity Technologies, Use ScriptableObjects as event channels in game code). A reasonable rule of thumb:
- Small, single-scene game, one player: a
static eventis simpler and perfectly clean. Start here. - Listeners in other scenes, in prefabs spawned at runtime, or chosen by designers: a ScriptableObject channel is cleaner, because nobody needs a reference to the player or a singleton.
- Two or more instances that must be told apart (local multiplayer, several bosses): separate channel assets, or a typed channel that passes an ID.
- One object reacting to its own child or sibling: neither; use a direct reference or a plain instance event (see unity-object-communication).
Central EventManager (event bus) vs ScriptableObject channels
Many tutorials build a “centralised event system” instead: one manager class through which every event passes. It comes in two common forms:
// (a) String-keyed singleton manager
EventManager.Instance.StartListening("PlayerDied", OnPlayerDied);
EventManager.Instance.TriggerEvent("PlayerDied");
// (b) Type-keyed static bus: one event per struct type
public readonly struct PlayerDied { }
EventBus<PlayerDied>.Subscribe(OnPlayerDied);
EventBus<PlayerDied>.Raise(new PlayerDied());Both solve the same problem as channels (the sender does not know its listeners), and for a code-only team they can be perfectly workable. The differences, as this wiki reads them (an interpretation rather than a comparison from one source):
| Central EventManager / event bus | ScriptableObject event channels | |
|---|---|---|
| Where events are defined | One class or file: easy to see every event in the game | One asset per event, spread across the Project window |
| What every script depends on | The manager. With a singleton (a), the scene must contain it, and it must exist before anyone subscribes, which runs into the undefined Awake order between scripts (see monobehaviour-lifecycle) | Only the asset dragged into its field; nothing has to be in the scene first |
| Keys and payloads | String keys (a) fail silently on a typo ("PlayerDeid") and usually pass object payloads; a type-keyed bus (b) fixes both | Each channel is a typed asset; a missing assignment is visible in the Inspector |
| Designers | Code only | Wire channels and codeless responses in the Inspector |
| Instances (two players) | One global event per key; pass an ID | Separate assets per player, or pass an ID |
| Testing | Global state to reset between tests | Create a fresh channel with ScriptableObject.CreateInstance |
| Shared pitfalls | Unsubscribe in OnDisable; listener order is not guaranteed | Same |
Verdict for “many unrelated effects reacting to one moment”: either works, and both beat calling each effect directly. Prefer ScriptableObject channels when designers will hook up effects, when listeners live in other scenes or spawned prefabs, or when you want to avoid a manager that must exist first. Prefer a type-keyed bus (b) for a programmer-only project that wants every event in code. Avoid the string-keyed singleton (a) for anything beyond a prototype: it combines a hidden global dependency with typo-prone keys. The singleton-pattern page covers the general cost of global managers.
Combining them when many things react
For a project where many systems react to the same moments, a combination tends to work better than any single mechanism (this is a synthesis, modelled on the PaddleBall layout rather than a rule from one source):
- Inside one prefab or system: direct references or ordinary C# instance events (
Healthtells its ownHealthBar). - Between systems: ScriptableObject channels (
PlayerDied,ScoreChanged,GamePaused) as the shared vocabulary of the game. - Presentation responses: a codeless
VoidEventListenerwith aUnityEvent, so designers can hook sounds, particles and UI to a channel without code. - State ownership: one manager still owns score and lives and raises the channels; see “Singleton vs events” in unity-gamemanager-pattern.
ScriptableObject as shared state: do I still need events?
Yes. A common next step is to keep global state, such as the player’s health or the score, in a ScriptableObject asset that every script references (often called a ScriptableObject variable). That removes the need for a singleton to read the value, but it does not announce anything: changing a field on a ScriptableObject notifies nobody. A health bar that reads the asset only sees the new value if it polls it every frame, which wastes work and spreads “has it changed?” checks around the project.
Two ways to add the missing notification:
- The owner raises a channel after each change.
PlayerHealthwrites the new value to the variable asset, then raises anIntEventChannelSOnamedHealthChangedwith the value as payload. State and event stay separate, which matches the “Channels are not storage” pitfall below. - The variable carries its own
Changedevent. The asset becomes state and channel in one:
using System;
using UnityEngine;
[CreateAssetMenu(menuName = "Variables/Int Variable")]
public class IntVariableSO : ScriptableObject
{
[SerializeField] private int initialValue = 3; // edited in the Inspector, saved in the asset
[NonSerialized] private int runtimeValue; // play-session value, never saved to the asset
public event Action<int> Changed;
public int Value
{
get => runtimeValue;
set
{
if (runtimeValue == value) return;
runtimeValue = value;
Changed?.Invoke(runtimeValue);
}
}
public void ResetToInitial() => Value = initialValue; // call once at start, e.g. from the owner's Awake
}Listeners subscribe to Changed in OnEnable and unsubscribe in OnDisable, exactly as with a channel. The same question for a singleton manager (“it holds the state, do I still need events?”) is answered the same way in “Singleton vs events” on unity-gamemanager-pattern.
Whichever you choose, keep one owner that changes the value and enforces the rules (clamping health, deciding when the player dies). An asset that any script can write to is a global variable with a nicer Inspector, and bugs where “something set health to -40” become hard to trace.
Play mode changes persist in the Editor. The Unity Manual states that “In the Unity Editor, you can save data to ScriptableObjects in Edit mode and Play mode”, and that “In a standalone Player at runtime, you can only read saved data from the ScriptableObject assets” (Unity Technologies, Unity Manual: ScriptableObject data assets). In practice this means that if your game writes to a serialised field of an asset during Play mode (for example lowering a public health field to 0), that value is still there when you stop playing and may be saved into the asset, so the next Play session starts dead. In a build the same code changes only the in-memory copy, which is lost when the game quits. That is why the example above keeps the play-session value in a [NonSerialized] field and calls ResetToInitial() at start: the asset’s designer-set value is never overwritten, and every session starts from the same state. (Tentative: in a build, an asset that no loaded scene or object references can be unloaded and reloaded, losing runtime changes; keep a reference to state assets from an object that persists, or treat them as reset on scene load.)
Pitfalls
- Always unsubscribe in
OnDisable. The channel asset outlives scenes, so a destroyed listener that never unsubscribed stays on the list and its handler can throwMissingReferenceException(Unity’s page warns that forgetting to unsubscribe causes “errors or memory leaks”). - Disabled listeners hear nothing. A listener subscribes in
OnEnable, so if the listener’s own GameObject is inactive it misses the event. Keep the listener on an active object and toggle a child (thepanelabove). - Enter Play Mode without domain reload. If you turn off domain reloading for faster Play mode, non-serialised fields on ScriptableObjects and static events both keep their values and subscribers between Play sessions; the Unity Manual’s fix is to always unregister handlers (Unity Technologies, Unity Manual: Domain reloading). This affects static events and channels alike.
- Event order is not guaranteed. Listeners run in subscription order, which depends on
OnEnableorder. Do not write listeners that rely on another listener having run first. - Channels are not storage. A channel says something happened; it does not remember the current score. Keep state in its owner (or a ScriptableObject variable, see “ScriptableObject as shared state” above) and send the new value as the payload.
Examples
- PaddleBall (Unity sample): goal hit, point scored, game over and pause are all channels; the game-over sound is a
VoidEventChannelListenerresponding to aGameOverchannel (Unity Technologies, Use ScriptableObjects as event channels in game code). - A typical student platformer (illustrative, not from a source):
PlayerDied(void),CoinsChanged(int) andCheckpointReached(Vector3) channels let the HUD, audio and save system live in a separate UI scene that is loaded additively.
Related
- observer-pattern - the underlying design pattern and its UnityEvent comparison
- unity-object-communication - references and static C# events, the step before channels
- csharp-delegates -
Action,eventand multicast delegates - unity-scriptableobjects - ScriptableObjects as data containers and architecture tools
- unity-gamemanager-pattern - how a state-owning manager raises events instead of being polled
- monobehaviour-lifecycle - why
OnEnable/OnDisableis the subscription pair - model-view-presenter-pattern - events between game data and UI
- source-unity-scriptableobjects-modular-architecture - the Unity e-book this pattern comes from
- singleton-pattern — Singleton pattern, an alternative this pattern often replaces