Events
An event is a transient message on the bus: emitted, queued, delivered, gone. The delivery rules are on the Events page; this page is the type and method reference. EventBus, EventMap, GameEvent, EventSubscription, and Subscriber come from @skewedaspect/sage-core. @skewedaspect/sage re-exports EventBus, EventMap, GameEvent, and EventSubscription under their own names; Subscriber is reached from @skewedaspect/sage-core directly.
import { EventBus } from '@skewedaspect/sage-core';
import type { EventMap, GameEvent, Subscriber } from '@skewedaspect/sage-core';One bus per engine
createGameEngine builds one EventBus and holds it as GameEngine.bus. Every manager, handler, and UI registers on it once, under its own name, and hears whichever simulation is current without registering again.
const bus = engine.bus;A simulation is handed that bus when the engine builds it. It attaches when the engine binds the simulation and detaches when the engine unbinds it; while detached, the simulation's entities register and emit against a bus of the simulation's own, held for when it attaches. Detaching reverses attaching in both directions: subscribers move back, and so does whichever of the simulation's own emissions the engine bus has not drained yet, in emission order. A simulation built with no engine bus, on a server, holds its own bus for its whole life, and attaching it changes nothing rather than failing. See Simulation for attach(), detach(), and attached.
EventMap
An empty interface at sage-core's package root. A game augments it once, and the augmentation is the event vocabulary:
declare module '@skewedaspect/sage-core'
{
interface EventMap
{
'door:opened' : { doorID : string };
'action:jump' : boolean;
}
}
export {};The augmentation names either package. EventMap is declared at sage-core's root and sage re-exports that same interface, so a declare module '@skewedaspect/sage' block merges into it exactly as the one above does. The samples on this site name sage-core, which is where the interface is declared. sage augments the map with its own library events -- level:*, asset:*, trigger:*, game:*, and input:deviceChanged -- so they are typed like a game's own.
GameEvent
type GameEvent = {
[Type in keyof EventMap & string] : {
type : Type;
senderID : string;
targetID ?: string;
payload : EventMap[Type];
}
}[keyof EventMap & string];A union over every declared event, one member per type. Narrowing on type narrows payload. Until a game augments the map, GameEvent is never.
EventSubscription
An exact event type, or a wildcard pattern over a declared type's prefixes:
type EventSubscription = (keyof EventMap & string) | `${ EventPrefix<keyof EventMap & string> }:*`;With door:opened and door:lock:opened declared, door:* and door:lock:* are valid subscriptions. A pattern spans every remaining segment, so door:* matches both.
EventsMatching<Subscription> resolves a subscription to the union of events it hears: the one event a type names, or every event under the prefix a pattern names. It is what types the callback on and useSageEvent take, so a game rarely writes it, and a helper that wraps a subscription of its own does.
import type { EventsMatching } from '@skewedaspect/sage';
type DoorEvent = EventsMatching<'level:*'>;
export function describe(event : DoorEvent) : string
{
return event.type;
}Subscriber
interface Subscriber
{
readonly id : string;
receive(event : GameEvent) : boolean;
}
type Unsubscribe = () => void;Every GameEntity is a Subscriber, registered once for all of its behaviors' subscriptions. Anything else holding the bus registers with on(subscription, callback) instead, which mints the subscriber ID itself.
EventBus
| Method | Meaning |
|---|---|
register(subscriber, subscriptions) | Registers under subscriber.id and answers with the teardown. A second registration under an ID already registered throws. |
on(subscription, callback) | Registers a callback under a subscriber ID the bus mints, and answers with the teardown. The callback never consumes. |
emit(event) | Queues the event. Nothing is delivered inline. |
drain() | Delivers the queue, first in, first out, in passes. The simulation calls this at the end of each fixed step, and the game manager calls it once on a rendered frame no fixed step ran. |
The teardown is idempotent, and a stale teardown never evicts a later registration that took the same ID. The engine bus mints subscriber-<n> for an on registration; a simulation's own bus mints <name>-subscriber-<n>, so a subscriber made through a simulation's bus reaches the engine bus under an ID of its own, beside the engine's own subscribers and beside the simulation's entities.
const unsubscribe = engine.bus.on('door:*', (event) =>
{
console.log(event.type, event.senderID);
});
engine.bus.emit({ type: 'door:opened', senderID: 'level', payload: { doorID: 'door-3' } });emit takes a whole event, sender included. GameEntity.emit(type, payload, targetID?) is the form that forces the entity's own ID as the sender.
Delivery
A targeted event goes to the subscriber registered under targetID, and to nobody else. A broadcast goes to every exact subscriber for the type in registration order, then every pattern subscriber whose pattern matches, in registration order, each subscriber once.
The drain
One drain pass delivers every event queued at the start of that pass; handler emissions form the next pass. A drain that exceeds drainPassLimit passes discards the remaining queue and raises. A handler that throws never stops delivery to the rest; the errors collect and are raised together as an AggregateError when the drain completes, with the drain pass error as one more entry when the limit was exceeded. Nothing drains before GameEngine.start().
Entity subscriptions
A behavior declares its subscriptions in static events, and the entity registers once for all of them under its own ID. Within one entity, delivery is a consume chain in attachment order, stopped by an onEvent returning true. See Behaviors.
