If your MonoBehaviours are holding direct references to each other and your scenes are becoming brittle as a result, the ScriptableObject event channel pattern is worth your time. This guide walks you through a complete, working implementation in Unity C# (tested against Unity 2022 LTS), covers typed event channels most tutorials skip, and tells you honestly when this pattern is overkill. The code is copy-pasteable. The tradeoffs are real.
The Coupling Problem ScriptableObject Events Solve
What is a ScriptableObject event system? It’s a pattern where a ScriptableObject asset acts as a shared event channel between systems. Raisers and listeners both hold a reference to the same asset rather than to each other, eliminating direct MonoBehaviour-to-MonoBehaviour dependencies entirely.
Before we write any code, consider the before state. Your PlayerHealth MonoBehaviour holds a direct reference to UIHealthBar. Your EnemySpawner holds a reference to ScoreManager. Every time you move a GameObject to a different scene or refactor a prefab, something breaks because the reference is gone. This is the scene-load order problem in practice.
Static events and singletons solve the reference problem on the surface, but they introduce global state that’s hard to reset between play sessions and nearly impossible to test in isolation. You swap one problem for another.
ScriptableObject event channels live in your project as assets, not in the scene. Any system can hold a reference to the asset without knowing anything about what else references it. The raiser doesn’t know who’s listening. The listener doesn’t know who raised. That’s the whole point.
Ryan Hipple popularized this approach in his Unite Austin 2017 talk on game architecture with ScriptableObjects, and Unity’s own Open Project #1 used it as a canonical implementation reference. We’re building on that foundation here, with some additions the basic tutorials leave out.
How the ScriptableObject Event Channel Pattern Works
The pattern has three moving parts. A GameEventSO ScriptableObject holds a list of registered listeners and exposes Raise(), Register(), and Deregister() methods. A GameEventListener MonoBehaviour registers itself with the channel on OnEnable and deregisters on OnDisable. The channel asset is the shared reference point.
At runtime, the flow looks like this:
- A raiser (any script) holds a reference to the
GameEventSOasset and callsRaise(). - The
GameEventSOiterates its registered listeners list and callsOnEventRaised()on each. - Each
GameEventListenerinvokes itsUnityEventresponse field, which you configure in the Inspector. - The responding MonoBehaviours execute their logic without ever knowing about the raiser.
The channel asset is the only shared knowledge. Neither side needs a direct reference to the other. That’s what makes prefabs portable and scenes independent.
Building the Core GameEventSO in C#
Step 1: Implement the GameEventSO ScriptableObject
This class is the channel itself. It stores registered listeners and fires them when Raise() is called. The CreateAssetMenu attribute lets you create instances directly from the Project window.
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Events/GameEvent", fileName = "NewGameEvent")]
public class GameEventSO : ScriptableObject
{
private readonly List<IGameEventListener> _listeners = new List<IGameEventListener>();
public void Raise()
{
for (int i = _listeners.Count - 1; i >= 0; i--)
{
_listeners[i].OnEventRaised();
}
}
public void Register(IGameEventListener listener)
{
if (!_listeners.Contains(listener))
_listeners.Add(listener);
}
public void Deregister(IGameEventListener listener)
{
_listeners.Remove(listener);
}
}
We iterate the list in reverse order inside Raise(). This protects against cases where a listener deregisters itself during the callback, which would otherwise cause an index-out-of-range exception mid-loop.
Step 2: Define the IGameEventListener Interface and Listener Component
The interface keeps the channel decoupled from any specific listener implementation. The GameEventListener MonoBehaviour implements it and exposes a UnityEvent for Inspector-assignable responses.
public interface IGameEventListener
{
void OnEventRaised();
}
using UnityEngine;
using UnityEngine.Events;
public class GameEventListener : MonoBehaviour, IGameEventListener
{
[SerializeField] private GameEventSO _channel;
[SerializeField] private UnityEvent _response;
private void OnEnable()
{
if (_channel != null)
_channel.Register(this);
}
private void OnDisable()
{
if (_channel != null)
_channel.Deregister(this);
}
public void OnEventRaised()
{
_response?.Invoke();
}
}
The null check on _channel in OnEnable is there for a reason. If you forget to assign the channel asset in the Inspector, you’ll get a silent failure rather than a NullReferenceException on startup, which is easier to diagnose.
Watch out for this bug: If you forget to call Deregister in OnDisable (or if your listener gets destroyed without OnDisable firing), the channel’s listener list will hold a reference to a destroyed MonoBehaviour. The next time Raise() runs, Unity throws a MissingReferenceException because it’s trying to call OnEventRaised() on an object that no longer exists. The symptom is an error that points into your GameEventSO.Raise() method rather than the actual offending listener. The fix is always Deregister in OnDisable, not OnDestroy.
Now that the core implementation is in place, let’s extend it to carry typed data through the channel.
Extending the Pattern with Typed Event Channels
Step 3: Create a Generic Base Class
Most tutorials stop at void events. That’s fine for simple signals like “player died” or “level loaded,” but you often need to pass data through the event. Here’s a generic base class that handles that.
using System.Collections.Generic;
using UnityEngine;
public abstract class GameEventSO<T> : ScriptableObject
{
private readonly List<IGameEventListener<T>> _listeners = new List<IGameEventListener<T>>();
public void Raise(T value)
{
for (int i = _listeners.Count - 1; i >= 0; i--)
{
_listeners[i].OnEventRaised(value);
}
}
public void Register(IGameEventListener<T> listener)
{
if (!_listeners.Contains(listener))
_listeners.Add(listener);
}
public void Deregister(IGameEventListener<T> listener)
{
_listeners.Remove(listener);
}
}
public interface IGameEventListener<T>
{
void OnEventRaised(T value);
}
Step 4: Create Concrete Typed Channels
Unity’s serialization system can’t serialize open generic types directly, so you need concrete subclasses. This looks like boilerplate, but each subclass is only two lines and gives you a distinct asset type in the Project window.
[CreateAssetMenu(menuName = "Events/IntGameEvent")]
public class IntGameEventSO : GameEventSO<int> { }
[CreateAssetMenu(menuName = "Events/FloatGameEvent")]
public class FloatGameEventSO : GameEventSO<float> { }
// Custom struct payload example
public struct PlayerDiedData
{
public int Score;
public Vector3 DeathPosition;
}
[CreateAssetMenu(menuName = "Events/PlayerDiedEvent")]
public class PlayerDiedEventSO : GameEventSO<PlayerDiedData> { }
The concrete subclass requirement is a Unity serialization constraint, not a design flaw. The upside is that each typed channel shows up as its own asset type in the CreateAssetMenu, which makes the Project window self-documenting. Your FloatGameEventSO assets are easy to find and clearly typed.
The typed listener component follows the same pattern as the void version, replacing UnityEvent with UnityEvent<T>. For example, a float listener would use UnityEvent<float> as its response field, letting you wire up any method that accepts a float directly in the Inspector.
Wiring Event Channels in the Unity Inspector
Step 5: Create Channel Assets and Assign Them
Right-click in the Project window and select Create > Events > GameEvent (or whichever typed channel you need). Name the asset after what it signals, like PlayerDied or ScoreChanged. Treat these assets like you treat prefabs: organized in a folder, named clearly, one asset per event.
Assign the same asset to both the raising script’s channel field and the GameEventListener component’s channel field. That shared asset reference is the entire decoupling mechanism. If they don’t reference the same asset, nothing connects.
Common Inspector mistakes that waste debugging time:
- Forgetting to assign the channel asset entirely. The null check in
OnEnablemeans no error fires, so the listener silently never registers. Add aDebug.LogWarninginOnEnablewhen_channelis null during development. - Assigning the wrong channel type. Unity won’t stop you from assigning a
FloatGameEventSOto anIntGameEventSOfield in the Inspector if you’re not careful with field types. Use the most specific type possible in your serialized fields. - Listener not activating because OnEnable fired before the asset was assigned. This happens when you assign the channel asset at runtime via code after the object has already enabled. Call
Register()manually after the assignment in that case.
Debugging ScriptableObject Event Channels
Adding an Editor Raise Button
This custom editor adds a “Raise Event” button to the GameEventSO Inspector so you can fire events without entering Play mode, which is useful for testing listener wiring.
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(GameEventSO))]
public class GameEventSOEditor : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
GUI.enabled = Application.isPlaying;
if (GUILayout.Button("Raise Event"))
{
((GameEventSO)target).Raise();
}
GUI.enabled = true;
}
}
#endif
One important caveat about ScriptableObject state in the Editor: the _listeners list persists between Play mode sessions because ScriptableObjects don’t reset the way scene objects do. If you enter and exit Play mode without properly deregistering all listeners, you can end up with stale entries in the list from the previous session. The symptom is events firing more times than expected. Adding a Debug.Log($"[{name}] listener count: {_listeners.Count}") call inside Raise() during development catches this quickly.
Comparing ScriptableObject Channels to C# Events and UnityEvents
The honest comparison matters here. Each approach has a place.
| Approach | Coupling | Inspector Visibility | Cross-Scene Support | Performance | Setup Cost |
|---|---|---|---|---|---|
| ScriptableObject Events | Low | Yes | Yes | Moderate | Medium |
| C# Events / Delegates | High | No | No | Fast | Low |
| UnityEvents | Medium | Yes | No | Slower | Low |
| Message Bus (third-party) | Low | No | Yes | Varies | High |
ScriptableObject events support cross-scene communication while C# events do not, because SO assets persist as project assets rather than scene objects. C# events are faster and simpler but require a direct reference to the publisher, which reintroduces the coupling you’re trying to remove. UnityEvents are Inspector-friendly but tied to scene objects and harder to share across scenes. The ScriptableObject channel wins on cross-scene sharing and designer accessibility but adds asset management overhead that smaller projects don’t need.
One practical note on UnityEvent vs. a plain C# delegate inside the channel: UnityEvent is slower due to reflection-based invocation, but it gives designers the ability to wire responses without touching code. If you’re raising an event more than 60 times per second in a performance-critical loop, replace the UnityEvent response with a plain Action delegate. For most gameplay events (player death, score change, level load), the performance difference is irrelevant.
When This Pattern Is Overkill
Use ScriptableObject event channels when the raiser and listener live in different prefabs or scenes, and use C# events otherwise. That’s the practical rule.
Use ScriptableObject events when:
- The raiser and listener are in different scenes or loaded additively.
- Designers need to wire responses in the Inspector without code changes.
- You want the event to be visible and testable as a project asset.
- Multiple unrelated systems need to respond to the same signal.
Avoid ScriptableObject events when:
- The event only ever fires within a single tightly coupled system. A plain C# event is less overhead and easier to follow.
- You’re building a game jam prototype or small project where creating and managing channel assets for every event slows iteration.
- The event fires more than 60 times per second in a hot path and you haven’t replaced
UnityEventwith a delegate. - Your team has no designers who need Inspector access, and all wiring happens in code.
After you implement this pattern, audit one existing scene in your project and find two direct MonoBehaviour references that cross prefab or scene boundaries. Those are your best candidates for replacement with event channels.
Key Takeaways and Frequently Asked Questions
Key Takeaways
- ScriptableObject event channels eliminate direct MonoBehaviour-to-MonoBehaviour dependencies by using a shared asset as the communication contract.
- Always deregister listeners in
OnDisable, notOnDestroy, to avoid stale references that causeMissingReferenceExceptionerrors on the nextRaise()call. - Typed generic event channels require concrete subclasses due to Unity’s serialization constraints, but each subclass is two lines and buys you a clearly typed, Inspector-assignable asset.
- ScriptableObject events are slower than plain C# delegates due to
UnityEventinvocation overhead. For high-frequency events, useActiondelegates instead. - This pattern adds asset management overhead. For small projects or events that stay within a single system, a plain C# event is the better choice.
Frequently Asked Questions
Can ScriptableObject events work across scenes?
Yes. Because the channel is a project asset rather than a scene object, any scene can reference it. A raiser in Scene A and a listener in Scene B can communicate through the same GameEventSO asset as long as both scenes are loaded simultaneously, which makes additive scene loading a natural fit for this pattern.
Do ScriptableObject events persist between Play mode sessions?
The asset itself persists, but the _listeners list is runtime state. If listeners don’t deregister cleanly when Play mode ends, stale entries can remain in the list at the start of the next session. Adding a list-clear in an OnDisable on the asset itself (using void OnDisable() on the ScriptableObject) is a safe way to reset state between sessions during development.
Are ScriptableObject events suitable for high-frequency updates?
Avoid using them for events firing more than 60 times per second in performance-critical code paths. UnityEvent uses reflection-based invocation, which carries overhead compared to a direct method call or a plain C# delegate. For things like health updates driven by physics or input polling, use a shared variable ScriptableObject or a direct reference instead.
What’s the difference between a ScriptableObject event channel and a UnityEvent?
A UnityEvent is a serializable delegate that lives on a scene object and wires responses in the Inspector. A ScriptableObject event channel is an asset that acts as a broker between systems, so neither the raiser nor the listener needs a direct reference to the other. UnityEvents are simpler but scene-bound. ScriptableObject channels add a layer of indirection that pays off when systems span multiple scenes or prefabs.
How should I organize event channel assets in a growing project?
Create a dedicated Assets/Events/ folder and organize channel assets by domain, such as Events/Player/, Events/UI/, and Events/Audio/. Name assets after the signal they carry, not the system that raises them. PlayerDied is a better name than PlayerHealthSystemEvent because it describes what happened, not where it came from.
If you want to go deeper on ScriptableObject architecture, including runtime sets and shared variable patterns, we cover those in related articles on sharpdeveloper.net. And if you want to know when the follow-up on advanced typed channels ships, the newsletter is the fastest way to find out.
- How Hela Designs Co-op Around Exploration Instead of Combat - September 16, 2026
- Write Once, Click Never: Automating Repetitive Unity Tasks with Editor Scripts - September 13, 2026
- ScriptableObject Event Systems: Decoupling Unity C# Without Overengineering - September 13, 2026




