Talking to One Entity
The simulation bus broadcasts by default: an untargeted event reaches every subscriber whose subscription matches. Two mechanisms address one entity instead. A targeted event commands it; an operation call asks it something and gets the answer back immediately.
Commands: a targeted event
entity.emit(type, payload, targetID) queues an event that the bus delivers to the subscriber registered under targetID and to nobody else. It lands at the next drain, at the end of the fixed step, and has no return value.
A button opening a specific door:
export class ButtonBehavior extends Behavior<ButtonState>
{
static readonly events = [ 'action:interact' ] as const;
readonly defaults : ButtonState = { doorID: '' };
onEvent(event : GameEvent) : boolean
{
if(event.type === 'action:interact')
{
this.entity.emit('door:open', { speed: 2.0 }, this.state.doorID);
return true;
}
return false;
}
}The door's behavior handles it the same way it handles any event, and it never checks whether the event was meant for it. A targeted event only ever arrives at its addressee:
export class DoorBehavior extends Behavior<DoorState>
{
static readonly events = [ 'door:open', 'door:close' ] as const;
readonly defaults : DoorState = { isOpen: false, speed: 1.0 };
onEvent(event : GameEvent) : boolean
{
if(event.type === 'door:open')
{
this.state.isOpen = true;
this.state.speed = event.payload.speed;
return true;
}
if(event.type === 'door:close')
{
this.state.isOpen = false;
return true;
}
return false;
}
}Both event types are declared in the game's EventMap augmentation:
declare module '@skewedaspect/sage-core'
{
interface EventMap
{
'action:interact' : boolean;
'door:open' : { speed : number };
'door:close' : Record<string, never>;
}
}The button stores the door's ID in its state. Relationships are IDs in state, and the live entity is never held across fixed steps:
const door = simulation.spawn('DoorEntity');
const button = simulation.spawn('ButtonEntity', undefined, { doorID: door.id });A targeted event addressed to an ID nothing holds is delivered to nobody. That is not an error.
Questions: an operation
An event has no return value. To ask an entity something, resolve it with getEntity, narrow it with hasBehavior, and call an operation through ops. The call is synchronous and answered in the caller's stack.
The chest declares the operation:
export class LockBehavior extends Behavior<LockState>
{
static readonly ops = [ 'isLocked', 'unlock' ] as const;
readonly defaults : LockState = { locked: true, codes: [ '1234' ] };
isLocked() : boolean
{
return this.state.locked;
}
unlock(code : string) : boolean
{
if(!this.state.codes.includes(code))
{
return false;
}
this.state.locked = false;
return true;
}
}The player's interaction behavior asks:
export class InteractBehavior extends Behavior<InteractState>
{
static readonly events = [ 'action:interact' ] as const;
readonly defaults : InteractState = { focusID: '', message: '' };
onEvent(event : GameEvent) : boolean
{
if(event.type !== 'action:interact')
{
return false;
}
const chest = this.entity.getEntity(this.state.focusID);
if(chest === undefined)
{
return true;
}
if(chest.hasBehavior(LockBehavior) && chest.ops.isLocked())
{
this.state.message = 'This chest is locked.';
return true;
}
this.entity.emit('chest:open', {}, chest.id);
return true;
}
}hasBehavior(LockBehavior) is true when one of the chest's attached behaviors is a LockBehavior, counting subclasses. When it is, chest.ops carries LockBehavior's operations and chest.state is its state slice, deep read-only. An entity with no LockBehavior answers false, and the code above treats it as unlocked.
Nothing here can fail silently the way a request could. No entity is getEntity answering undefined. No such operation is hasBehavior answering false. A throw inside unlock is a throw at the call site.
Siblings
A behavior reaches a sibling on its own entity the same two ways. Through this.ops, typed by the behavior's Requires parameter, a composition that lacks the operation does not compile:
import type { OpsOf } from '@skewedaspect/sage-core';
export class AutoLockBehavior extends Behavior<AutoLockState, OpsOf<typeof LockBehavior>>
{
readonly defaults : AutoLockState = { relockAfter: 5, open : 0 };
onUpdate(dt : number) : void
{
if(this.ops.isLocked())
{
return;
}
this.state.open += dt;
if(this.state.open >= this.state.relockAfter)
{
this.state.open = 0;
this.entity.emit('chest:relock', {}, this.entity.id);
}
}
}this.entity.hasBehavior(LockBehavior) works too, and narrows to read-only state as it does for a stranger.
Reading state across entities
Narrowed state is deep read-only. A cross-entity write fails to compile:
const chest = this.entity.getEntity(this.state.focusID);
if(chest !== undefined && chest.hasBehavior(LockBehavior))
{
const locked = chest.state.locked; // fine
chest.state.locked = false; // compile error
chest.ops.unlock('1234'); // the way in
}One entity never writes another's state. It reads state and calls operations.
Which to use
| Targeted event | Operation | Broadcast | |
|---|---|---|---|
| Reaches | One entity | One entity | Every matching subscriber |
| Answers | Nothing | A return value, immediately | Nothing |
| Delivered | At the next drain | Now, in the caller's stack | At the next drain |
| Use for | Commands the receiver acts on in its own time | Questions, and changes that must have happened by the next line | News anyone may care about |
A common pattern is a targeted command followed by a broadcast from the receiver, so anything else that cares hears the outcome:
onEvent(event : GameEvent) : boolean
{
if(event.type === 'door:open')
{
this.state.isOpen = true;
this.entity.emit('door:opened', { doorID: this.entity.id });
return true;
}
return false;
}Input actions are broadcasts by design: InputManager delivers every fired action to every entity subscribed to it, and deciding which entities answer is game logic. See Events.
