Skip to content

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:

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

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

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

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

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

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

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

typescript
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 eventOperationBroadcast
ReachesOne entityOne entityEvery matching subscriber
AnswersNothingA return value, immediatelyNothing
DeliveredAt the next drainNow, in the caller's stackAt the next drain
Use forCommands the receiver acts on in its own timeQuestions, and changes that must have happened by the next lineNews 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:

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

Next steps

  • Entities: the rules behind getEntity and hasBehavior
  • Behaviors: declaring operations and the call depth limit
  • Events: targeted and broadcast delivery in full

Released under the MIT License.