Architecture guide · TypeScript · renderer independent

Use a type-safe event bus without turning your game into invisible spaghetti.

Events are useful when one gameplay fact has optional, one-to-many reactions. They are harmful when they replace every direct call or become a hidden state database. Define a typed contract, keep delivery semantics small, own subscription lifecycles, and make event flow observable.

Choose an event, command, or store by the question being asked.

Event: what happened?

enemy:defeated reports a completed fact. Quests, audio, analytics, achievements, and UI may observe it.

Command: please attempt this

inventory.addItem() has one owner, validates rules, and can return success, overflow, or failure.

State: what is true now?

A quest store answers which objectives are active. Do not reconstruct current state by replaying incidental UI events.

Effect reference: run registered behavior

Serializable content can name an effect, while a registry resolves the game-owned implementation explicitly.

Event-driven architecture decouples producers from consumers, but ordering, error handling, observability, and consistency still require design. The Microsoft architecture overview distinguishes broker and mediator topologies. A local game bus is much smaller than a distributed broker: it coordinates one process and must not pretend to provide durable delivery, retries, or transactions.

Map each event name to exactly one payload shape.

import { EventBus } from '@overworld-engine/core'

interface GameEventMap {
  'combat:enemy-defeated': {
    enemyId: string
    byPlayerId: string
    xp: number
  }
  'door:opened': {
    doorId: string
    actorId: string
  }
  'player:level-changed': {
    playerId: string
    previous: number
    current: number
  }
}

const events = new EventBus<GameEventMap>()

events.on('combat:enemy-defeated', ({ enemyId, xp }) => {
  console.log(enemyId, xp)
})

events.emit('combat:enemy-defeated', {
  enemyId: 'slime-7',
  byPlayerId: 'player-1',
  xp: 25,
})

The event name selects the payload type for both emitters and listeners. Prefer serializable identifiers and values over React elements, Three.js objects, sockets, stores, or mutable class instances. A stable payload is easier to inspect, record, test, and selectively send across a protocol.

Extend the framework contract without editing framework source.

import {
  gameEvents,
  type OverworldEventMap,
} from '@overworld-engine/core'

declare module '@overworld-engine/core' {
  interface OverworldEventMap {
    'combat:enemy-defeated': {
      enemyId: string
      xp: number
    }
    'crafting:recipe-completed': {
      recipeId: string
      quantity: number
    }
  }
}

gameEvents.emit('crafting:recipe-completed', {
  recipeId: 'iron-sword',
  quantity: 1,
})

type EventName = keyof OverworldEventMap

TypeScript declaration merging lets a product add domain events while preserving one typed map. Keep ownership visible: define combat events beside the combat boundary, export the augmentation once, and avoid several modules declaring incompatible payloads for the same name.

Create buses and systems in the same composition root.

import {
  EventBus,
  type OverworldEventMap,
} from '@overworld-engine/core'
import { createQuestEngine } from '@overworld-engine/quest'
import { createDialogueEngine } from '@overworld-engine/dialogue'

export function createGameSession() {
  const events = new EventBus<OverworldEventMap>()

  const quests = createQuestEngine({
    quests: QUESTS,
    conditions,
    effects,
    context,
    events,
  })
  const dialogue = createDialogueEngine({
    dialogues: DIALOGUES,
    conditions,
    effects,
    context,
    events,
  })

  return {
    events,
    quests,
    dialogue,
    dispose() {
      quests.dispose()
      events.clear()
    },
  }
}

The global gameEvents singleton is a convenient default for one game instance. A factory-owned bus gives tests, multiplayer rooms, editor previews, and server sessions independent listeners and cleanup. Systems that accept an injected bus remain usable in either model.

Know exactly what emit means before gameplay depends on it.

Overworld delivery is synchronous and registration-ordered. emit returns after all current event-specific listeners and onAny observers have been called. Each listener collection is copied for the emission, so subscribing or unsubscribing during a handler does not change which event-specific listeners receive that same emission.

  • A thrown listener error is logged and does not prevent later listeners from running.
  • once unsubscribes itself before invoking its handler.
  • Async work started by a listener is not awaited by emit.
  • A nested emit runs immediately; avoid deep event chains and accidental recursion.
  • Emit only effective state transitions, not every no-op setter or render frame.

If one operation requires validation, a return value, or transactional sequencing, call its owning method directly. Emit a fact after the owner commits the change.

Treat every subscription as a resource that must be released.

function bindQuestNotifications(events: EventBus<OverworldEventMap>) {
  const stopStarted = events.on('quest:started', ({ questId }) => {
    notifications.show({ kind: 'quest-started', questId })
  })
  const stopCompleted = events.on('quest:completed', ({ questId }) => {
    notifications.show({ kind: 'quest-completed', questId })
  })

  return () => {
    stopCompleted()
    stopStarted()
  }
}

const unbind = bindQuestNotifications(events)
// route, room, preview, hot-reload, or test teardown:
unbind()

Ghost listeners cause duplicate rewards, stale UI updates, retained objects, and confusing hot-reload behavior. The component or session that creates a subscription owns its unsubscribe function. Reserve clear() for a bus whose entire lifecycle is ending; do not use it to erase listeners owned by unrelated modules on a shared bus.

Record order and profile synchronous listener cost.

import { createEventRecorder } from '@overworld-engine/test-kit'
import { profileBus } from '@overworld-engine/devtools'

const recorder = createEventRecorder(events)
const profiler = profileBus(events)

events.emit('combat:enemy-defeated', {
  enemyId: 'slime-7',
  byPlayerId: 'player-1',
  xp: 25,
})

expect(recorder.events.map(({ event }) => event)).toEqual([
  'combat:enemy-defeated',
])

console.log(profiler.top(5, 'totalMs'))
profiler.stop()
recorder.stop()

The recorder assigns deterministic sequence numbers rather than timestamps. The profiler measures synchronous dispatch only; promises started by handlers are outside its duration. In development, EventBusInspector provides a bounded live stream and per-event counts without changing domain code.

Relay an explicit protocol allowlist, never the whole local bus.

import { relayEvents } from '@overworld-engine/net'

const stopRelay = relayEvents(events, transport, {
  events: [
    'door:opened',
    'weather:changed',
  ],
})

// Stops both outbound forwarding and inbound re-emission.
stopRelay()

Relayed payloads must be JSON-serializable. The relay suppresses echo loops and ignores events outside its allowlist, but it is not an authority system. A peer must not be allowed to manufacture trusted events such as currency granted or inventory changed. Send validated commands to the authoritative server and let that server emit accepted results.

Read the authoritative TypeScript multiplayer guide →

Use events selectively enough that dependencies stay understandable.

  • Do not use events for a request with exactly one required owner and result.
  • Do not store mutable current state only inside past event payloads.
  • Do not create long chains where handlers emit handlers emit handlers.
  • Do not name events after UI implementation details when they represent domain facts.
  • Do not put per-frame transforms on a broad application bus.
  • Do document producers, payload meaning, expected consumers, and delivery scope.

The bus is a coordination seam, not the architecture itself. Stores own queryable state, engines own invariants, registries resolve content behavior, renderers own presentation, and transports own protocol validation.

TypeScript game event-bus questions

Should a TypeScript game use a global event bus?

A global bus is convenient for a single application instance, but isolated buses are safer for tests, multiplayer rooms, editor previews, and hot reload. Create the bus in the composition root and inject it into systems that share that lifecycle.

What is the difference between a game event and a command?

A command asks one owner to attempt work and may return a result. An event reports a fact that already happened and may have zero or many observers. Use a store for facts that must be queried later.

Should event handlers be synchronous or asynchronous?

Overworld dispatches listeners synchronously so ordering is explicit. A listener may start asynchronous work, but the bus does not await it. Model completion or failure as another event or a direct command result instead of assuming emit waits.

Can I send every local game event over multiplayer transport?

No. Relay an explicit allowlist of JSON-serializable, protocol-owned events. Validate authoritative commands on the server, and do not expose local UI, analytics, debug, or trusted economic events to arbitrary peers.

See events coordinate a complete gameplay slice

Run movement, interaction, dialogue, quest progress, rewards, inventory, UI, analytics, and tests.

Open the runnable React Three Fiber RPG starter →