9 minutes
TL;DR — Systems work together by emitting named events onto a shared EventBusThe central messaging channel a system emits events onto and listens on, instead of calling another system directly.. They don't import and call each other. You get
this.listen(), whose subscriptions the base class auto-disposes on shutdown;emitChanged, which drops repeat payloads; andonForEntity, which routes to one entity. What's new here is pub/sub decouplingRemoving one piece of code's direct knowledge of another so each can change, test, or be absent independently. plus this project's event-naming rules: announce in past tense, request in the imperativePhrased as a command (do this) rather than a report (this happened); the grammar of a request event.. Every later lesson wires components together this way.
Last lesson, the query system kept each system's set filled live. Add a Frozen component and the UFO drops out of MoveSystem, with no branch anywhere. But a query only answers which entities a system acts on. It says nothing about how two systems work together. One question makes that gap clear. A bullet hits an enemy. How does the bullet system tell the enemy?
The shortest answer is wrong. But it's wrong in a useful way, so look at it first.
Why a direct reference between systems goes wrong
A referenceWhen one piece of code directly holds onto another and calls its methods, instead of going through the bus. here is when one system holds onto another and calls its methods. Picture a Space Invaders clone. BulletSystem moves its bullets up the screen and checks for overlaps. When a bullet touches an invader, something has to subtract health. The fastest path is to grab the enemy system and call it:
It runs. The hit lands, the health drops, and the demo ships. But that one import causes trouble in four ways at once.
The better way is to stop having one system grab another. Instead, a system sends a small named message onto a shared channel called the EventBus. Any system that cares listens for it. Systems announce what happened; they don't give orders. Put the two approaches side by side. The right column is that announcement:
| When a bullet hits | Direct call (this.enemySystem.applyDamage) | Event (emit('enemy.hit')) |
|---|---|---|
| Unit test in isolation | No — you must build a real EnemySystem and its whole chain first | Yes — emitTo announce an event by name onto the bus, optionally with a data payload, for any listener to react to. into a bare bus, assert what comes out |
| Construction order | EnemySystem must exist before BulletSystem; you sort it by hand | None — emit before any listenerCode that has subscribed to an event name and runs when that event fires. exists and it's fine |
| A level with no enemies | Pass a stubA throwaway stand-in object you pass in place of a real dependency so the code under test has something to call. or null, then guard every call site | Nothing listens; the emit is a no-opAn operation that does nothing — here, an emit with no listeners simply has no effect. |
| Add a second reactor (sound, combo) | Edit BulletSystem to call that one too | Add a listener; BulletSystem never changes |
Every cell on the left is extra work the import created. Replace the call with one line and all four problems go away:
BulletSystem now reports what happened and moves on. Maybe an EnemySystem reacts. Maybe a SoundSystem plays a hit sound. Maybe nothing is listening. Either way, it's no longer the bullet's concern.

The emit method you'll use most often
Every system already holds this.eventBus. The World hands it over when the system is built. The default way to send an event is emit. Here it is from source:
Most of that is debug bookkeeping you can ignore. The one line that does the work is processEventDirect(type, data). It hands the event to every listener. emit runs synchronously. By the time the line after your emit runs, every listener has already fired. Here's the case worth knowing. A listener can emit during dispatch. enemy.hit fires, a listener emits enemy.killed, and its listener emits something else. Each nested emit runs right away, recursively, depth-first. It finishes before the outer listener loop moves on. So a one-way chain finishes inside the first emit, before control returns to your code. But the bus won't save you from two listeners that emit at each other. It has no loop-breaker. The two emits keep calling each other until the stack overflows. Chains are fine. Cycles never are.
Two relatives of emit are quieter but still useful. The first is emitChanged:
It remembers a key for the last payloadThe data object carried alongside an event name, passed to every listener's callback. it sent under each name. When the new payload matches, it drops the emit. So the emitter side can stay simple. Call it every single frame and let the bus skip the repeats.
If your score holds at 4,500 for three hundred frames, the HUDHeads-up display, the on-screen overlay that shows live game state like score or health. hears about it once. One caveat. emitChanged dedupes by making a JSON key from the payload. Functions and class instances get dropped, and a payload that can't be turned into a string never dedupes. So "call it every frame" only works for plain-data payloads. That's exactly what entity-scoped events should carry anyway. There's also emitImmediate. It dispatches the same way, but it skips the dev-build duplicate-tracking and race-attribution bookkeeping that emit does. It still recurses on re-entry, so it bets that no listener will emit back at it.
Listening for one entity's events
The other quiet method is on the listening side. A plain subscription fires for every emit of its name. Say 200 Galaga enemies emit position.changed each frame, and your HUD only tracks the player. A plain listener runs 200 times and ignores 199 of them. onForEntity indexes the subscription by entity id, so the other 199 emits never reach it:
Most of that code just builds the lookup. What matters is the (type, entityId) key. Store the callbackA function you hand to something else to call back later — here, the function the bus runs when an event fires. under that key, and an emit for npc-42 never touches a listener registered for hero. No callback runs. No comparison happens. You subscribeTo register a callback that the bus runs whenever an event of a given name is emitted. to it like this:
There's one requirement. The emitter must put entityId in the payload. That's why every entity-scoped event in this course carries one. Without it, an entity-routed listener has no key to match, so it never fires.
Cleaning up subscriptions with this.listen()
There's a trap on the subscribe side, and it's easy to miss. A subscription is not torn down when a system is removed. The bus still holds your callback. The callback still holds onto your system. Both keep running long after world.removeSystem(). Restart a level three times, and three leftover ScoreSystem copies all respond to every score.add.
That leak is why System gives you this.listen() instead of letting you reach for this.eventBus.on(). A listen() subscription is tied to the system's lifetime. The base class disposes every one of them on shutdown. The three wrappers are small. Each one registers on the bus and tracks the unsubscribeTo remove a previously-registered listener so its callback stops firing. for you:
listen wraps on, listenOnce wraps once, and listenForEntity wraps onForEntity. That last one is the entity-scoped subscriber from the previous section, now auto-cleaning. Subscribe from onInitialize and you're done. There's no unsubscribe array to maintain and no onShutdown drain to write:
One rule. Don't reach for raw this.eventBus.on() inside a system. That subscription is untracked, so it leaks. An eslintThe standard JavaScript/TypeScript linter that flags code-pattern violations before they ship. rule flags it so it never makes it past review. Outside a system, where you handle the teardown yourself, eventBus.on is the right choice.
Naming events so mismatches can't slip through
The bus is keyed by raw strings. That's a strength: there are no shared types to wire up, and any system can talk to any other. But it's also a risk. When two systems disagree on a string, nothing happens. No exception, no warning, no red in the console. The emitter sends its event, and no one is listening for that name.
This bug has actually shipped. A ScoreboardSystem subscribed to score.updated. The ScoreSystem emitted score.changed. Both names look fine. Both systems passed their tests. The game ran with no errors while the HUD sat at zero and the real score climbed behind it. You can lose an hour to this, hunting for broken code that doesn't exist. The real problem is two words that mean the same thing but don't match.

Two conventions stop this. The first is about grammar. It tells you an event's intent at a glance:
| Kind | Grammar | Examples | Means |
|---|---|---|---|
| State change | past tense | score.changed, enemy.killed, brick.destroyed | "this happened, react if you care" — zero listeners is fine |
| Direct request | imperative or *Request | score.add, score.resetRequest | "please do this" — aimed at the one system that owns it |
enemy.killed is news. score.add is a command. Keep the two kinds apart. If the codebase already says changed, then score.updated is wrong. It means the same thing but won't connect.
The second convention removes the problem at the source. Declare each event name once, in a constant, next to the system that owns it. Events a system emits live in {Name}Events. Events it listens for live in {Name}InputEvents. The Score component ships exactly this:
Two details in that block are worth a note. The ADD payload names its target ownerEntity, not the entityId used elsewhere. Score-targeting keys off the score owner, while the entity-routed events from earlier match on entityId. Also look at the two RESET members. ScoreEvents.RESET (score.reset) is the past-tense broadcast that a reset happened. ScoreInputEvents.RESET (score.resetRequest) is the command asking for one. Same member name, opposite directions, so import the one whose direction you mean.
Every other file imports the constant instead of retyping the string:
Import the constant, and a rename becomes a refactor your editor follows for you. It won't turn into a silent disconnect you find during playtest.
Three decoupled systems in Breakout
Watch the pattern run from start to finish. Take a BreakoutThe classic arcade game where a ball knocks out a wall of bricks; used here as a three-system worked example. brick. The ball destroys it, the score goes up, and the HUD redraws. The real test is that none of the three systems imports another. They only import each other's event constants. Here's who fires what:
| Emitter | Event | Tense | Listener(s) |
|---|---|---|---|
| ball's module | ball.collided | past | BrickSystem |
BrickSystem | brick.destroyed | past | ScoreSystem, combo, sound |
ScoreSystem | score.changed | past | HUD |
| UI button | score.resetRequest | request | ScoreSystem |
No row knows the row two below it. BrickSystem owns bricks. When a brick's hit points run out, it reports what happened and removes the entity. It imports BallEvents.COLLIDED for the string, and nothing else about the ball:
ScoreSystem listens for things worth points and has no idea what a brick is. It imports BrickEvents for one string and counts:
The HUD listens to ScoreEvents.CHANGED and repaints a number. It knows nothing about bricks or balls.

Now stretch it. Add a combo system that doubles points. It subscribes to BrickEvents.DESTROYED too, and BrickSystem doesn't change one character. You can also unit testAn automated test that exercises one piece of code in isolation from the rest of the program. ScoreSystem against a bare EventBus, with no bricks, no balls, and no Babylon. The systems never learned each other's names, only their events' names.
Reacting to game events outside the canvas
Everything so far ran inside a system. Sooner or later, code outside any System needs to join in. Think of a DOMThe Document Object Model, the browser's live tree of page elements your JavaScript reads and changes. button, a pause overlay, or an incoming network message. The world's eventBus field is protectedA class member only the class and its subclasses can reach — outside code can't touch it directly., so only the world and its subclasses can touch it. The way in for outside code is world.getEventBus():
The button doesn't reach into ScoreSystem and zero out a field. It doesn't know ScoreSystem exists. It files a resetRequest and trusts whoever owns the score to handle it. The same approach that decoupled your systems from each other also decouples your UI from all of them.
Announce in past tense, request in the imperative, and declare every name once. Next we stop building all of this in code. The entities, their components, and their starting values move into a JSON file. The framework reads that file and builds the world from it.
Next: Scenes as Data — your whole level becomes a JSON file of entities and components. ArcadeGame boots it in four lines, and you can edit it without touching a line of TypeScript.