Events & Decoupling
Letting systems talk without knowing each other
The health bar needs to know when health changes. The easy fix is a reference from one to the other. Do that twenty times and every system knows every other system, and nothing can be deleted or tested alone.
Three ways to fire an event
| Mechanism | Wired up in | Best for |
|---|---|---|
C# event | Code | System-to-system. Fast, refactorable, type-safe. |
UnityEvent | The Inspector | Designer-facing hooks. Buttons, triggers. |
ScriptableObject channel | An asset | Cross-scene, zero direct references. |
C# events
The default choice. The publisher exposes an event; anyone can subscribe; the publisher never learns who is listening.
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
private int current;
// Anyone can listen. Health does not care who.
public event Action<int, int> Changed; // current, max
public event Action Died;
private void Awake()
{
current = maxHealth;
}
public void TakeDamage(int amount)
{
if (current <= 0) return;
current = Mathf.Max(0, current - amount);
Changed?.Invoke(current, maxHealth);
if (current == 0) Died?.Invoke();
}
}?.Invoke() is not optional politeness. An event with no subscribers is null, and invoking it directly throws.The leak that causes most crashes
A subscription is a reference. If a listener is destroyed without unsubscribing, the publisher still holds it and still calls into it — and Unity throws MissingReferenceException from a line that looks innocent.
public class HealthBar : MonoBehaviour
{
[SerializeField] private Health health;
private void OnEnable() => health.Changed += Redraw;
private void OnDisable() => health.Changed -= Redraw;
private void Redraw(int current, int max)
{
fill.fillAmount = (float)current / max;
}
}public class HealthBar : MonoBehaviour
{
private void Start()
{
health.Changed += Redraw;
// No unsubscribe. When this object is destroyed, Health
// still calls Redraw on a dead object.
// MissingReferenceException, thrown from inside Health.
}
}OnEnable, unsubscribe in OnDisable — not Start/OnDestroy. Objects get disabled and re-enabled far more often than they get created and destroyed, and only the OnEnable pair stays balanced through that.UnityEvents
A UnityEvent is wired in the Inspector, which lets a designer connect a door to a button without opening a script. That is the whole reason to use one.
using UnityEngine;
using UnityEngine.Events;
public class Interactable : MonoBehaviour
{
// Appears in the Inspector with a + button
public UnityEvent onInteract;
public void Interact() => onInteract?.Invoke();
}| C# event | UnityEvent | Note |
|---|---|---|
Faster | Slower | UnityEvent uses reflection. |
Survives refactors | Breaks silently on rename | The Inspector stores a method name string. |
Invisible to designers | Visible and editable | The real trade-off. |
Compile-time checked | Runtime only | A broken link is a silent no-op. |
ScriptableObject channels
When two systems live in different scenes and cannot hold a reference at all, an asset in the middle solves it. Covered in ScriptableObjects; the short version is that both sides reference the asset, and neither references the other.
Do not over-decouple
Events have a cost too
Fully event-driven code is hard to follow. You can no longer answer "what happens when the player dies?" by reading one file — you have to find every subscriber.
A reasonable rule: use a direct reference when one object genuinely owns another (a weapon and its muzzle), and an event when unrelated systems need to react to the same thing (health, UI, audio, achievements).
What to take away
- C# events for system-to-system, UnityEvents for designer wiring.
- Always
?.Invoke(); an event with no listeners is null. - Subscribe in
OnEnable, unsubscribe inOnDisable. - An unbalanced subscription is the usual cause of
MissingReferenceException. - UnityEvent links break silently when you rename a method.
- Use direct references for ownership; events for unrelated reactions.