logo

Babylon.js Market

By Lawrence

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; and onForEntity, 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:

// BulletSystem.ts — the version that seems fine for about a week
import { EnemySystem } from './EnemySystem';

class BulletSystem extends System {
  constructor(eventBus: EventBus, private enemySystem: EnemySystem) {
    super(eventBus);
    this.query = { required: [BulletComponent] };
  }

  protected onUpdate(dt: number): void {
    for (const bullet of this.entities) {
      const hit = this.findOverlappingEnemy(bullet);
      if (hit) {
        // Reach into the other system and poke it
        this.enemySystem.applyDamage(hit.id, 10);
      }
    }
  }
}

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 hitsDirect call (this.enemySystem.applyDamage)Event (emit('enemy.hit'))
Unit test in isolationNo — you must build a real EnemySystem and its whole chain firstYes — 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 orderEnemySystem must exist before BulletSystem; you sort it by handNone — 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 enemiesPass 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 siteNothing 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 tooAdd 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.ts — knows nothing about who handles hits
protected onUpdate(dt: number): void {
  for (const bullet of this.entities) {
    const hit = this.findOverlappingEnemy(bullet);
    if (hit) {
      this.eventBus.emit('enemy.hit', { entityId: hit.id, damage: 10 });
    }
  }
}

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.

Event flow diagram on a dark background showing three glowing system nodes labeled BulletSystem, EnemySystem, and SoundSystem, each connected only by neon cyan lines to a central horizontal event bus rail labeled EventBus, with a small event packet labeled enemy.hit traveling along the rail, no lines connecting the systems to each other, dark navy background with neon green and magenta accents

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:

@babylonjsmarket/ecs/src/ECS/EventBus/EventBus.ts
emit<T = any>(type: string, data?: T): void {
  // Ring-buffer recording (debug overlays). One null-check when off, a
  // handful of slot writes when on — see _recordEmit. Stays out of the
  // wildcard/wrapper code path so the fast emit path is preserved.
  if (this._recordingBuffer !== null) this._recordEmit(type, data);
  // Check for duplicate events if tracking is enabled
  this.checkDuplicate(type, data);

  // Attribute this emit to the currently-running System (dev builds only).
  if (this._raceDetector !== null) {
    this._raceDetector.recordEvent(type, data);
  }

  // Fast path: no wildcard listeners - skip the event-wrapper allocation.
  if (this.wildcardListeners.size === 0) {
    this.processEventDirect(type, data);
    return;
  }

  // Wildcard listeners need the event wrapper. A re-entrant emit during
  // processing simply recurses through processEvent.
  const event = getPooledEvent(type, data);
  this.processEvent(event);
}

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:

@babylonjsmarket/ecs/src/ECS/EventBus/EventBus.ts
emitChanged<T = any>(type: string, data?: T): void {
  const dataKey = this.getDataKey(data);
  // If we can compute a key and it matches the last emission, skip
  if (dataKey !== null && this.lastEventData.get(type) === dataKey) {
    return; // Skip duplicate
  }
  // Store this emission's key for future comparison
  if (dataKey !== null) {
    this.lastEventData.set(type, dataKey);
  }
  this.emit(type, data);
}

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.

protected onUpdate(dt: number): void {
  // Call this every frame — the bus drops the 299 frames the score didn't move.
  this.eventBus.emitChanged('score.changed', { score: this.score });
}

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:

@babylonjsmarket/ecs/src/ECS/EventBus/EventBus.ts
onForEntity<T extends { entityId: string } = { entityId: string }>(
  type: string,
  entityId: string,
  callback: EventCallback<T>,
): UnsubscribeFn {
  if (!entityId) return () => { /* no-op */ };

  let byEntity = this.entityListeners.get(type);
  if (!byEntity) {
    byEntity = new Map();
    this.entityListeners.set(type, byEntity);
  }
  let callbacks = byEntity.get(entityId);
  if (!callbacks) {
    callbacks = new Set();
    byEntity.set(entityId, callbacks);
  }
  callbacks.add(callback as EventCallback);

  return () => {
    const callbacksRef = byEntity!.get(entityId);
    if (!callbacksRef) return;
    callbacksRef.delete(callback as EventCallback);
    // Prune empty inner sets/maps so the index doesn't grow forever as
    // entities get destroyed and recreated under fresh ids.
    if (callbacksRef.size === 0) {
      byEntity!.delete(entityId);
      if (byEntity!.size === 0) this.entityListeners.delete(type);
    }
  };
}

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:

// Fires only when player.id matches the emit's entityId — never for the other 199.
this.eventBus.onForEntity('position.changed', player.id, (data) => {
  this.movePlayerMarker(data.position);
});

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:

@babylonjsmarket/ecs/src/ECS/System/System.ts
protected listen<T = any>(type: string, callback: EventCallback<T>): UnsubscribeFn {
  return this.track(this.eventBus.on(type, callback));
}

/**
 * Subscribe to a single occurrence of an event (see EventBus.once),
 * auto-cleaned on system shutdown if it hasn't fired yet.
 */
protected listenOnce<T = any>(type: string, callback: EventCallback<T>): UnsubscribeFn {
  return this.track(this.eventBus.once(type, callback));
}

/**
 * Subscribe to an event for a single entity (see EventBus.onForEntity),
 * auto-cleaned on system shutdown.
 */
protected listenForEntity<T extends { entityId: string } = { entityId: string }>(
  type: string,
  entityId: string,
  callback: EventCallback<T>,
): UnsubscribeFn {
  return this.track(this.eventBus.onForEntity(type, entityId, callback));
}

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:

class EnemySystem extends System {
  protected onInitialize(): void {
    this.listen('enemy.hit', (data) => this.handleHit(data));
    this.listen('wave.started', (data) => this.spawnWave(data));
  }
  // No unsubscribe array, no onShutdown drain — the base System
  // disposes every listen() subscription when the system is removed.
}

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.

Dark background diagram of a broken event connection, a neon green node labeled ScoreSystem emitting an event labeled score.changed toward the right, and a neon magenta node labeled ScoreboardSystem listening on a separate disconnected line labeled score.updated, the two lines visibly failing to meet in the middle with a gap highlighted in red, caption-style label reading no error, no HUD update, dark navy background neon wireframe style

Two conventions stop this. The first is about grammar. It tells you an event's intent at a glance:

KindGrammarExamplesMeans
State changepast tensescore.changed, enemy.killed, brick.destroyed"this happened, react if you care" — zero listeners is fine
Direct requestimperative or *Requestscore.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:

@babylonjsmarket/arcade/src/Components/Score/Score.ts
export const ScoreEvents = {
  /** Fired when any player's score changes. */
  CHANGED: 'score.changed',
  /** Fired once when every bucket is cleared. */
  RESET: 'score.reset',
} as const;

export const ScoreInputEvents = {
  /** Direct request to award points. `{ ownerEntity, points? }`. */
  ADD: 'score.add',
  /** External request to clear every player's score. `{}`. */
  RESET: 'score.resetRequest',
  /**
   * Score also listens to goal.scored so Goal mechanic scenes just work out
   * of the box — no middle-man system needed to route points from the net
   * into the scoreboard.
   */
  GOAL_SCORED: 'goal.scored',
} as const;

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:

// Right: if ScoreSystem ever renames the event, this listener follows it automatically
import { ScoreEvents } from '@babylonjsmarket/arcade';
this.listen(ScoreEvents.CHANGED, (data) => this.refresh(data));

// Wrong: a hardcoded string at the listener site — this is how
// score.updated happened in the first place
this.listen('score.updated', (data) => this.refresh(data));

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:

EmitterEventTenseListener(s)
ball's moduleball.collidedpastBrickSystem
BrickSystembrick.destroyedpastScoreSystem, combo, sound
ScoreSystemscore.changedpastHUD
UI buttonscore.resetRequestrequestScoreSystem

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:

import { BallEvents } from './Ball';

export const BrickEvents = {
  DESTROYED: 'brick.destroyed',
} as const;

class BrickSystem extends System {
  protected onInitialize(): void {
    this.listen(BallEvents.COLLIDED, (data) => {
      const brick = this.world!.getEntity(data.entityId);
      if (!brick || !this.entities.has(brick)) return;
      this.eventBus.emit(BrickEvents.DESTROYED, {
        entityId: brick.id,
        points: brick.get(BrickComponent)!.points,
      });
      this.world!.removeEntity(brick);
    });
  }

  protected onUpdate(dt: number): void { /* ... */ }
}

ScoreSystem listens for things worth points and has no idea what a brick is. It imports BrickEvents for one string and counts:

import { BrickEvents } from './Brick';

class ScoreSystem extends System {
  private score = 0;

  protected onInitialize(): void {
    this.listen(BrickEvents.DESTROYED, (data) => {
      this.score += data.points;
      // emitChanged: a frame where the score didn't actually move never reaches the HUD
      this.eventBus.emitChanged(ScoreEvents.CHANGED, { score: this.score });
    });
  }

  protected onUpdate(dt: number): void { /* ... */ }
}

The HUD listens to ScoreEvents.CHANGED and repaints a number. It knows nothing about bricks or balls.

Neon flow diagram on dark background showing a left-to-right event chain, glowing node BrickSystem emitting brick.destroyed onto a horizontal cyan bus rail, node ScoreSystem consuming it and emitting score.changed back onto the rail, node HUD consuming score.changed and displaying a glowing score readout 4500, arrows only touch the central rail never each other, dark navy background with neon green cyan and orange accents, clean wireframe schematic style

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():

// UI code, outside any system
const bus = world.getEventBus();
document.querySelector('#reset')!.addEventListener('click', () => {
  bus.emit(ScoreInputEvents.RESET);
});

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.

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search