Skip to content

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:

typescript
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

typescript
import { GameEventBus } from '@skewedaspect/sage';

const eventBus = new GameEventBus();

In practice, you rarely create one manually -- createGameEngine() provides one at sage.eventBus.

Publishing

typescript
eventBus.publish({
    type: 'player:move',
    payload: {
        x: 100,
        y: 200,
        speed: 5,
    },
});

Subscribing

typescript
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:

typescript
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:

typescript
// All input events
eventBus.subscribePattern('input:*', (event) =>
{
    console.log(`Input event: ${ event.type }`);
});

Regex Pattern

For more complex matching, pass a RegExp:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 TypePayloadDescription
input:device:connectedDeviceConnectedPayloadA new input device was detected
input:device:disconnectedDeviceDisconnectedPayloadAn input device was removed
input:changedInputChangedPayloadInput state changed on a device
typescript
interface DeviceConnectedPayload
{
    device : InputDevice;
}

interface DeviceDisconnectedPayload
{
    device : InputDevice;
}

interface InputChangedPayload
{
    deviceId : string;
    device : InputDevice;
    state : InputState;
}

Level Events

Event TypePayloadDescription
level:progressLevelProgressPayloadLevel loading progress update
level:completeLevelCompletePayloadLevel finished loading
level:errorLevelErrorPayloadLevel loading failed
typescript
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.

typescript
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:

typescript
// 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:

typescript
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:

typescript
// 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
}
typescript
// 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:

typescript
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:

typescript
// 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

typescript
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:

typescript
// 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 Map for 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

  1. Use structured event types. Namespace with colons: category:action (e.g. player:jump, level:complete).
  2. Keep payloads small. Avoid passing large objects or deep structures in event payloads.
  3. Always clean up subscriptions. Store the unsubscribe function and call it when the subscriber is destroyed.
  4. Be specific. Prefer exact subscriptions over patterns when you know the exact event type.
  5. Document your events. Maintain an event payload map so consumers know what to expect.

Released under the MIT License.