Events
An event is a transient message on the bus: emitted, queued, delivered, gone. Nothing about an event is durable. Anything that needs a lasting record keeps its own, in state.
The event map
The event vocabulary is closed. Every event type and its payload is declared once, in the event map, and emission, subscription, and registration are typed against it, so an event name outside the map, or a payload that does not match its type, is a compile error.
A game adds its own event types by augmenting the map at sage-core's package root, which is the only place an augmentation merges:
// events.ts
declare module '@skewedaspect/sage-core'
{
interface EventMap
{
'door:opened' : { doorID : string };
'door:lock:opened' : { doorID : string };
'action:damage' : { amount : number };
'action:jump' : boolean;
}
}
export {};Import this file once from the game's entry point so the augmentation is part of the build.
GameEvent is the union of every declared event, exported from @skewedaspect/sage-core and re-exported from @skewedaspect/sage under the same name. Each carries type, senderID, an optional targetID, and payload, and narrowing on type narrows payload.
One bus per engine
An engine has one bus. createGameEngine builds it and hands it to GameEngine, held as GameEngine.bus, and every manager, handler, level, and UI registers on it once, under its own name.
A simulation is handed that bus when the engine builds it. It attaches to the bus when the engine binds the simulation and detaches when the engine unbinds it. The simulation's own entities register and emit through it, which forwards to the engine bus while attached and holds their registrations and emissions while not: attaching re-registers every held subscriber and appends every held emission, in order, and detaching reverses it exactly. Subscribers move back to the simulation's own bus, and so does whichever of the simulation's own emissions the engine bus has not drained yet, in emission order, rather than staying on the engine bus for whatever attaches next. Two simulations alive at once -- a preload beside the current level, or the level a transition is still deciding on -- never meet on the bus, since only the bound one is attached.
A simulation built with no engine, on a server, holds its own bus and is that bus's only user.
Registration
An entity registers once with the bus, for all of its behaviors' subscriptions, keyed by its ID. A second registration under an ID already registered is rejected. Tearing a registration down is idempotent.
A behavior subscribes through static events:
export class DoorLogBehavior extends Behavior<DoorLogState>
{
static readonly events = [ 'door:*' ] as const;
readonly defaults : DoorLogState = { heard: [] };
onEvent(event : GameEvent) : boolean
{
this.state.heard.push(event.type);
return false;
}
}A subscription is an exact event type or a wildcard pattern such as door:*. A pattern spans every remaining segment, so door:* matches door:opened and door:lock:opened alike.
Anything that is not an entity registers with bus.on(subscription, callback), which mints the subscriber ID itself and answers with the teardown:
const unsubscribe = engine.bus.on('door:*', (event) =>
{
console.log(event.type, event.payload);
});A manager, a level controller, and a UI all subscribe this way.
Emitting
A behavior emits through its entity:
entity.emit('door:opened', { doorID: entity.id });
entity.emit('action:damage', { amount: 10 }, otherID);Every event carries its sender, and entity.emit forces the entity's own ID as the sender. The third argument addresses the event to one subscriber.
Events emitted from onCreate are held until every creation hook succeeds, and then appended in emission order. If a hook throws, the held emissions are discarded, the entity is unsubscribed, and the error propagates.
Delivery
An event may address one subscriber. A targeted event is delivered to that subscriber alone, whatever anyone else subscribes to. An untargeted event is a broadcast, delivered to every subscriber whose subscription matches, and one subscriber consuming the news never stops another from hearing it.
Delivery order for one event is exact subscribers in registration order, then pattern subscribers in registration order, each subscriber once.
Within one entity, delivery is a consume chain. The entity's subscribed behaviors hear the event in attachment order, and a behavior's onEvent returning true consumes it and stops that entity's chain. This is how a shield placed before a health behavior intercepts damage:
const behaviors = [ ShieldBehavior, HealthBehavior ] as const;ShieldBehavior.onEvent returns true while the shield is up, and HealthBehavior never sees the damage. With the shield down it returns false, and the event passes through.
The drain
Emitting never delivers inline. An emission goes on the queue and the bus drains first in, first out, at one defined point in the fixed step, after every entity has updated. On a rendered frame in which no fixed step ran, because no simulation is bound or the game is paused, the game manager drains once, so a manager or a UI hears news between steps and while paused. Nothing drains before GameEngine.start().
One drain pass delivers every event queued at the start of that pass. Events that handlers emit during the pass form the next pass, preserving their order. The drain pass limit, drainPassLimit, counts these passes, not individual events, and a drain that exceeds it discards the remaining queue and raises a drain pass error.
A handler that throws never stops delivery to the rest. The errors collect as the drain proceeds and are raised together as an AggregateError when it completes. When the pass limit is exceeded, the drain pass error is one more entry in that same AggregateError. That AggregateError propagates out of whichever drain ran it, the fixed step's or the game manager's: nothing catches it, and nothing logs it in place of raising it.
Outside the entity system
The bus is not sealed to entities. Anything holding the engine registers on bus and hears events the way an entity does, under the same delivery rules: consuming is an entity's own chain mechanic, and no subscriber, inside or out, ever takes an event away from another.
const unsubscribe = engine.bus.on('entity:died', (event) =>
{
score += 100;
});Anything outside may emit as well, with engine.bus.emit(event). An outside emission goes on the queue like any other and is delivered by the next drain, so it always lands between fixed steps. The bus takes a whole event, sender included; what an outside emission claims as its sender is the emitter's to answer for.
Actions
An action is a named command a controller issues to an entity. A player through bindings, an NPC, a server applying what a client sent, and a replay are all controllers.
In core an action is an action:<name> event declared in the event map like any other, with a typed payload, broadcast to every subscriber that declared it. A behavior subscribes to the actions it answers and reads them in onEvent like any other news:
export class MovementBehavior extends Behavior<MovementState>
{
static readonly events = [ 'action:moveForward', 'action:moveRight' ] as const;
readonly defaults : MovementState = { position: { x: 0, y: 0, z: 0 }, forward: 0, right: 0 };
onEvent(event : GameEvent) : boolean
{
if(event.type === 'action:moveForward')
{
this.state.forward = event.payload;
return true;
}
if(event.type === 'action:moveRight')
{
this.state.right = event.payload;
return true;
}
return false;
}
onUpdate(dt : number) : void
{
this.state.position.z -= this.state.forward * dt;
this.state.position.x += this.state.right * dt;
}
}Deciding which entities answer a broadcast action is game logic. Everything that turns a device into an action lives in sage, held by InputManager. See Input.
The action: prefix is a convention, not a mechanism: a game's own semantic commands and the raw input actions InputManager broadcasts share one bus and one namespace. The entity-behaviors example prefixes its raw input actions cmd to keep the two apart.
The library's own events
sage declares its own events into the map the same way a game declares its own, so they are typed and delivered like anything else, on the one bus:
| Event | Payload |
|---|---|
level:progress | { levelName, progress, message? } |
level:complete | { levelName, message?, level } |
level:error | { levelName, message, error } |
level:transition:start | { from?, to } |
level:transition:progress | { stage, levelName } |
level:transition:complete | { levelName } |
level:transition:error | { from?, to, error } |
asset:progress | { path, loaded, total } |
asset:complete | { paths } |
trigger:enter, trigger:exit | { trigger, other } |
game:paused, game:resumed | { reason? } |
input:deviceChanged | { deviceType, previousDeviceType } |
These arrive as outside emissions: a manager holding the engine, not an entity, calls bus.emit between fixed steps, the same as anything else outside the entity system emits. A game subscribes to them the same way it subscribes to its own events:
engine.bus.on('level:transition:complete', (event) =>
{
hideLoadingScreen(event.payload.levelName);
});
engine.bus.on('level:*', (event) =>
{
console.log(event.type);
});Where to go next
- Behaviors for
onEventand the other hooks - Input for how a device becomes an
action:<name>event - Simulation for where the drain sits in the fixed step
