
Event-Driven Architecture in ECS: When to Use an Event Bus
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:
1AttackSystem2 ↓3DamageSystem4 ↓5HitFlashSystem6 ↓7DamageNumberUISystemEach system needs to know which system comes next.
With an Event Bus, the flow becomes:
1AttackSystem2 ↓3Damage Request Event4 ↓5DamageSystem6 ↓7Damage Resolved Event8 ├── HitFlashSystem9 ├── DamageNumberUISystem10 └── HealthBarSystemThe 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:
1class AttackSystem {2 constructor(3 world,4 damageSystem,5 ) {6 this.world = world7 this.damageSystem = damageSystem8 }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:
1AttackSystem2 ↓3DamageSystem4 ├── DamageNumberUISystem5 ├── HitFlashSystem6 ├── HealthBarSystem7 ├── CombatLogSystem8 └── AudioSystemThe 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:
1Attack Requested2 ↓3Validate attack4 ↓5Damage resolvedThese 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:
1InputSystem2 ↓3ItemUseRequestSystem4 ↓5EVT_USE_ITEM_REQUEST6 ↓7ItemUseSystemThe request might contain:
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:
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 events21 }22}This gives us two basic operations.
Publish an event:
1eventBus.emit(2 'damage-resolved',3 {4 target,5 amount: 12,6 },7)Then consume it somewhere else:
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:
1eventBus.emit(2 'damage-resolved',3 payload,4)throughout the project, we can define event types centrally.
For example:
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:
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:
1AttackSystem2 ├── calculate damage3 ├── modify health4 ├── show damage number5 ├── flash target6 ├── show health bar7 ├── check death8 └── play soundThat is a lot of responsibility.
Instead, we can build a pipeline.
1Attack2 ↓3Damage Request4 ↓5DamageSystem6 ↓7Health updated8 ↓9EVT_DAMAGE_RESOLVED10 ├── DamageNumberUISystem11 ├── HitFlashSystem12 ├── HealthBarVisibilitySystem13 └── CombatLogSystemThe 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:
1{2 source: attacker,3 target,4 amount: 12,5 critical: false,6}Then a damage-number system can consume it:
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:
1EVT_DAMAGE_RESOLVEDalready 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:
1DamageSystem knows DamageNumberUISystemwe have:
1DamageSystem publishes DamageResolved2
3DamageNumberUISystem consumes DamageResolvedThose 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:
1DamageSystem2 ↓3emit EVT_DAMAGE_RESOLVED4 ↓5DamageNumberUISystemIf the damage system runs first, the UI system can display feedback immediately.
Conceptually:
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:
1emitNext(2 EVT_SOMETHING,3 payload,4)The flow becomes:
1Current Frame2
3System A4 ↓5emitNext6
7----------------8
9Next Frame10
11System B12 ↓13consumeThis 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:
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 events38 }39
40 beginFrame() {41 for (42 const [type, events]43 of this.next44 ) {45 if (!this.current.has(type)) {46 this.current.set(type, [])47 }48
49 this.current50 .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:
1DamageNumberUISystem2↓3DamageSystemand 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:
1DamageSystem2↓3DamageNumberUISystemThis 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:
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:
1EVT_DAMAGE_RESOLVEDshould 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:
1Health.current = 50That is persistent state.
1DamageResolved { amount: 20 }That is temporary information describing a change.
State vs Event
Consider an inventory.
This belongs in a component:
1class Inventory {2 constructor() {3 this.items = []4 }5}But when an item is picked up, we may emit:
1EVT_PICKUPThe inventory stores the lasting result.
The event describes what just happened.
Another example:
1FinalStats.attack = 10is state.
1EVT_STATS_CHANGEDtells 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:
1{2 target,3 amount,4}may be enough for a simple damage number.
But as combat becomes more advanced, consumers may need:
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:
1{2 target,3 amount: 12,4}But using the Event Bus as a permanent storage container for things like:
1current player health2current inventory3current equipment4current targetwould 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:
1EVT_GET_VELOCITYfollowed by:
1EVT_VELOCITY_RESPONSEThat 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:
1transform.position.x +=2 velocity.x * deltaTimedoes 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:
1EVT_POSITION_CHANGED2EVT_VELOCITY_CHANGED3EVT_MOUSE_MOVED4EVT_TRANSFORM_READ5EVT_INVENTORY_OPEN6EVT_INVENTORY_RENDER7EVT_HEALTH_READfor 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:
1Event A2 ↓3System B4 ↓5Event C6 ↓7System D8 ↓9Event E10 ↓11System FNow 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:
1EVT_DAMAGE_RESOLVED2EVT_USE_ITEM_REQUEST3EVT_STATS_CHANGED4EVT_PICKUPThese tell us what occurred.
Avoid vague names such as:
1EVT_UPDATE2EVT_CHANGED3EVT_ACTION4EVT_PROCESSThose 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:
1EVT_USE_ITEM_REQUESTis better than:
1EVT_USE_ITEMif the event has not been validated yet.
Similarly:
1EVT_ATTACK_REQUESTmeans:
Something wants an attack to happen.
While:
1EVT_ATTACK_RESOLVEDmeans:
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:
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:
1EVT_DAMAGE_RESOLVEDevent 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:
1Attack Requested2Ability Activated3Item Used4Target Changed5Interaction TriggeredA 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:
1Attack RequestIn a single-player game, the request can be resolved locally.
In multiplayer, the same gameplay intent may eventually become:
1Client2 ↓3Attack Request4 ↓5Server6 ↓7Validation8 ↓9Damage Resolution10 ↓11Replicated ResultThe 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:
1EVT_DAMAGE_RESOLVEDmay be useful inside the local simulation.
But the network layer might replicate:
1Health changed to 62or 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:
1EVT_USE_ITEM_REQUEST2player=1 slot=13
4EVT_DAMAGE_RESOLVED5source=1 target=7 amount=126
7EVT_STATS_CHANGED8entity=1 attack=10 defense=4This 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:
1INPUT2InputSystem3
4↓5
6REQUESTS7ItemUseRequestSystem8InteractionSystem9AbilityRequestSystem10
11↓12
13GAMEPLAY14ItemUseSystem15HealSystem16DamageSystem17StatsSystem18
19↓20
21RESULT EVENTS22EVT_DAMAGE_RESOLVED23EVT_STATS_CHANGED24EVT_PICKUP25
26↓27
28FEEDBACK29DamageNumberUISystem30HitFlashSystem31HealthBarUISystem32QuickbarUISystemThe 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:
1ENTITY2Who is involved?3
4COMPONENT5What state exists?6
7SYSTEM8What logic runs?9
10EVENT11What happened?12
13REQUEST EVENT14What does something want to happen?15
16RESULT EVENT17What actually happened?For example:
1Player presses quickbar slot 12
3↓4
5InputSystem6
7↓8
9EVT_USE_ITEM_REQUEST10
11↓12
13ItemUseSystem14
15↓16
17Inventory updated18
19↓20
21Heal event22
23↓24
25HealSystem26
27↓28
29Health updatedEach 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:
1MovementSystem2reads Velocity3writes TransformThere 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:
1DamageSystem2publishes DamageResolved3
4DamageNumberUISystem5reacts6
7HitFlashSystem8reacts9
10CombatLogSystem11reactsThe 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:
1System A calls System B2System B calls System C3System C calls System Dwe can build flows such as:
1Request2↓3Gameplay System4↓5Resolved Event6├── Feedback System7├── UI System8└── Logging SystemThat makes it easier to extend gameplay without constantly modifying the systems that originally produced the event.
The key distinction is:
1Components = state2
3Events = something happened4
5Request Events = something wants to happen6
7Result Events = something actually happenedEvents 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.

Learn why ECS system order matters and how to structure an update pipeline for input, movement, physics, combat, UI, cameras, and rendering.

Build a simple Entity Component System in JavaScript from scratch with entities, data-only components, queries, systems, and a working update loop.

Learn how to decide whether game logic belongs in an Entity, Component, or System when building an Entity Component System in JavaScript.