Unity Patterns — Event-Driven Architecture
Core rule: event-driven, not polling. React to state changes via events, delegates, and callbacks. Never poll in
Update()for something that can be an event.
Event-Driven vs Polling
| Polling (BAD) | Event-Driven (GOOD) | |
|---|---|---|
| How | Check every frame | Fire when state changes |
| CPU | Runs every frame even if nothing changed | Zero cost when idle |
| Coupling | Listener knows about source internals | Source broadcasts, listeners subscribe |
| Scale | O(n) checks per frame | O(1) per state change |
Polling — Do NOT Do This
// BAD — checking a bool every single frame
private void Update()
{
if (_door.IsOpen)
{
EnterRoom();
}
}
Event-Driven — Do This Instead
// GOOD — react when the door actually opens
private void OnEnable()
{
_door.Opened += OnDoorOpened;
}
private void OnDisable()
{
_door.Opened -= OnDoorOpened;
}
private void OnDoorOpened()
{
EnterRoom();
}
C# Events & Delegates (Preferred)
Zero-allocation, type-safe, fastest option.
using System;
using PixelEngine.Architecture;
public class HealthSystem : IDisposable
{
[Serializable]
public readonly struct Configuration : IConfiguration
{
public readonly int maxHealth;
public static readonly Configuration Default = new Configuration(100);
public Configuration(int maxHealth)
{
this.maxHealth = maxHealth;
}
}
[Serializable]
public readonly struct Reference : IReference
{
public static readonly Reference Default = new Reference();
}
public struct State : IState
{
public int currentHealth;
public bool isDead;
}
public class Components : IComponents { }
private Configuration _configuration;
private Reference _reference;
private State _state;
public Configuration configuration => _configuration;
public Reference reference => _reference;
public State state => _state;
public Components components { get; private set; }
public event Action<int> HealthChanged;
public event Action Died;
public HealthSystem() : this(Configuration.Default, Reference.Default) { }
public HealthSystem(in Configuration configuration, in Reference reference)
{
_configuration = configuration;
_reference = reference;
_state = new State();
this.components = new Components();
Init();
}
public void SetConfiguration(in Configuration configuration)
{
_configuration = configuration;
}
public void TakeDamage(int amount)
{
if (_state.isDead) return;
_state.currentHealth = Math.Max(0, _state.currentHealth - amount);
HealthChanged?.Invoke(_state.currentHealth);
if (_state.currentHealth <= 0)
{
_state.isDead = true;
Died?.Invoke();
}
}
private void Init()
{
_state.currentHealth = _configuration.maxHealth;
}
public void Dispose() { }
}
Subscribe/unsubscribe in OnEnable / OnDisable to avoid leaks:
private void OnEnable()
{
_healthSystem.Died += OnPlayerDied;
}
private void OnDisable()
{
_healthSystem.Died -= OnPlayerDied;
}
UnityEvent (Inspector-Wirable)
Use UnityEvent when designers need to wire callbacks in the Inspector. Slower than C# events — do not use in hot paths.
using UnityEngine.Events;
public class Door : MonoBehaviour
{
[SerializeField]
private UnityEvent _onOpened;
public void Open()
{
// ... door logic ...
_onOpened?.Invoke();
}
}
C# event |
UnityEvent |
|
|---|---|---|
| Performance | Fast (delegate invoke) | Slower (reflection, serialization) |
| Inspector | Not visible | Configurable in Inspector |
| Use when | Code-to-code | Designer-to-code wiring |
Lifecycle Method Ordering
Order your MonoBehaviour event functions in the same order Unity calls them (execution order):
public class Example : MonoBehaviour
{
private void Awake() { } // 1. Called once, before Start
private void OnEnable() { } // 2. Subscribe to events here
private void Start() { } // 3. Called once, after all Awake
private void FixedUpdate() { } // 4. Physics tick
private void Update() { } // 5. Per-frame (use sparingly)
private void LateUpdate() { } // 6. After all Update calls
private void OnDisable() { } // 7. Unsubscribe from events here
private void OnDestroy() { } // 8. Cleanup
}
When Update() IS Acceptable
| Use Case | Why It's OK |
|---|---|
| Continuous input (movement, camera) | No discrete event to react to |
| Animation blending / procedural motion | Needs per-frame interpolation |
Physics queries (Raycast) per frame |
Driven by player aim |
| Frame-rate-dependent visual effects | Inherently per-frame |
Everything else should be event-driven.
Input — Use Action Callbacks
Use Input System action callbacks, not per-frame polling.
// BAD — polling
private void Update()
{
if (UnityEngine.Input.GetKeyDown(KeyCode.Space))
{
Jump();
}
}
// GOOD — event callback via Input System
private void OnEnable()
{
_controls.Player.Jump.performed += OnJump;
_controls.Enable();
}
private void OnDisable()
{
_controls.Player.Jump.performed -= OnJump;
_controls.Disable();
}
private void OnJump(InputAction.CallbackContext ctx)
{
Jump();
}
Coroutines & Async — When to Use
| Mechanism | Use For | Reference |
|---|---|---|
| Coroutine | Sequences tied to MonoBehaviour lifetime | StartCoroutine |
| Awaitable | Async operations with cancellation | await Awaitable.NextFrameAsync() |
UniTask |
Zero-alloc async, broad await support |
Third-party, performant |
// Coroutine — wait for event, not polling
private IEnumerator WaitForDoor()
{
yield return new WaitUntil(() => _door.IsOpen);
EnterRoom();
}
ScriptableObject as Event Channel
Decouple systems with a SO-based event bus. Zero scene references needed.
[CreateAssetMenu(menuName = "Events/Void Event")]
public class VoidEventChannel : ScriptableObject
{
private Action _listeners;
public void Raise()
{
_listeners?.Invoke();
}
public void Subscribe(Action listener)
{
_listeners += listener;
}
public void Unsubscribe(Action listener)
{
_listeners -= listener;
}
}