Events
SAGE uses an event bus to decouple communication between all parts of the engine. Publishers emit events without knowing who is listening; subscribers react to events without needing references to publishers. The bus routes everything efficiently in between.
The GameEvent Interface
Every event flowing through the bus is a GameEvent:
interface GameEvent<P extends Record<string, unknown> = Record<string, unknown>>
{
type : string; // Event identifier, e.g. "player:move"
senderID? : string; // Optional sender entity ID
targetID? : string; // Optional target entity ID
payload? : P; // Typed payload data
}Events are self-contained packets. The type field identifies what happened, the optional senderID/targetID fields enable directed communication, and payload carries the data.
Pub/Sub Basics
Creating an Event Bus
import { GameEventBus } from '@skewedaspect/sage';
const eventBus = new GameEventBus();In practice, you rarely create one manually -- createGameEngine() provides one at sage.eventBus.
Publishing
eventBus.publish({
type: 'player:move',
payload: {
x: 100,
y: 200,
speed: 5,
},
});Subscribing
const unsubscribe = eventBus.subscribe('player:move', (event) =>
{
const { x, y } = event.payload;
console.log(`Player moved to ${ x }, ${ y }`);
});
// Later, when you no longer care:
unsubscribe();Every subscribe call returns an unsubscribe function. Always store it and call it when the subscriber is destroyed to avoid leaks.
Subscription Types
Exact Match
Listen for one specific event type:
eventBus.subscribeExact('collision:player', (event) =>
{
const { otherEntity } = event.payload;
console.log(`Player collided with ${ otherEntity.name }`);
});Wildcard Pattern
Catch multiple events with a glob-style pattern:
// All input events
eventBus.subscribePattern('input:*', (event) =>
{
console.log(`Input event: ${ event.type }`);
});Regex Pattern
For more complex matching, pass a RegExp:
eventBus.subscribePattern(/^collision:/, (event) =>
{
console.log(`Collision detected: ${ event.type }`);
});Automatic Detection
The generic subscribe() method detects whether you are using a pattern and routes accordingly:
// Exact match (no wildcard characters)
eventBus.subscribe('game:start', handleGameStart);
// Pattern match (contains *)
eventBus.subscribe('player:*', handlePlayerEvents);Type Safety
Generic Payloads
Pass a type parameter to subscribe for typed payloads:
interface PlayerMovePayload
{
x : number;
y : number;
speed : number;
}
eventBus.subscribe<PlayerMovePayload>('player:move', (event) =>
{
// event.payload is typed as PlayerMovePayload
const { x, y, speed } = event.payload;
animatePlayer(x, y, speed);
});Event Payload Maps
GameEventBus is generic. By default, it uses LibraryEventPayloadMap, which types all SAGE-defined events. You can extend it with your own events for full compile-time safety:
import { GameEventBus } from '@skewedaspect/sage';
import type { LibraryEventPayloadMap } from '@skewedaspect/sage';
interface MyGameEvents
{
'game:score-updated' : { score : number; player : string };
'game:paused' : { reason : string };
'player:respawned' : { position : { x : number; y : number } };
[key : string] : unknown;
}
// Create a bus that knows about both library and game events
const eventBus = new GameEventBus<MyGameEvents & LibraryEventPayloadMap>();
// Compiler enforces your custom payloads
eventBus.publish({
type: 'game:score-updated',
payload: { score: 100, player: 'alice' },
}); // OK
eventBus.publish({
type: 'game:score-updated',
payload: { score: 100 },
}); // Error: missing 'player'Subscriptions are also type-safe:
eventBus.subscribe('input:changed', (event) =>
{
// event.payload is typed as InputChangedPayload
console.log(`Input from device: ${ event.payload.deviceId }`);
});INFO
For wildcard/pattern subscriptions, payload typing is not available since multiple event types might match. The callback receives a generic event and you'll need to narrow the type yourself.
Event Type Helpers
SAGE provides template literal types for consistent event naming:
import type { ActionEvent, LevelEvent, InputEvent } from '@skewedaspect/sage';
// These create branded string types for each event category:
// ActionEvent<'jump'> -> "action:jump"
// LevelEvent<'loaded'> -> "level:loaded"
// InputEvent<'changed'> -> "input:changed"
// Build union types for your game's actions
type MyActions = 'jump' | 'attack' | 'dodge' | 'interact';
type MyActionEvents = ActionEvent<MyActions>;
// Result: "action:jump" | "action:attack" | "action:dodge" | "action:interact"Built-in Event Types
SAGE emits several events internally that you can subscribe to.
Input Events
| Event Type | Payload | Description |
|---|---|---|
input:device:connected | DeviceConnectedPayload | A new input device was detected |
input:device:disconnected | DeviceDisconnectedPayload | An input device was removed |
input:changed | InputChangedPayload | Input state changed on a device |
interface DeviceConnectedPayload
{
device : InputDevice;
}
interface DeviceDisconnectedPayload
{
device : InputDevice;
}
interface InputChangedPayload
{
deviceId : string;
device : InputDevice;
state : InputState;
}Level Events
| Event Type | Payload | Description |
|---|---|---|
level:progress | LevelProgressPayload | Level loading progress update |
level:complete | LevelCompletePayload | Level finished loading |
level:error | LevelErrorPayload | Level loading failed |
interface LevelProgressPayload
{
levelName : string;
progress : number; // 0-100
message? : string;
}
interface LevelCompletePayload
{
levelName : string;
message? : string;
}
interface LevelErrorPayload
{
levelName : string;
message : string;
error : unknown;
}Action Events
Action events are dynamic, matching the pattern action:* where * is the action name you registered with the BindingManager.
interface ActionPayload
{
value : boolean | number; // true/false for digital, 0-1 for analog
deviceId : string; // Which device triggered this
context? : string; // Active input context
}Example action events: action:jump, action:move_forward, action:fire_weapon.
Custom Events
The event bus is not limited to SAGE's built-in events. You can publish and subscribe to any event type you define.
Publishing Custom Events
The engine's event bus accepts custom event types thanks to the index signature on LibraryEventPayloadMap. You need to cast the bus to accept arbitrary payloads:
// The engine's eventBus is typed as GameEventBus<LibraryEventPayloadMap>.
// To publish custom events, cast it to accept your payload.
const bus = engine.eventBus as unknown as GameEventBus<Record<string, unknown>>;
bus.publish({
type: 'quest:completed',
payload: { questId: 'fetch_sword', reward: 500 },
});This cast is safe -- the bus routes events by string type regardless of the generic parameter. The generic only affects compile-time payload checking.
Subscribing to Custom Events
Subscribing is simpler -- the subscribe method accepts any string and you narrow the payload yourself:
engine.eventBus.subscribe('quest:completed', (event) =>
{
const payload = event.payload as { questId : string; reward : number };
console.log(`Quest ${ payload.questId } done, reward: ${ payload.reward }`);
});Type-Safe Custom Events
For full compile-time safety across your game, define a payload map and cast the bus once at startup:
// gameEvents.ts -- define your custom event types
interface GameEventPayloads
{
'quest:completed' : { questId : string; reward : number };
'quest:started' : { questId : string; name : string };
'npc:dialogue' : { npcId : string; line : string };
'score:changed' : { score : number; delta : number };
[key : string] : unknown; // required -- allows SAGE's built-in events to pass through
}// game.ts -- cast once, use everywhere
import type { LibraryEventPayloadMap, GameEventBus } from '@skewedaspect/sage';
import type { GameEventPayloads } from './gameEvents.ts';
type AllEvents = GameEventPayloads & LibraryEventPayloadMap;
// After engine creation, cast the bus
const bus = engine.eventBus as unknown as GameEventBus<AllEvents>;
// Now both custom and built-in events are fully typed
bus.publish({
type: 'quest:completed',
payload: { questId: 'fetch_sword', reward: 500 },
}); // OK -- payload matches GameEventPayloads['quest:completed']
bus.publish({
type: 'quest:completed',
payload: { questId: 'fetch_sword' },
}); // Error: missing 'reward'
bus.subscribe('quest:completed', (event) =>
{
event.payload.reward; // number -- fully typed, no cast needed
});The [key : string] : unknown index signature
Your custom payload map must include the index signature [key : string] : unknown. Without it, TypeScript will reject SAGE's built-in events because your map doesn't contain entries like 'input:changed'. The index signature tells TypeScript "other event types are allowed, with unknown payloads."
From Behaviors
Behaviors publish custom events through the $emit helper, which automatically stamps senderID and handles the bus casting:
class QuestBehavior extends GameEntityBehavior
{
name = 'QuestBehavior';
eventSubscriptions = [ 'quest:started' ];
completeQuest(questId : string, reward : number) : void
{
// $emit handles the cast internally -- just pass the event
this.$emit({
type: 'quest:completed',
payload: { questId, reward },
});
}
processEvent(event : GameEvent, state : QuestState) : boolean
{
if(event.type === 'quest:started')
{
const payload = event.payload as { questId : string; name : string };
state.activeQuest = payload.questId;
return true;
}
return false;
}
}Note that $emit casts the bus internally, so behaviors never need the as unknown as GameEventBus<...> cast themselves.
Common Pitfalls
Forgetting the cast when publishing from game code. The engine's bus is typed as GameEventBus<LibraryEventPayloadMap>. Publishing a custom event type without casting will produce a TypeScript error like Type '"quest:completed"' is not assignable to type 'keyof LibraryEventPayloadMap & string'. Use the cast pattern shown above.
Omitting the index signature. If your custom payload map doesn't have [key : string] : unknown, merging it with LibraryEventPayloadMap works but subscribing to unlisted event types will fail.
Subscribing in behaviors without adding to eventSubscriptions. A behavior's processEvent is only called for event types listed in its eventSubscriptions array. If you subscribe to 'quest:completed' in eventSubscriptions but misspell it as 'quest:complete', the behavior will silently never receive the event.
Common Patterns
Component Communication
Use the event bus to wire up independent systems without direct references:
// Player controller publishes
function handlePlayerInput(key : string) : void
{
if(key === 'Space')
{
eventBus.publish({
type: 'player:jump',
payload: { timestamp: performance.now() },
});
}
}
// Audio system subscribes
eventBus.subscribe('player:jump', () =>
{
audioSystem.playSound('jump.wav');
});
// Animation system subscribes independently
eventBus.subscribe('player:jump', () =>
{
playerSprite.playAnimation('jump');
});Game State Changes
function changeGameState(newState : string) : void
{
eventBus.publish({
type: 'game:stateChange',
payload: {
previous: currentState,
current: newState,
},
});
currentState = newState;
}
eventBus.subscribe('game:stateChange', (event) =>
{
const { previous, current } = event.payload;
console.log(`State: ${ previous } -> ${ current }`);
updateUI(current);
});Directed Entity Communication
For sending events to a specific entity, use entity messaging instead of the event bus. Entity messaging delivers events directly to the target entity's behaviors without broadcasting:
// Fire-and-forget: activate a specific door
await entityManager.send('Door.001', 'activate', { source: 'button' });
// Request-response: query an entity's state
const result = await entityManager.request<LockState>('Door.001', 'lock:state', {});
if(result.success)
{
console.log(`Door is ${ result.value.locked ? 'locked' : 'unlocked' }`);
}The event bus is for broadcast communication (one-to-many). Entity messaging is for targeted communication (one-to-one). See the Entity Messaging guide for detailed patterns.
Performance
The event bus is optimized for game development workloads:
- Async delivery via microtasks. Events are dispatched through
Promise.resolve(), so publishers never block on subscriber execution. - O(1) exact matching. Direct subscriptions use a
Mapfor constant-time lookup. - Efficient pattern matching. Wildcard and regex subscriptions are matched against incoming events without unnecessary iteration.
- Thousands of events per second. Benchmarks confirm the bus handles high-frequency event traffic without becoming a bottleneck.
TIP
Use exact subscriptions when possible. They are faster than pattern subscriptions because they skip the pattern-matching step entirely.
Best Practices
- Use structured event types. Namespace with colons:
category:action(e.g.player:jump,level:complete). - Keep payloads small. Avoid passing large objects or deep structures in event payloads.
- Always clean up subscriptions. Store the unsubscribe function and call it when the subscriber is destroyed.
- Be specific. Prefer exact subscriptions over patterns when you know the exact event type.
- Document your events. Maintain an event payload map so consumers know what to expect.
