AR
AgentRuss
ECS event bus diagram showing gameplay systems communicating through events instead of direct system-to-system calls.
AgentRuss Guide

Event-Driven Architecture in ECS: When to Use an Event Bus

Written by
AgentRuss
Published

As an ECS grows, one problem appears very quickly:

How should systems communicate with each other?

At first, the obvious solution is often to make one system call another directly.

For example, an attack system might call a damage system, which calls a hit-flash system, which calls a UI system.

That can work for a while.

But over time, those direct references create tight coupling.

Systems begin to know too much about each other, and adding one new feature can mean editing several unrelated systems.

An Event Bus gives us another option.

Instead of one system calling another directly, a system can publish an event describing what happened or what has been requested.

Other systems can react to that event independently.

In this guide, we'll look at how event-driven architecture fits into ECS, when events are useful, when they are not, and how to avoid turning an Event Bus into a different kind of mess.


The Core Idea

Without events, communication might look like this:

JavaScript
1AttackSystem
2
3DamageSystem
4
5HitFlashSystem
6
7DamageNumberUISystem

Each system needs to know which system comes next.

With an Event Bus, the flow becomes:

JavaScript
1AttackSystem
2
3Damage Request Event
4
5DamageSystem
6
7Damage Resolved Event
8 ├── HitFlashSystem
9 ├── DamageNumberUISystem
10 └── HealthBarSystem

The important difference is that DamageSystem does not need direct references to the systems reacting to the result.

It only publishes:

Damage was resolved.

Any interested system can listen for that event.


Why Direct System Calls Become a Problem

Imagine we start with this:

JavaScript
1class AttackSystem {
2 constructor(
3 world,
4 damageSystem,
5 ) {
6 this.world = world
7 this.damageSystem = damageSystem
8 }
9
10 attack(attacker, target) {
11 this.damageSystem.applyDamage(
12 attacker,
13 target,
14 10,
15 )
16 }
17}

This seems reasonable.

Now we want damage numbers.

We might change DamageSystem so it needs DamageNumberUISystem.

Then we want hit flashes.

Now it needs HitFlashSystem.

Then health bars.

Then combat logging.

Then sound effects.

Eventually the dependency chain becomes something like:

JavaScript
1AttackSystem
2
3DamageSystem
4 ├── DamageNumberUISystem
5 ├── HitFlashSystem
6 ├── HealthBarSystem
7 ├── CombatLogSystem
8 └── AudioSystem

The damage system now knows about a large part of the game.

That makes it harder to:

  • test in isolation
  • reuse
  • reorder systems
  • remove features
  • add features
  • prepare for networking
  • understand dependencies

The problem is not that systems communicate.

The problem is that the communication creates unnecessary knowledge between them.


Events Describe Something That Happened

A useful mental model is:

  • Components describe state
  • Events describe something that happened

For example:

Health is state.

DamageResolved is an event.

Inventory is state.

ItemPickedUp is an event.

Equipment is state.

ItemEquipped is an event.

FinalStats is state.

StatsChanged is an event.

This distinction is useful because events are usually temporary.

A component might exist for minutes or hours.

An event may only matter for one frame.


Requests and Results Are Different Events

One of the most useful patterns is separating requests from resolved outcomes.

For example:

JavaScript
1Attack Requested
2
3Validate attack
4
5Damage resolved

These represent different things.

A request means:

Something wants this action to happen.

A resolved event means:

The action actually happened.

That difference becomes important when gameplay rules can reject a request.


Example: Using an Item

Suppose the player presses quickbar slot 1.

A simple approach might directly use the item inside the input system.

But that mixes input handling with inventory logic.

A cleaner flow is:

JavaScript
1InputSystem
2
3ItemUseRequestSystem
4
5EVT_USE_ITEM_REQUEST
6
7ItemUseSystem

The request might contain:

JavaScript
1{
2 entity,
3 slot: 1,
4}

Then ItemUseSystem can decide whether the action is valid.

It might check:

  • does the slot contain an item?
  • does the inventory contain enough of it?
  • is the item usable?
  • is the player allowed to use it now?
  • should it be consumed?

Only after validation does the actual gameplay effect occur.

This keeps the input layer separate from gameplay rules.


Why Separate ItemUseRequestSystem and ItemUseSystem?

This separation can look unnecessary at first.

Why not just combine both systems?

For a small project, you absolutely could.

But the two systems answer different questions.

ItemUseRequestSystem asks:

Did someone request to use an item?

ItemUseSystem asks:

Is that request valid, and what happens if it succeeds?

That separation becomes valuable when requests can come from more than one place.

For example:

  • keyboard input
  • mouse input
  • controller input
  • inventory UI
  • AI
  • scripted events
  • multiplayer commands

All of them can eventually create the same EVT_USE_ITEM_REQUEST.

The item-use logic does not care where the request came from.


A Minimal Event Bus

A simple Event Bus can be surprisingly small.

For example:

JavaScript
1export class EventBus {
2 constructor() {
3 this.events = new Map()
4 }
5
6 emit(type, payload) {
7 if (!this.events.has(type)) {
8 this.events.set(type, [])
9 }
10
11 this.events.get(type).push(payload)
12 }
13
14 consume(type) {
15 const events =
16 this.events.get(type) ?? []
17
18 this.events.set(type, [])
19
20 return events
21 }
22}

This gives us two basic operations.

Publish an event:

JavaScript
1eventBus.emit(
2 'damage-resolved',
3 {
4 target,
5 amount: 12,
6 },
7)

Then consume it somewhere else:

JavaScript
1const events =
2 eventBus.consume(
3 'damage-resolved',
4 )
5
6for (const event of events) {
7 console.log(
8 event.target,
9 event.amount,
10 )
11}

This is not a production-ready Event Bus yet, but it demonstrates the concept.


Use Constants for Event Types

String event names are easy to mistype.

Instead of writing:

JavaScript
1eventBus.emit(
2 'damage-resolved',
3 payload,
4)

throughout the project, we can define event types centrally.

For example:

JavaScript
1export const EVT_DAMAGE_RESOLVED =
2 'damage-resolved'
3
4export const EVT_USE_ITEM_REQUEST =
5 'use-item-request'
6
7export const EVT_STATS_CHANGED =
8 'stats-changed'
9
10export const EVT_PICKUP =
11 'pickup'

Then:

JavaScript
1eventBus.emit(
2 EVT_DAMAGE_RESOLVED,
3 payload,
4)

This keeps event names consistent and makes it easier to find where an event is produced or consumed.


A Damage Pipeline Example

Damage is a good example of event-driven architecture.

Without events, an attack system might be responsible for too much:

JavaScript
1AttackSystem
2 ├── calculate damage
3 ├── modify health
4 ├── show damage number
5 ├── flash target
6 ├── show health bar
7 ├── check death
8 └── play sound

That is a lot of responsibility.

Instead, we can build a pipeline.

JavaScript
1Attack
2
3Damage Request
4
5DamageSystem
6
7Health updated
8
9EVT_DAMAGE_RESOLVED
10 ├── DamageNumberUISystem
11 ├── HitFlashSystem
12 ├── HealthBarVisibilitySystem
13 └── CombatLogSystem

The damage system only needs to resolve damage correctly.

It does not need to know how the result is presented.


Example Damage Event

A resolved damage event might contain:

JavaScript
1{
2 source: attacker,
3 target,
4 amount: 12,
5 critical: false,
6}

Then a damage-number system can consume it:

JavaScript
1const events =
2 this.world.events.consume(
3 EVT_DAMAGE_RESOLVED,
4 )
5
6for (const event of events) {
7 this.spawnDamageNumber(
8 event.target,
9 event.amount,
10 )
11}

A hit-flash system can react to the same kind of event independently.

The damage system does not need to know either one exists.


One Event Can Trigger Many Reactions

This is one of the biggest advantages of an event-driven design.

Suppose:

JavaScript
1EVT_DAMAGE_RESOLVED

already drives:

  • floating damage numbers
  • hit flash
  • temporary health-bar visibility

Later, we decide to add:

  • controller vibration
  • combat text log
  • sound effects
  • analytics
  • quest tracking

We may not need to modify DamageSystem at all.

We can simply add another consumer.

That makes the architecture easier to extend.


Events Reduce Coupling, Not Dependencies

An Event Bus does not magically remove dependencies from the game.

For example, DamageNumberUISystem still depends on EVT_DAMAGE_RESOLVED existing.

That is a dependency.

The difference is that the dependency is now on a message contract rather than a direct system reference.

Instead of:

JavaScript
1DamageSystem knows DamageNumberUISystem

we have:

JavaScript
1DamageSystem publishes DamageResolved
2
3DamageNumberUISystem consumes DamageResolved

Those systems no longer need to know about each other directly.


Same-Frame Events

One design decision is when events become visible.

A same-frame event can be emitted by one system and consumed by another later in the same update.

For example:

JavaScript
1DamageSystem
2
3emit EVT_DAMAGE_RESOLVED
4
5DamageNumberUISystem

If the damage system runs first, the UI system can display feedback immediately.

Conceptually:

JavaScript
1emit(type, payload)

places the event into the current frame's queue.

This works well for immediate reactions such as:

  • damage numbers
  • hit flashes
  • sound effects
  • UI updates

Next-Frame Events

Sometimes we want an event to become available only on the next frame.

We might represent that with something like:

JavaScript
1emitNext(
2 EVT_SOMETHING,
3 payload,
4)

The flow becomes:

JavaScript
1Current Frame
2
3System A
4
5emitNext
6
7----------------
8
9Next Frame
10
11System B
12
13consume

This can be useful when allowing another system to react immediately would create inconsistent state during the current update.

It also gives us a clean way to defer work.


Why Have Current and Next Queues?

A slightly more capable Event Bus might maintain two queues:

JavaScript
1export class EventBus {
2 constructor() {
3 this.current = new Map()
4 this.next = new Map()
5 }
6
7 emit(type, payload) {
8 this.push(
9 this.current,
10 type,
11 payload,
12 )
13 }
14
15 emitNext(type, payload) {
16 this.push(
17 this.next,
18 type,
19 payload,
20 )
21 }
22
23 push(queue, type, payload) {
24 if (!queue.has(type)) {
25 queue.set(type, [])
26 }
27
28 queue.get(type).push(payload)
29 }
30
31 consume(type) {
32 const events =
33 this.current.get(type) ?? []
34
35 this.current.set(type, [])
36
37 return events
38 }
39
40 beginFrame() {
41 for (
42 const [type, events]
43 of this.next
44 ) {
45 if (!this.current.has(type)) {
46 this.current.set(type, [])
47 }
48
49 this.current
50 .get(type)
51 .push(...events)
52 }
53
54 this.next.clear()
55 }
56}

Now we can distinguish between:

  • react during this frame
  • react during the next frame

That makes event timing explicit.


Event Timing and System Order Work Together

Events do not remove the need to think about system order.

Suppose:

JavaScript
1DamageNumberUISystem
2
3DamageSystem

and both use same-frame events.

The UI system checks for damage events first.

There are none.

Then DamageSystem emits one.

The UI does not see it until the next frame.

If immediate feedback is expected, the correct order is:

JavaScript
1DamageSystem
2
3DamageNumberUISystem

This is why system ordering and event architecture need to be designed together.


Begin Frame and End Frame

A structured Event Bus often has explicit frame boundaries.

For example:

JavaScript
1world.events.beginFrame()
2
3world.update(deltaTime)
4
5world.events.endFrame()

beginFrame() might:

  • promote next-frame events
  • clear temporary frame state
  • prepare queues

endFrame() might:

  • discard consumed events
  • clear temporary data
  • move deferred events

The exact implementation can vary.

The important part is that event lifetime is predictable.


Events Should Usually Be Short-Lived

An event describes something that happened.

That usually means it should not remain forever.

For example:

JavaScript
1EVT_DAMAGE_RESOLVED

should not still be sitting in the event queue ten seconds later.

If information needs to persist, it probably belongs in component state.

A useful distinction is:

JavaScript
1Health.current = 50

That is persistent state.

JavaScript
1DamageResolved { amount: 20 }

That is temporary information describing a change.


State vs Event

Consider an inventory.

This belongs in a component:

JavaScript
1class Inventory {
2 constructor() {
3 this.items = []
4 }
5}

But when an item is picked up, we may emit:

JavaScript
1EVT_PICKUP

The inventory stores the lasting result.

The event describes what just happened.

Another example:

JavaScript
1FinalStats.attack = 10

is state.

JavaScript
1EVT_STATS_CHANGED

tells interested systems that those values changed.

That distinction is a useful guide when deciding whether something should become an event.


Events Should Carry Enough Information

An event should usually include the information consumers actually need.

For example:

JavaScript
1{
2 target,
3 amount,
4}

may be enough for a simple damage number.

But as combat becomes more advanced, consumers may need:

JavaScript
1{
2 source,
3 target,
4 amount,
5 damageType,
6 critical,
7 abilityId,
8}

The goal is not to include everything imaginable.

It is to create a useful event contract.

A consumer should not have to perform unrelated world lookups just because the event is missing obvious context.


Avoid Putting Mutable Game State Inside Events

Events should generally describe the event rather than becoming another long-lived storage location.

For example, this is reasonable:

JavaScript
1{
2 target,
3 amount: 12,
4}

But using the Event Bus as a permanent storage container for things like:

JavaScript
1current player health
2current inventory
3current equipment
4current target

would blur the distinction between state and events.

Those values belong in components.


When Not to Use an Event

Events are useful, but they should not replace every direct component interaction.

Suppose MovementSystem needs Velocity.

It should simply read the component.

We do not need:

JavaScript
1EVT_GET_VELOCITY

followed by:

JavaScript
1EVT_VELOCITY_RESPONSE

That would add complexity without providing useful decoupling.

Likewise, if a system is directly responsible for a piece of state, it can usually modify that component directly.

For example:

JavaScript
1transform.position.x +=
2 velocity.x * deltaTime

does not need an event.


Use Events for Meaningful Gameplay Boundaries

Events are especially useful when something crosses a responsibility boundary.

Good examples include:

  • item requested
  • item used
  • damage resolved
  • entity died
  • item picked up
  • checkpoint activated
  • stats changed
  • interaction requested
  • ability activated

These represent meaningful gameplay facts or requests.

They are useful because multiple parts of the game may care about them.


Don't Turn Everything Into an Event

An Event Bus can become addictive.

Soon you may be tempted to write:

JavaScript
1EVT_POSITION_CHANGED
2EVT_VELOCITY_CHANGED
3EVT_MOUSE_MOVED
4EVT_TRANSFORM_READ
5EVT_INVENTORY_OPEN
6EVT_INVENTORY_RENDER
7EVT_HEALTH_READ

for every tiny operation.

That can make the architecture harder to understand than direct component access.

A useful question is:

Does another independent responsibility genuinely need to react to this?

If not, an event may not be necessary.


Avoid Event Spaghetti

Direct calls can create tightly coupled code.

But excessive events can create the opposite problem:

You can no longer tell what causes anything.

Imagine an event chain like:

JavaScript
1Event A
2
3System B
4
5Event C
6
7System D
8
9Event E
10
11System F

Now changing one event can trigger unexpected behaviour across the engine.

This is sometimes called event spaghetti.

The solution is not to avoid events.

The solution is to use them deliberately.


Keep Event Names Meaningful

Good event names describe gameplay meaning.

For example:

JavaScript
1EVT_DAMAGE_RESOLVED
2EVT_USE_ITEM_REQUEST
3EVT_STATS_CHANGED
4EVT_PICKUP

These tell us what occurred.

Avoid vague names such as:

JavaScript
1EVT_UPDATE
2EVT_CHANGED
3EVT_ACTION
4EVT_PROCESS

Those become difficult to understand once the project grows.


Requests Should Usually Say Request

If an event represents intent rather than a completed action, make that clear.

For example:

JavaScript
1EVT_USE_ITEM_REQUEST

is better than:

JavaScript
1EVT_USE_ITEM

if the event has not been validated yet.

Similarly:

JavaScript
1EVT_ATTACK_REQUEST

means:

Something wants an attack to happen.

While:

JavaScript
1EVT_ATTACK_RESOLVED

means:

The attack was successfully processed.

Naming events this way makes the pipeline easier to reason about.


Events Can Improve Testing

Event-driven systems can also be easier to test.

Suppose we want to test ItemUseSystem.

Instead of simulating keyboard input, we can publish:

JavaScript
1events.emit(
2 EVT_USE_ITEM_REQUEST,
3 {
4 entity: player,
5 slot: 1,
6 },
7)

Then run the system and inspect the result.

Likewise, a UI system can be tested by feeding it a fake:

JavaScript
1EVT_DAMAGE_RESOLVED

event without needing a real combat simulation.

The event becomes a clean boundary for testing behaviour.


Events Can Help With Replays

Events can also be useful when building replay systems.

Imagine recording meaningful gameplay events:

JavaScript
1Attack Requested
2Ability Activated
3Item Used
4Target Changed
5Interaction Triggered

A replay system may be able to feed those inputs back into a deterministic simulation.

Not every event needs to be recorded, but an event-driven architecture can make important gameplay actions easier to observe.


Events and Multiplayer

Events also fit naturally into multiplayer architecture.

A local input might produce:

JavaScript
1Attack Request

In a single-player game, the request can be resolved locally.

In multiplayer, the same gameplay intent may eventually become:

JavaScript
1Client
2
3Attack Request
4
5Server
6
7Validation
8
9Damage Resolution
10
11Replicated Result

The networking layer changes, but the separation between:

  • intent
  • validation
  • resolution

remains valuable.

This is one reason request-based architecture can make future multiplayer work easier.


Don't Send Every Internal Event Over the Network

Using events internally does not mean every event should become a network message.

For example:

JavaScript
1EVT_DAMAGE_RESOLVED

may be useful inside the local simulation.

But the network layer might replicate:

JavaScript
1Health changed to 62

or a compact combat result instead.

Events are an architectural tool.

They do not automatically define your networking protocol.


Events Can Improve Debugging

An Event Bus can also become a useful debugging surface.

During development, we can log events such as:

JavaScript
1EVT_USE_ITEM_REQUEST
2player=1 slot=1
3
4EVT_DAMAGE_RESOLVED
5source=1 target=7 amount=12
6
7EVT_STATS_CHANGED
8entity=1 attack=10 defense=4

This creates a timeline of gameplay activity.

When something fails, you can ask:

Was the request emitted?
Did the resolving system consume it?
Was the result event emitted?

That can make multi-system bugs much easier to trace.


A Practical Event Pipeline

A larger ECS might use flows such as:

JavaScript
1INPUT
2InputSystem
3
4
5
6REQUESTS
7ItemUseRequestSystem
8InteractionSystem
9AbilityRequestSystem
10
11
12
13GAMEPLAY
14ItemUseSystem
15HealSystem
16DamageSystem
17StatsSystem
18
19
20
21RESULT EVENTS
22EVT_DAMAGE_RESOLVED
23EVT_STATS_CHANGED
24EVT_PICKUP
25
26
27
28FEEDBACK
29DamageNumberUISystem
30HitFlashSystem
31HealthBarUISystem
32QuickbarUISystem

The important idea is that events create boundaries between responsibilities.


A Simple Decision Checklist

When you're deciding whether to use an event, ask:

Is this persistent state?

Use a Component.

Is this something that happened?

An Event may be appropriate.

Is this a request that may fail validation?

A request event can be useful.

Do several independent systems need to react?

An Event is often a good fit.

Is one system simply reading data it already owns or queries?

Use direct component access.

Am I creating an event just to avoid writing a normal method or component lookup?

You may be overusing events.


A Useful Mental Model

The relationship between ECS pieces can be summarized like this:

JavaScript
1ENTITY
2Who is involved?
3
4COMPONENT
5What state exists?
6
7SYSTEM
8What logic runs?
9
10EVENT
11What happened?
12
13REQUEST EVENT
14What does something want to happen?
15
16RESULT EVENT
17What actually happened?

For example:

JavaScript
1Player presses quickbar slot 1
2
3
4
5InputSystem
6
7
8
9EVT_USE_ITEM_REQUEST
10
11
12
13ItemUseSystem
14
15
16
17Inventory updated
18
19
20
21Heal event
22
23
24
25HealSystem
26
27
28
29Health updated

Each stage has a clear responsibility.


Event Bus vs Direct Communication

Neither approach is universally better.

Use direct component access when the relationship is simple and local.

For example:

JavaScript
1MovementSystem
2reads Velocity
3writes Transform

There is no reason to add an Event Bus there.

Use events when you want to communicate a meaningful request or outcome across responsibilities.

For example:

JavaScript
1DamageSystem
2publishes DamageResolved
3
4DamageNumberUISystem
5reacts
6
7HitFlashSystem
8reacts
9
10CombatLogSystem
11reacts

The goal is not maximum decoupling.

The goal is useful decoupling.


Event-Driven Architecture Still Needs Discipline

An Event Bus is not a substitute for good architecture.

You still need to think about:

  • system responsibilities
  • event naming
  • event lifetime
  • system order
  • current-frame vs next-frame behaviour
  • payload design
  • ownership of persistent state

Used carefully, events make those boundaries clearer.

Used everywhere without structure, they can make the game harder to understand.


Conclusion

An Event Bus gives ECS systems a way to communicate without creating unnecessary direct dependencies.

Instead of:

JavaScript
1System A calls System B
2System B calls System C
3System C calls System D

we can build flows such as:

JavaScript
1Request
2
3Gameplay System
4
5Resolved Event
6├── Feedback System
7├── UI System
8└── Logging System

That makes it easier to extend gameplay without constantly modifying the systems that originally produced the event.

The key distinction is:

JavaScript
1Components = state
2
3Events = something happened
4
5Request Events = something wants to happen
6
7Result Events = something actually happened

Events are especially useful for gameplay boundaries such as damage, item use, interaction, pickups, stats changes, death, and abilities.

But they should not replace ordinary component access or become a message for every tiny state change.

A good Event Bus reduces coupling while keeping the flow of the game understandable.

And as an ECS grows into more complex combat, UI, status effects, AI, replays, testing, and multiplayer, those clean communication boundaries become increasingly valuable.