Event System
Build a decoupled communication system so your scripts can talk to each other without knowing each other exist.
Why Events? The Problem with Direct References
Imagine the player picks up a coin. What needs to happen?
- The score increases by 1.
- The UI updates the score display.
- A sound effect plays.
- A particle effect bursts from the coin.
- An achievement system checks if the player hit 100 coins.
Without events, the coin script needs a direct reference to every single one of those systems:
// BAD: Tight coupling! The coin "knows" about everything.
public class BadCoin : MonoBehaviour
{
// The coin needs references to ALL these systems
public ScoreManager scoreManager;
public UIManager uiManager;
public AudioManager audioManager;
public ParticleManager particleManager;
public AchievementManager achievementManager;
private void OnTriggerEnter(Collider other)
{
scoreManager.AddScore(1);
uiManager.UpdateScoreDisplay();
audioManager.PlayCoinSound();
particleManager.SpawnCoinParticles(transform.position);
achievementManager.CheckCoinMilestone();
Destroy(gameObject);
}
}
This is called tight coupling, and it causes serious problems:
- If you remove the achievement system, the coin script breaks.
- If you want to add a new reaction (e.g., a combo multiplier), you must modify the coin script.
- Every coin prefab in the scene needs all five references dragged into the Inspector.
- Testing the coin in isolation is impossible — it depends on everything.
With events, the coin simply says "Hey, I was collected!" and does not care who is listening:
// GOOD: The coin does not know about any other system.
public class GoodCoin : MonoBehaviour
{
[SerializeField] private GameEvent onCoinCollected;
private void OnTriggerEnter(Collider other)
{
onCoinCollected.Raise(); // "Something happened!"
Destroy(gameObject);
}
}
The score manager, UI, audio, particles, and achievements each independently listen for the "coin collected" event and react on their own. The coin does not even know they exist. This is called loose coupling, and it is the foundation of clean game architecture.
C# Delegates and Events Explained Simply
Before we build our event system, let us understand the building blocks. If you are new to C#, do not worry — we will take this step by step.
What Is a Delegate?
A delegate is a variable that holds a reference to a method. Just like an int variable holds a number, a delegate variable holds a method that you can call later.
// Step 1: Declare a delegate TYPE (like declaring a new variable type)
// This delegate can hold any method that takes no parameters and returns void.
public delegate void SimpleDelegate();
// Step 2: Create a variable of that type
SimpleDelegate myDelegate;
// Step 3: Assign a method to it
myDelegate = SayHello;
// Step 4: Call it (this calls SayHello)
myDelegate();
void SayHello()
{
Debug.Log("Hello!");
}
What Is an Event?
An event is a delegate with restrictions. It can only be invoked (fired) from inside the class that owns it. Other classes can only subscribe (listen) or unsubscribe (stop listening). This is a safety measure — you do not want random scripts firing your events.
public class Player : MonoBehaviour
{
// Declare an event. Only Player can fire this.
public event SimpleDelegate OnPlayerDied;
public void Die()
{
// Fire the event (notify all listeners)
OnPlayerDied?.Invoke();
}
}
public class UIManager : MonoBehaviour
{
[SerializeField] private Player player;
private void OnEnable()
{
// Subscribe: "When the player dies, call my ShowGameOver method"
player.OnPlayerDied += ShowGameOver;
}
private void OnDisable()
{
// Unsubscribe: ALWAYS do this to prevent memory leaks
player.OnPlayerDied -= ShowGameOver;
}
private void ShowGameOver()
{
Debug.Log("Showing Game Over screen");
}
}
+= subscribes a method to an event (adds a listener). -= unsubscribes it (removes the listener). Multiple methods can subscribe to the same event — when the event fires, ALL subscribed methods are called.
Action and Action<T> — The Easy Way
Declaring custom delegate types gets tedious. C# provides built-in delegate types called Action that handle the most common cases:
| Type | Meaning | Example |
|---|---|---|
Action | A method with no parameters, returns void | event Action OnPlayerDied; |
Action<int> | A method with one int parameter, returns void | event Action<int> OnScoreChanged; |
Action<int, string> | A method with int and string params, returns void | event Action<int, string> OnAchievement; |
using System;
using UnityEngine;
public class ScoreManager : MonoBehaviour
{
// Event that sends the new score value when it changes
public event Action<int> OnScoreChanged;
private int _score;
public void AddScore(int amount)
{
_score += amount;
// Fire the event with the new score value
OnScoreChanged?.Invoke(_score);
}
}
public class ScoreUI : MonoBehaviour
{
[SerializeField] private ScoreManager scoreManager;
private void OnEnable()
{
scoreManager.OnScoreChanged += UpdateDisplay;
}
private void OnDisable()
{
scoreManager.OnScoreChanged -= UpdateDisplay;
}
// This method receives the int that was passed to Invoke()
private void UpdateDisplay(int newScore)
{
Debug.Log($"Score: {newScore}");
}
}
UnityEvents vs C# Events
Unity provides its own event system called UnityEvent. You have probably seen it — it is the box in the Inspector where you can drag objects and select methods (used on UI Buttons, for example). Let us compare the two approaches:
| Feature | C# Events (Action) | UnityEvent |
|---|---|---|
| Setup | Code only | Inspector drag-and-drop |
| Performance | Faster (no reflection) | Slightly slower |
| Designer-friendly | No (requires coding) | Yes (visual wiring) |
| Type safety | Compile-time checked | Runtime checked |
| Serialization | Not saved in scenes | Saved in scenes/prefabs |
| Debugging | Harder to trace | Visible in Inspector |
Use C# events for system-to-system communication in code (GameManager to ScoreManager). Use UnityEvents when you want designers to wire things up in the Inspector without coding (button clicks, animation events). In this tutorial, we will use a hybrid approach: ScriptableObject Event Channels that combine the best of both worlds.
ScriptableObject Event Channels — The Production Approach
The problem with C# events: the listener needs a direct reference to the object that fires the event. The ScoreUI needs a reference to ScoreManager. If they are in different scenes or if the ScoreManager is created at runtime, wiring them up is painful.
The solution is to use a ScriptableObject as a middleman — an "event channel" that lives in your Assets folder. The broadcaster raises the event on the ScriptableObject, and listeners subscribe to that same ScriptableObject. Neither the broadcaster nor the listeners know about each other.
This pattern is used in production by Unity themselves (it was featured in their "Open Projects" initiative).
How It Works
- Create a
GameEventScriptableObject asset in your project (e.g., "OnCoinCollected"). - The coin script has a reference to this asset and calls
Raise()on it. - The ScoreManager, AudioManager, and ParticleManager each have a
GameEventListenercomponent that references the same asset. - When
Raise()is called, all listeners are notified.
Step 1: The GameEvent ScriptableObject
using System.Collections.Generic;
using UnityEngine;
namespace InfiniteRunner.Events
{
/// <summary>
/// A ScriptableObject-based event channel.
/// Create instances in the Project window via:
/// Right-click > Infinite Runner > Events > Game Event
/// </summary>
[CreateAssetMenu(
fileName = "NewGameEvent",
menuName = "Infinite Runner/Events/Game Event",
order = 0)]
public class GameEvent : ScriptableObject
{
// All listeners currently registered to this event
private readonly List<GameEventListener> _listeners = new List<GameEventListener>();
// Optional: description for the Inspector
[TextArea(2, 5)]
[SerializeField] private string description;
/// <summary>
/// Fire this event, notifying all registered listeners.
/// Called by the broadcaster (e.g., the coin when collected).
/// </summary>
public void Raise()
{
// Iterate backwards so listeners can safely unregister
// during the callback without breaking the loop
for (int i = _listeners.Count - 1; i >= 0; i--)
{
_listeners[i].OnEventRaised();
}
}
/// <summary>
/// Register a listener to this event.
/// Called automatically by GameEventListener.OnEnable().
/// </summary>
public void RegisterListener(GameEventListener listener)
{
if (!_listeners.Contains(listener))
{
_listeners.Add(listener);
}
}
/// <summary>
/// Unregister a listener from this event.
/// Called automatically by GameEventListener.OnDisable().
/// </summary>
public void UnregisterListener(GameEventListener listener)
{
_listeners.Remove(listener);
}
}
}
Save this at Assets/Scripts/Events/GameEvent.cs.
A ScriptableObject is a data container that lives as an asset in your project (like a material or a texture). Unlike MonoBehaviours, it does not need to be attached to a GameObject. It is perfect for shared data and event channels because any script in any scene can reference the same asset.
Step 2: The GameEventListener Component
This MonoBehaviour is attached to any GameObject that wants to listen for an event. It bridges the ScriptableObject event to a UnityEvent, so you can wire up responses in the Inspector.
using UnityEngine;
using UnityEngine.Events;
namespace InfiniteRunner.Events
{
/// <summary>
/// Attach this to any GameObject that needs to respond to a GameEvent.
/// Drag the GameEvent asset into the "Game Event" field, then
/// use the "Response" UnityEvent to wire up what happens.
/// </summary>
public class GameEventListener : MonoBehaviour
{
[Tooltip("The event to listen for.")]
[SerializeField] private GameEvent gameEvent;
[Tooltip("What to do when the event is raised.")]
[SerializeField] private UnityEvent response;
private void OnEnable()
{
// Register with the event when this object becomes active
if (gameEvent != null)
{
gameEvent.RegisterListener(this);
}
}
private void OnDisable()
{
// Unregister when this object is deactivated or destroyed
if (gameEvent != null)
{
gameEvent.UnregisterListener(this);
}
}
/// <summary>
/// Called by the GameEvent when it is raised.
/// Invokes the UnityEvent response.
/// </summary>
public void OnEventRaised()
{
response?.Invoke();
}
}
}
Save this at Assets/Scripts/Events/GameEventListener.cs.
Typed Events: Passing Data with Events
The basic GameEvent is great for simple notifications ("something happened"), but often you need to send data along with the event. For example, "the score changed to 42" or "the player took 10 damage."
We will create typed variants using C# generics:
Base Typed Event
using System.Collections.Generic;
using UnityEngine;
namespace InfiniteRunner.Events
{
/// <summary>
/// A generic event that carries data of type T.
/// Subclass this for concrete types (int, float, string, etc.).
/// </summary>
public abstract class TypedGameEvent<T> : ScriptableObject
{
private readonly List<TypedGameEventListener<T>> _listeners
= new List<TypedGameEventListener<T>>();
[TextArea(2, 5)]
[SerializeField] private string description;
/// <summary>
/// Raise this event with data.
/// </summary>
public void Raise(T value)
{
for (int i = _listeners.Count - 1; i >= 0; i--)
{
_listeners[i].OnEventRaised(value);
}
}
public void RegisterListener(TypedGameEventListener<T> listener)
{
if (!_listeners.Contains(listener))
{
_listeners.Add(listener);
}
}
public void UnregisterListener(TypedGameEventListener<T> listener)
{
_listeners.Remove(listener);
}
}
}
Base Typed Listener
using UnityEngine;
using UnityEngine.Events;
namespace InfiniteRunner.Events
{
/// <summary>
/// Generic listener for typed events.
/// Subclass this for concrete types.
/// </summary>
public abstract class TypedGameEventListener<T> : MonoBehaviour
{
[SerializeField] private TypedGameEvent<T> gameEvent;
[SerializeField] private UnityEvent<T> response;
private void OnEnable()
{
if (gameEvent != null)
{
gameEvent.RegisterListener(this);
}
}
private void OnDisable()
{
if (gameEvent != null)
{
gameEvent.UnregisterListener(this);
}
}
public void OnEventRaised(T value)
{
response?.Invoke(value);
}
}
}
Concrete Typed Events
Unity cannot serialize generic ScriptableObjects directly, so we create small concrete classes for each type we need:
using UnityEngine;
namespace InfiniteRunner.Events
{
/// <summary>
/// An event that carries an integer value (score, health, coins, etc.).
/// </summary>
[CreateAssetMenu(
fileName = "NewIntEvent",
menuName = "Infinite Runner/Events/Int Event",
order = 1)]
public class IntEvent : TypedGameEvent<int> { }
}
namespace InfiniteRunner.Events
{
/// <summary>
/// Listener for IntEvent. Attach to any GameObject.
/// </summary>
public class IntEventListener : TypedGameEventListener<int> { }
}
using UnityEngine;
namespace InfiniteRunner.Events
{
/// <summary>
/// An event that carries a float value (speed, distance, timer, etc.).
/// </summary>
[CreateAssetMenu(
fileName = "NewFloatEvent",
menuName = "Infinite Runner/Events/Float Event",
order = 2)]
public class FloatEvent : TypedGameEvent<float> { }
}
namespace InfiniteRunner.Events
{
/// <summary>
/// Listener for FloatEvent. Attach to any GameObject.
/// </summary>
public class FloatEventListener : TypedGameEventListener<float> { }
}
using UnityEngine;
namespace InfiniteRunner.Events
{
/// <summary>
/// An event that carries a string value (messages, names, etc.).
/// </summary>
[CreateAssetMenu(
fileName = "NewStringEvent",
menuName = "Infinite Runner/Events/String Event",
order = 3)]
public class StringEvent : TypedGameEvent<string> { }
}
namespace InfiniteRunner.Events
{
/// <summary>
/// Listener for StringEvent. Attach to any GameObject.
/// </summary>
public class StringEventListener : TypedGameEventListener<string> { }
}
The pattern is always the same: create a two-line class that extends TypedGameEvent<YourType> with a [CreateAssetMenu] attribute, and a corresponding two-line listener class. You can create events for Vector3, bool, GameState, or any custom struct.
Practical Example: Game State Changed Event
Let us wire up a real example. We will create an event channel for game state changes so that any system (UI, audio, spawner) can react when the game state changes — without a direct reference to the GameManager.
Step 1: Create a GameState Event Type
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Events
{
/// <summary>
/// An event that carries the new GameState value.
/// Used to broadcast state transitions globally.
/// </summary>
[CreateAssetMenu(
fileName = "NewGameStateEvent",
menuName = "Infinite Runner/Events/Game State Event",
order = 10)]
public class GameStateEvent : TypedGameEvent<GameState> { }
}
using InfiniteRunner.Core;
namespace InfiniteRunner.Events
{
/// <summary>
/// Listener for GameStateEvent.
/// </summary>
public class GameStateEventListener : TypedGameEventListener<GameState> { }
}
Step 2: Create the Event Asset
- In the Project window, create a folder:
Assets/ScriptableObjects/Events/ - Right-click inside that folder.
- Select Create > Infinite Runner > Events > Game State Event.
- Name it
OnGameStateChanged. - In the Inspector, add a description like "Fired whenever the game state changes. Carries the new GameState value."
Step 3: Update GameManager to Fire the Event
using UnityEngine;
using InfiniteRunner.Events;
namespace InfiniteRunner.Core
{
public class GameManager : Singleton<GameManager>
{
// Reference to the ScriptableObject event channel
[Header("Event Channels")]
[SerializeField] private GameStateEvent onGameStateChanged;
// ... (rest of the GameManager code from Chapter 6) ...
private void TransitionToState(GameState newState)
{
_currentState?.Exit();
CurrentState = newState;
_currentState = newState switch
{
GameState.Menu => _menuState,
GameState.Playing => _playingState,
GameState.Paused => _pausedState,
GameState.GameOver => _gameOverState,
_ => throw new System.ArgumentOutOfRangeException()
};
_currentState.Enter();
// Fire the ScriptableObject event channel
if (onGameStateChanged != null)
{
onGameStateChanged.Raise(newState);
}
// Also fire the C# event (for code-only listeners)
OnGameStateChanged?.Invoke(newState);
}
}
}
Step 4: Wire It Up in the Inspector
- Select the
[GameManager]GameObject in the Hierarchy. - In the Inspector, find the "Event Channels" section.
- Drag the
OnGameStateChangedScriptableObject asset into the "On Game State Changed" field.
Step 5: Create a Listener
Now any system can listen for state changes. For example, a simple UI panel toggler:
using UnityEngine;
using InfiniteRunner.Core;
using InfiniteRunner.Events;
namespace InfiniteRunner.UI
{
/// <summary>
/// Shows or hides a UI panel based on the game state.
/// Attach to any UI panel and set which states it should be visible in.
/// </summary>
public class GameStateUIToggle : MonoBehaviour
{
[Header("Event Channel")]
[SerializeField] private GameStateEvent onGameStateChanged;
[Header("Visible In These States")]
[SerializeField] private GameState[] visibleStates;
private void OnEnable()
{
if (onGameStateChanged != null)
{
// We cannot use the GameStateEventListener component here
// because we need custom logic. So we register manually
// using the C# event on GameManager instead.
GameManager.Instance.OnGameStateChanged += HandleStateChanged;
}
}
private void OnDisable()
{
if (GameManager.Instance != null)
{
GameManager.Instance.OnGameStateChanged -= HandleStateChanged;
}
}
private void HandleStateChanged(GameState newState)
{
bool shouldBeVisible = false;
foreach (GameState state in visibleStates)
{
if (state == newState)
{
shouldBeVisible = true;
break;
}
}
gameObject.SetActive(shouldBeVisible);
}
}
}
For simpler cases, you can skip writing a custom script entirely. Add a GameEventListener component to a GameObject, drag in the event asset, and use the UnityEvent dropdown to call built-in methods like GameObject.SetActive(true). No code needed!
How It All Connects
Here is the flow of communication in our event system:
BROADCASTER EVENT CHANNEL LISTENERS
(fires event) (ScriptableObject) (react to event)
+--------------+ +------------------+ +---------------+
| GameManager | ----> | OnGameState | ----> | UIManager |
| "state | Raise | Changed | Notify | "show/hide |
| changed!" | | (lives in Assets)| | panels" |
+--------------+ +------------------+ +---------------+
|
+----> +---------------+
| | AudioManager |
| | "change music"|
| +---------------+
|
+----> +---------------+
| SpawnManager |
| "start/stop |
| spawning" |
+---------------+
Key insight: GameManager does NOT know about UI, Audio, or Spawn.
They are completely decoupled via the ScriptableObject event channel.
Project Files After This Chapter
Assets/
Scripts/
Events/
GameEvent.cs <-- Basic void event (no data)
GameEventListener.cs <-- Listener for basic events
TypedGameEvent.cs <-- Generic base for typed events
TypedGameEventListener.cs <-- Generic base for typed listeners
IntEvent.cs <-- Event carrying an int
IntEventListener.cs <-- Listener for int events
FloatEvent.cs <-- Event carrying a float
FloatEventListener.cs <-- Listener for float events
StringEvent.cs <-- Event carrying a string
StringEventListener.cs <-- Listener for string events
GameStateEvent.cs <-- Event carrying a GameState
GameStateEventListener.cs <-- Listener for GameState events
ScriptableObjects/
Events/
OnGameStateChanged.asset <-- The actual event channel asset
Common Mistakes to Avoid
If you subscribe in OnEnable(), you MUST unsubscribe in OnDisable(). Forgetting this causes: (1) memory leaks, (2) errors when destroyed objects still receive events, and (3) events firing multiple times if an object is enabled/disabled repeatedly. The GameEventListener handles this automatically, which is one of its advantages.
Events do not guarantee the order in which listeners are called. If your AudioManager needs to react BEFORE the UIManager, do not rely on event ordering. Instead, have one listener handle timing-sensitive logic explicitly.
Events are powerful, but do not go overboard. Not every interaction needs an event channel. Use events for cross-system communication. Within a single system, direct method calls are fine and simpler.
What We Built
- Understood the problem of tight coupling and why events solve it.
- Learned C# delegates, events, and Action<T> from scratch.
- Compared UnityEvents vs C# events and when to use each.
- Built a complete ScriptableObject Event Channel system with:
GameEvent— void event (no data)TypedGameEvent<T>— generic base for data-carrying events- Concrete types:
IntEvent,FloatEvent,StringEvent,GameStateEvent - Matching listener components for each type
- Connected the GameManager to the event system for state change notifications.
In the next chapter, we will build the Input System that captures keyboard and touch input and converts it into game actions.
The event system is the communication backbone of our game. This is a big milestone worth saving.
git status
git add .
git commit -m "Implement event system with ScriptableObject channels"
Run git log --oneline to see your growing commit history. Each entry is a save point you can return to.