EVENT-DRIVEN ARCHITECTURE IN UNITY
The first time I wrote a Unity game, my player script had a reference to the UI manager, which had a reference to the audio manager, which had a reference to the score tracker, which had a reference back to the player. One of them got destroyed at the wrong time, a NullReferenceException appeared in the console, and I spent a full evening trying to figure out which script was asking a ghost for its name. That was the moment I started caring about events.
Event-driven architecture is one of those topics where the reality is much more boring and useful than the name suggests. It isn't a magic design pattern. It's just a way of saying "stuff happens, and other stuff can listen for it, and neither side has to know about the other." Once that clicks, a huge category of Unity problems gets easier. Your UI stops caring who scored the point. Your audio stops caring who pressed the button. Your analytics system stops dragging every other system into its constructor. That's the good stuff.
This post is the version of the talk I'd give to someone who has shipped one or two Unity projects and is starting to feel the weight of tight coupling. We're going to compare the three main flavors of events in Unity, talk about the pub-sub pattern, and cover the traps that will eat your weekend if you aren't watching for them.
Why decoupling is the whole point
Before we get into code, I want to make the case for why you should care at all. Because if you've only ever built small prototypes, tight coupling feels fine. You just drag the reference in the Inspector, you call the method, and it works. Done.
The reason decoupling matters is that games grow. A feature that was a quick hack at week two becomes load-bearing at week ten. By week thirty you've got a player controller that reaches into twelve other scripts to trigger side effects, and now you can't test any of those scripts in isolation because they all need a player to exist. You can't swap the UI for a redesigned version without rewiring half the codebase. You can't reuse the audio manager in a different project because it knows about things that only exist in this game.
Events cut those wires. When the player script fires an "I took damage" event and doesn't know or care who's listening, you can add a health bar, a screen shake, a hit sound, and a damage log without ever touching the player script again. That's the payoff. Smaller surface area, clearer responsibilities, code you can actually reuse.
The other big win is testability. I talk about this in the Unity 3D developer guide, but testing MonoBehaviours is painful precisely because they tend to be tangled up with the scene. Events help because the subscriber and publisher can be tested independently. You don't need a full scene to verify that the audio manager plays the right sound when a specific event fires. You fire the event in a test, you assert the sound played, you're done.
Flavor one: plain C# events
The most fundamental way to do events in Unity is the one Unity didn't invent, which is just plain C# events using delegates. You write something like this.
public static class GameEvents
{
public static event Action<int> OnScoreChanged;
public static void RaiseScoreChanged(int newScore)
{
OnScoreChanged?.Invoke(newScore);
}
}
Then anywhere you care about the score, you subscribe.
void OnEnable()
{
GameEvents.OnScoreChanged += HandleScoreChanged;
}
void OnDisable()
{
GameEvents.OnScoreChanged -= HandleScoreChanged;
}
void HandleScoreChanged(int newScore)
{
scoreText.text = newScore.ToString();
}
The publisher calls GameEvents.RaiseScoreChanged(42) and every listener reacts. The publisher has no idea the UI exists. The UI has no idea what raised the score.
This is the cleanest, fastest, most flexible option. No Unity-specific magic, no boxing, no weird Inspector wiring. If you know how C# works, you already understand this. The performance is great because delegates are cheap. You can pass any kind of data you want, you can have multiple subscribers, and the compiler catches signature mismatches.
The downside is that plain C# events are invisible in the Inspector. Designers can't wire them up. You can't look at a scene and see what's listening to what. For a pure code-driven system where programmers own the whole pipeline, I reach for C# events first. For systems where designers need to hook things up without touching code, you'll want something else.
Flavor two: UnityEvents
UnityEvents are Unity's answer to "but what about the designers." They're serializable, they show up in the Inspector, and you can drag references to methods on other GameObjects and wire them up without writing code.
using UnityEngine;
using UnityEngine.Events;
public class DamageReceiver : MonoBehaviour
{
public UnityEvent<int> OnDamageTaken;
public void TakeDamage(int amount)
{
OnDamageTaken?.Invoke(amount);
}
}
Now any designer can add a listener in the Inspector. They can hook up a sound effect, a screen shake, a UI animation, whatever. It's genuinely useful for designer-driven workflows. Buttons use UnityEvents. Playable signals use them. A lot of official Unity samples use them.
The downsides are real though. UnityEvents are slower than plain C# events. The serialization is a bit ugly. You can only pass up to four arguments, and you can't pass arbitrary types that Unity doesn't know how to serialize. If you rename a method that a UnityEvent references, the Inspector link can break silently and you won't know until the scene is loaded. I have spent way too much time tracking down a missing method reference that survived a refactor because Unity didn't warn me about it.
My rule is roughly this. If a designer is going to wire it up in the Inspector, use UnityEvents. If it's a programmer-to-programmer system, use C# events. Don't mix them in the same flow unless you have a specific reason.
Flavor three: ScriptableObject events
This is the pattern that changed how I architect Unity projects. The idea comes from a Unity talk a few years back and it's been copied and refined a hundred times since. You create a ScriptableObject that represents an event channel, and other ScriptableObjects or components raise and listen to it.
using UnityEngine;
using UnityEngine.Events;
[CreateAssetMenu(menuName = "Events/Int Event")]
public class IntEventChannel : ScriptableObject
{
public UnityAction<int> OnEventRaised;
public void Raise(int value)
{
OnEventRaised?.Invoke(value);
}
}
Then anywhere you want to publish, you reference the ScriptableObject asset.
public class ScoreManager : MonoBehaviour
{
[SerializeField] private IntEventChannel scoreChangedChannel;
public void AddPoints(int points)
{
currentScore += points;
scoreChangedChannel.Raise(currentScore);
}
}
And listeners do the same.
public class ScoreUI : MonoBehaviour
{
[SerializeField] private IntEventChannel scoreChangedChannel;
void OnEnable() => scoreChangedChannel.OnEventRaised += HandleScoreChanged;
void OnDisable() => scoreChangedChannel.OnEventRaised -= HandleScoreChanged;
void HandleScoreChanged(int score) { /* ... */ }
}
The beautiful thing here is that the publisher and listener share nothing except a reference to the asset. They don't know about each other. They don't need to be in the same scene. The event channel exists as a file in your project, which means you can browse all your events like you browse any other asset. You can find every reference to an event channel by right-clicking it. You can have one scene publish and another scene listen, with no manager glue in between.
This is the pattern I use for almost every cross-system communication in my current projects. Player health changes, enemy spawns, level state transitions, UI screen changes. Each gets its own event channel asset. The code stays small, the dependencies are visible in the Inspector, and the project scales in a way that straight C# events don't always handle gracefully.
The cost is that you now have a lot of ScriptableObject assets. I have folders full of them. If that bothers you, you won't like this pattern. If it doesn't bother you, it's great.
The pub-sub pattern, briefly
All three of these flavors are implementations of the same underlying idea, which is the publish-subscribe pattern. Publishers emit events. Subscribers react. Neither side knows about the other. The event system itself is the intermediary.
The reason this pattern shows up everywhere, not just in Unity, is that it cleanly separates "something happened" from "respond to something happening." That separation is what lets you add, remove, and modify behavior without breaking unrelated code. It's one of the oldest and most boring design patterns in programming, and it's boring because it works.
The observer pattern is basically the same thing from a slightly different angle. Publisher is the subject. Subscribers are the observers. The mechanism is different in that observers typically register with the subject directly, rather than going through a channel or bus, but the core idea is identical. Don't let anyone gatekeep you on the terminology. If you're firing events and other code is listening, you're doing the pattern.
If you've worked with C++ engines or written code that talks to an engine's messaging system, you'll recognize the shape of this. I wrote about how these patterns show up in C++ game development and they look nearly identical in spirit. The syntax changes, the fundamentals don't.
Trap one: memory leaks from forgotten unsubscribes
Here is the number one bug I see with event-driven Unity code, including from experienced developers who should know better. You subscribe to an event in OnEnable or Start, you forget to unsubscribe in OnDisable or OnDestroy, and now the event has a reference to an object that logically shouldn't exist anymore.
In plain C# terms, the event is holding a delegate, and that delegate is holding a reference to your MonoBehaviour. The garbage collector can't clean up the MonoBehaviour because something is pointing to it. Meanwhile, Unity has destroyed the underlying C++ object, so when the event fires and the delegate runs, it touches a MonoBehaviour whose "dead" state throws an exception.
The fix is simple and unskippable. Every time you subscribe, you must unsubscribe. Pair them like braces.
void OnEnable()
{
GameEvents.OnScoreChanged += HandleScoreChanged;
}
void OnDisable()
{
GameEvents.OnScoreChanged -= HandleScoreChanged;
}
If you use lambdas when subscribing, you cannot unsubscribe them. This is a classic trap.
// Bad. You can never remove this listener.
GameEvents.OnScoreChanged += (score) => Debug.Log(score);
Always store the delegate in a named method or a field if you need to unsubscribe later. Always.
Trap two: null references after scene load
When a scene unloads, Unity destroys the objects in it. If those objects had subscribed to a static event or a ScriptableObject event and didn't unsubscribe, the event is now holding references to destroyed MonoBehaviours. The next time the event fires, you get a MissingReferenceException or worse, a silent bug where something references a "destroyed but not null" Unity object.
This is especially sneaky with static events, because static means the subscriber list survives scene changes. If you load scene A, subscribe to a static event, then load scene B, the subscribers from scene A are still in the list. They point to destroyed objects.
The defense against this is discipline about OnDisable and OnDestroy. Unsubscribe when your object is disabled or destroyed. If you're using ScriptableObject events, clear the delegate list when the game starts or stops. I usually do this in a bootstrapper.
void OnEnable()
{
OnEventRaised = null;
}
That's blunt, but it guarantees stale subscribers don't survive a domain reload or scene transition.
Trap three: event storms and invisible ordering
Once you start using events for everything, it's easy to create chains where one event fires another event which fires another event, and now you have four cascading state changes happening inside the same frame, and the order depends on which listener subscribed first. This is the dark side of decoupling. The system is easy to read in isolation and hard to reason about as a whole.
My guidance here is to avoid events for things that need strict ordering. If step A must happen before step B, don't model it as two events where B happens to listen to A. Call them in sequence explicitly. Events are for "a thing happened, whoever cares can react." They are not a replacement for a control flow.
Also, don't call events inside event handlers unless you really mean to. If handling event X always raises event Y, you've just created a hidden coupling. Either merge them into a single event or make the call explicit.
Putting it together in a real game
For a small or medium sized Unity project, here's roughly how I structure things. Gameplay state lives in a few MonoBehaviours or ScriptableObjects. Cross-system communication happens through ScriptableObject event channels. UI and audio subscribe to those channels. Designer-facing wiring, like button clicks or specific Inspector hookups, uses UnityEvents. Internal code-to-code signaling inside a single system uses plain C# events.
The goal is that any given script has the shortest possible list of references to other scripts. When I open a new feature file, I want the first ten lines to tell me what this thing does, not give me a tour of every other system it has opinions about.
When this is working well, you can add a new feature and know that the existing code won't break because the existing code doesn't know your new feature exists. That's the dream, and it's achievable. It's also the thing that makes shipping a bigger project sustainable, which I wrote more about in the game development pipeline post.
The takeaway
Event-driven architecture in Unity isn't a silver bullet. It won't fix a bad design, and it can make debugging more annoying if you lean on it too hard. But it's the single biggest architectural tool I have for keeping Unity projects sane as they grow. Pick the flavor of events that fits the situation. Plain C# events for programmer-owned code. UnityEvents for designer-facing wiring. ScriptableObject events for cross-system communication.
Unsubscribe when you subscribe. Don't use lambdas you can't remove. Don't chain events into spaghetti. Keep the surface area of every script small.
Do that, and your next NullReferenceException will be someone else's problem.
LIKED THIS? STAY IN THE LOOP
New posts, game updates, and things you won't find anywhere else.