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, re-exported from @skewedaspect/sage as GameEntityEvent, is the union of every declared event. Each carries type, senderID, an optional targetID, and payload, and narrowing on type narrows payload.
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.
Emitting
A behavior emits through its entity:
this.entity.emit('door:opened', { doorID: this.entity.id });
this.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.
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.
Outside the entity system
The bus is not sealed to entities. Anything holding the simulation may register a subscriber on simulation.bus and hear 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 = simulation.bus.register(
{
id: 'score-keeper',
receive(event)
{
if(event.type === 'entity:died') { score += 100; }
return false;
},
},
[ 'entity:died' ]
);Anything outside may emit as well, with simulation.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 engine's library bus
engine.eventBus is sage's GameEventBus, separate from the simulation bus. Levels, asset loading, triggers, and pause and resume publish there, and it delivers synchronously to callbacks:
engine.eventBus.subscribe('level:transition:complete', (event) =>
{
hideLoadingScreen(event.payload.levelName);
});
engine.eventBus.subscribe('level:*', (event) =>
{
console.log(event.type);
});| 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? } |
Entity events do not travel on this bus, and actions do not either. entity:state-changed is still in the library payload map, and nothing in 0.10 publishes it.
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
