Game systems communicate constantly. An enemy death may affect score, currency, audio, analytics, achievements, tutorials, quests, and save data. One way to handle that communication is direct access: the sender knows the receivers and calls them explicitly. Another way is event-based communication: the sender announces what happened, and interested systems respond independently.
The difference is similar to two kinds of email. In the first case, John writes directly to Jane because he knows Jane’s address and knows exactly what Jane can do. In the second case, John writes to an agency: “To whom it may concern.” He does not know who will handle the request. He only knows where to send the message.
Dear Jane,
I am hosting dinner tonight. Could you make the mushroom-and-truffle pizza you are known for, with extra cheese, and bring it over by 7 PM?
That direct request relies on two pieces of knowledge:
- The sender knows the receiver’s address.
- The sender knows the receiver’s specific capability.
In code, direct access looks like this:
public class Enemy
{
private void OnDeath()
{
Singleton<ScoreSystem>.Instance.AddScore(score);
Singleton<CurrencySystem>.Instance.AddCurrency(worth);
}
}
This is easy to read, but Enemy now knows about ScoreSystem, CurrencySystem, and the exact methods each system exposes. The enemy is not just reporting that it died. It is telling specific systems what to do.
That directness is useful when the relationship is stable and intentional. It becomes harder to maintain when more systems need to react to the same event. Adding audio, achievements, analytics, or tutorial behavior should not require Enemy to know about every one of those systems.
Event-Based Communication
The second style appears when John does not know who should handle the request. Maybe he has a billing problem with a company. He does not know the accountant, support specialist, database operator, or automation script that will eventually resolve it. He only knows the address of the organization responsible for that kind of issue.
To the support team,
My account was charged twice for the same order. The order number is 1842. Could someone look into this?
John sends one message to one routing point. Inside the organization, several things may happen. Support may open a ticket, billing may issue a refund, an automated audit may mark the duplicate charge, and a notification system may send John a receipt. John does not need to know those internal receivers. He only needs to describe the situation clearly.
An event bus uses the same shape. The sender raises an event that describes what happened. Any number of systems can subscribe to that event. The sender depends on the event type and the bus, but not on the systems that respond.
The simplified implementation below is adapted from the same basic shape used by Adam Myhre’s Unity Event Bus: a static generic bus, event binding objects, and small event data types.
using System.Collections.Generic;
using System.Linq;
public interface IEvent
{
}
public static class EventBus<T> where T : IEvent
{
private static readonly HashSet<IEventBinding<T>> s_bindings = new();
public static void Register(IEventBinding<T> binding)
{
s_bindings.Add(binding);
}
public static void Deregister(IEventBinding<T> binding)
{
s_bindings.Remove(binding);
}
public static void Raise(T @event)
{
foreach (var binding in s_bindings.ToArray())
{
binding.OnEvent(@event);
}
}
}
EventBus<T> is static and generic. Each event type gets its own independent set of bindings. EventBus<EnemyDied> and EventBus<PlayerLeveledUp> do not share subscribers because they are different closed generic types.
Using a HashSet makes registration idempotent for the same binding instance. Registering the same binding twice still leaves only one subscriber entry.
The event itself should be small and descriptive. It represents something that happened, not a command telling a specific receiver what to do.
public readonly struct EnemyDied : IEvent
{
public readonly int Score;
public readonly int Worth;
public EnemyDied(int score, int worth)
{
Score = score;
Worth = worth;
}
}
Now Enemy can publish the fact that it died:
public class Enemy
{
private void OnDeath()
{
EventBus<EnemyDied>.Raise(new EnemyDied(score, worth));
}
}
The enemy no longer knows about score, currency, audio, analytics, or any other receiver. It only knows how to describe the event.
Subscribers
Systems that care about EnemyDied can subscribe independently. A score system can listen for the event and update score:
using UnityEngine;
public class ScoreSystem : MonoBehaviour
{
private EventBinding<EnemyDied> _binding;
private void OnEnable()
{
_binding = new EventBinding<EnemyDied>(HandleEnemyDied);
EventBus<EnemyDied>.Register(_binding);
}
private void OnDisable()
{
EventBus<EnemyDied>.Deregister(_binding);
}
private void HandleEnemyDied(EnemyDied enemyDied)
{
AddScore(enemyDied.Score);
}
private void AddScore(int amount)
{
// Update score state.
}
}
A currency system can react to the same event without Enemy changing:
using UnityEngine;
public class CurrencySystem : MonoBehaviour
{
private EventBinding<EnemyDied> _binding;
private void OnEnable()
{
_binding = new EventBinding<EnemyDied>(HandleEnemyDied);
EventBus<EnemyDied>.Register(_binding);
}
private void OnDisable()
{
EventBus<EnemyDied>.Deregister(_binding);
}
private void HandleEnemyDied(EnemyDied enemyDied)
{
AddCurrency(enemyDied.Worth);
}
private void AddCurrency(int amount)
{
// Update currency state.
}
}
If audio should play when an enemy dies, an audio system can subscribe too. If achievements should react later, an achievement system can subscribe later. The publisher remains stable because it does not know the subscriber list.
For completeness, here is the minimal binding type used by the examples:
using System;
public interface IEventBinding<T>
{
Action<T> OnEvent { get; }
}
public sealed class EventBinding<T> : IEventBinding<T> where T : IEvent
{
public Action<T> OnEvent { get; }
public EventBinding(Action<T> onEvent)
{
OnEvent = onEvent;
}
}
Event Bus and Singleton
Persistent singletons are convenient because they provide one global access point to one runtime owner. The cost is coupling. A class that reaches into several singletons needs to know exactly which systems it depends on, and those direct dependencies tend to grow over time.
An event bus reduces that pressure. The sender depends on EventBus<T> and the event type, not on every system that might respond. In the email metaphor, this is the difference between remembering every person’s address and sending a report to one routing desk.
That does not make event buses free. They can hide behavior if events are too broad, poorly named, or raised from too many places. A good event should describe a concrete domain fact, such as EnemyDied, InventoryItemAdded, or SceneTransitionStarted. Subscribers should register and deregister with their Unity lifecycle so stale listeners do not remain after objects are disabled or destroyed.
At its core, an event bus is a routing mechanism. Events are raised, systems listen, and the sender stays decoupled from the responders. Used carefully, that is enough to keep many game systems easier to extend without turning every gameplay object into a directory of global service calls.