Skip to content

EventBus

The EventBus system is a typed publish/subscribe channel. Game logic emits named events with payloads; other systems subscribe and react. It’s the spine for decoupling scenes from each other and from systems.

Open it from Inspector → Systems → EventBus → Open.

Each event has:

  • A name (snake_case, starts with a lowercase letter): score_changed, player_died, quest_complete.
  • A payload type literal (TypeScript-style): { score: number; cause: string } or {} for no payload.
  • A ring-buffer flag: when on, the bus caches the last emit so late subscribers receive it on subscribe.

The editor renders this as a four-column table you can add rows to, delete rows from, and edit in place.

The point of the type literal isn’t runtime enforcement (the bus is plain JavaScript). It’s documentation and editor support. When your code subscribes to score_changed, the inferred handler signature gives you autocomplete and warns you on shape drift.

Bundles usually define events for the obvious decouplings:

EventPayloadEmitted byListened by
score_changed{ score: number; delta: number }Game logicHUD, save system
player_died{ cause: 'fall' | 'enemy' }Game logicAudio, game-over scene
quest_complete{ questId: string }Quest FSMBanner, save, audio
goal_reached{ goalId: string }Goals trackerBanner, unlock logic
dialogue_chapter_complete{ endingId: string }Dialogue runnerComplete scene

When ring-buffer is on, the bus remembers the last payload emitted for that event and replays it to any subscriber that joins after the emit. This solves the classic “the HUD subscribes after the first score_changed fired” race.

Use ring-buffer for state-shaped events (score_changed, theme_changed, current_level). Avoid it for spike-shaped events (button_clicked, enemy_killed). Spikes shouldn’t replay; the player hearing the coin sound on every HUD remount would be miserable.

Decoupling the HUD from gameplay: gameplay emits score_changed. The HUD subscribes once on boot. Neither needs to know about the other.

Save on milestone: SaveSystem subscribes to a list of milestone events (configured in its own editor). Anyone can emit a milestone without knowing the save system exists.

Cross-scene messages: scene A emits player_entered_forest. Scene B’s AmbientController subscribes and swaps the music bed. No scene-to-scene direct call.

The EventBus is overkill for tight intra-scene coupling. If two objects in the same scene need to talk every frame, give them direct references; don’t route through the bus. The bus is for the across-the-spine messages that decouple subsystems.

The EventBus’s config in .forge/gamemanager.toml is a simple list:

[systems.EventBus.config]
events = [
{ name = "score_changed", payload = "{ score: number; delta: number }", ringBuffer = true },
{ name = "player_died", payload = "{ cause: 'fall' | 'enemy' }", ringBuffer = false },
{ name = "quest_complete", payload = "{ questId: string }", ringBuffer = false },
]

Validation: every event name must be unique and match ^[a-z][a-z0-9_]*$. Duplicates and invalid names block the save.