Skip to content

Behaviors ​

A behavior is a class with real methods, extending Behavior<State, Requires, Entity>. State is the slice of the entity's state bag the behavior declares and writes; Requires names the operations it needs from its siblings; Entity is the entity class it attaches to, GameEntity unless it says otherwise. An entity's behaviors are where its game logic lives.

typescript
import { Behavior } from '@skewedaspect/sage';
import type { GameEvent } from '@skewedaspect/sage';

What a behavior declares ​

DeclarationWhereMeaning
static readonly opsClassThe operations it exposes on its entity, as [ 'name' ] as const. Each name must have a method behind it.
static readonly eventsClassThe events it subscribes to, exact types or patterns like door:*
readonly defaultsInstanceStarting values for its own slice, typed against State. Required.
RequiresType parameterThe operations it calls on its entity through this.ops
EntityType parameterThe entity class this behavior attaches to. Defaults to GameEntity.

A behavior declaring NodeEntity as its third parameter composes only onto a NodeEntity class; the composition check refuses it on any other class, and inside it this.entity is a NodeEntity with no cast:

typescript
import { Behavior } from '@skewedaspect/sage';
import type { NodeEntity } from '@skewedaspect/sage';

export class SpatialBehavior extends Behavior<object, object, NodeEntity>
{
    readonly defaults = {};

    onCreate() : void
    {
        this.entity.node.setEnabled(true);
    }
}
typescript
interface LockState
{
    locked : boolean;
    codes : string[];
}

export class LockBehavior extends Behavior<LockState>
{
    static readonly ops = [ 'isLocked', 'unlock' ] as const;
    static readonly events = [ 'action: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;
    }

    onEvent(event : GameEvent) : boolean
    {
        if(event.type === 'action:unlock')
        {
            return this.unlock(event.payload.code);
        }

        return false;
    }
}

Inside a behavior ​

MemberTypeMeaning
this.stateStateThe behavior's own slice, writable
this.opsRequiresThe entity's operations, typed as what this behavior requires
this.entityGameEntityThe behavior's own entity
this.simulationGameSimulationViewThe simulation the entity was created with. Protected.

Reading or writing a field another behavior declared is a compile error; the shared bag is reached through a declared shared field, never through a neighbor's slice. Two behaviors declaring the same field share it.

A behavior calls a sibling's operation through this.ops without knowing which sibling answers. The composition fails to compile when nothing provides what Requires names:

typescript
import type { OpsOf } from '@skewedaspect/sage-core';

export class AskLockBehavior extends Behavior<AnswerState, OpsOf<typeof LockBehavior>>
{
    readonly defaults : AnswerState = { answered: false };

    onCreate() : void
    {
        this.state.answered = this.ops.isLocked();
    }
}

Through this.entity a behavior narrows to a sibling with hasBehavior, resolves another entity by ID with getEntity, and emits events. State reached by narrowing is read-only.

this.simulation is the simulation the entity was created with. A behavior that rolls draws from the simulation's own seeded generator, this.simulation.random, rather than importing a generator of its own. See Random numbers.

typescript
import { Behavior } from '@skewedaspect/sage';

export class CritBehavior extends Behavior<{ crit : boolean }>
{
    readonly defaults = { crit: false };

    onUpdate() : void
    {
        this.state.crit = this.simulation.random.chance(0.1);
    }
}

A behavior that declares NodeEntity as its entity reads a NodeSimulationContext through this.simulation, so the scene, the node pool, the mesh loader, the audio manager, and collider debugging are all reachable there.

Behavior instance fields are transient. Only state persists, so anything worth saving lives in the state bag.

Hooks ​

HookRunsTakes
onStateReady()Once, on every entity, the moment its behaviors are wired. A restored entity runs it exactly as a created one does.Nothing
onCreate()Once, after the entity is wired, on a newly created entity. Never on restore.Nothing
onUpdate(dt)Once per fixed stepThe step's duration in seconds
onEvent(event)When a subscribed event is delivered. Returning true consumes it and stops the entity's chain.The event

onStateReady is the moment a behavior's state exists and nothing has read it yet. No mesh has loaded, no creation hook has run, no step has been taken. Whatever the entity class computes itself is already answering by then. A behavior that has to reach its own state before anything reads it does that here. See Computed state.

Creation hooks run in attachment order. Events emitted from onCreate are held outside the queue until every creation hook succeeds, then appended in emission order. If a hook throws, the held emissions are discarded, the entity is unsubscribed, and the error propagates.

onCreate does not call getEntity. Spawn order is not a contract.

NodeEntity hooks ​

Behaviors on a NodeEntity receive two more hooks. Neither is declared on Behavior itself; a behavior implements the method and NodeEntity recognizes it by name.

HookRuns
onMeshLoaded()Once per entity, when its mesh loading settles, whether or not a mesh results. Read this.entity.mesh.
onDestroy()Once, when the entity's destroy() runs, before it unsubscribes and before the node is disposed or pooled

onMeshLoaded never runs before onCreate. Acquiring the node comes first of all, in the entity's declareComputedState, so onStateReady, onCreate, and onMeshLoaded all read this.entity.node, adopted, pooled, or freshly built alike. The mesh is what the hook order is about: this.entity.mesh before onMeshLoaded is whatever the entity's node set carries, which is nothing for a load still in flight, nothing at all for an adopted node, and the pooled set's own mesh for a claim that arrived loaded. Only the hook says the loading has settled.

A fresh spawn settles once its mesh source's promise settles. A pooled claim on an already-loaded set and an adopted node both settle immediately, inside construction, after onCreate. Restore runs the same node construction as create, so a behavior sees onMeshLoaded once whether its entity was created or restored.

typescript
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';
import { Behavior } from '@skewedaspect/sage';
import type { NodeEntity } from '@skewedaspect/sage';

export class PhysicsBodyBehavior extends Behavior<PhysicsBodyState>
{
    readonly defaults : PhysicsBodyState = { mass: 1 };

    #aggregate : PhysicsAggregate | undefined;

    onMeshLoaded() : void
    {
        const entity = this.entity as NodeEntity;

        if(entity.mesh === undefined)
        {
            return;
        }

        this.#aggregate = new PhysicsAggregate(
            entity.mesh,
            PhysicsShapeType.BOX,
            { mass: this.state.mass },
            entity.node.getScene()
        );
    }

    onDestroy() : void
    {
        this.#aggregate?.dispose();
        this.#aggregate = undefined;
    }
}

A mesh source that rejects settles the load rather than failing the spawn. The error is logged through the simulation's logger, naming the entity and carrying what the source raised, the entity keeps its node and carries no mesh, and onMeshLoaded runs with this.entity.mesh unset, the same as for a source that answers with nothing.

A throwing onMeshLoaded is not caught. When the settling is immediate, the throw propagates out of construction the same way a throwing onCreate does. When it is deferred, the error surfaces as an unhandled rejection: only the load itself is caught, never the hook that runs once it settles. A throwing onDestroy propagates out of destroy() and out of despawn.

Computed state ​

this.computed(field, { get, set }) redefines one of the behavior's own state fields as a getter and a setter over something the behavior owns. Reading the field asks that thing and writing the field tells it, so there is one truth rather than a copy kept in step with one. field is a key of the behavior's own slice, and get and set are typed by that field, so a computation over a field the behavior never declared does not compile.

The { get, set } pair is a Computation<Value>, exported from both packages. A behavior that assembles its getter and setter before the computed call names the type there.

A behavior declares its computations in onStateReady, the moment its state exists and before anything has read it. Until then the field holds whatever composition or a snapshot put there, so a behavior seeds what it owns from the field and then takes the field over. Those are the same two steps for a fresh entity and a restored one.

A Set is the plain example. State cannot hold one, but a behavior that asks has on every step wants one, and the field still serializes as an array.

typescript
import { Behavior } from '@skewedaspect/sage';

interface CodesState
{
    codes : string[];
}

export class KeypadBehavior extends Behavior<CodesState>
{
    static readonly ops = [ 'accepts' ] as const;

    readonly defaults : CodesState = { codes: [] };

    readonly #codes = new Set<string>();

    onStateReady() : void
    {
        for(const code of this.state.codes)
        {
            this.#codes.add(code);
        }

        this.computed('codes', {
            get: () => [ ...this.#codes ],
            set: (codes) =>
            {
                this.#codes.clear();

                for(const code of codes)
                {
                    this.#codes.add(code);
                }
            },
        });
    }

    accepts(code : string) : boolean
    {
        return this.#codes.has(code);
    }
}

A snapshot asks the getter and records the array; a restore assigns, and the setter rebuilds the Set from what it is given. What a getter answers is finite JSON-compatible data or the snapshot rejects it, naming the entity and the field.

A field computed from a Set answers with a fresh list each read, because an element of that list has nothing under it to write through to. A computation over a field that holds a record may instead answer with a live view: the same object on every read, whose components write through, so state.position.x += 1 moves what state.position = { ... } moves. NodeEntity's position and rotation and HavokPhysicsBodyBehavior's four transform fields all answer that way.

A behavior computing a field its entity class already computes lands over the entity's and answers for it while it stands. Destroying the entity removes every computation in reverse order of declaration, before any onDestroy runs, so a behavior's teardown reads state as plain data. There is no removal of one computation on its own. See Entities for the entity side.

A body cannot be built on the spot, so HavokPhysicsBodyBehavior declares no onStateReady: it builds the body in onMeshLoaded, seeds it from whatever the fields hold at that moment, and computes those fields over it in the same call. See Physics.

Operations ​

An operation is a method a behavior exposes on its entity, called by name and answered immediately. It is the synchronous channel, used between behaviors on one entity through this.ops and from one entity to another through a narrowed GameEntity:

typescript
class AlarmBehavior extends Behavior<{ doorID : string }>
{
    readonly defaults = { doorID: '' };

    onCreate() : void
    {
        const door = this.entity.getEntity(this.state.doorID);

        if(door !== undefined && door.hasBehavior(LockBehavior))
        {
            door.ops.unlock('1234');
        }
    }
}

Operation calls stop at operationCallDepthLimit, counted per entity, rather than running away. A chain one call deeper than the limit throws a RangeError.

The three channels ​

An entity's behaviors talk over three channels, and each carries something different.

  • State is facts. It persists, and writing a field notifies nobody. There is no reactive state watching.
  • Operations are verbs. A call is answered immediately, in the caller's stack, with a return value.
  • Events are news. They queue on the bus, are never delivered inline, and have no return value.

Update order ​

Order is attachment order, for updates and for events alike. An entity's default update() runs each behavior's onUpdate in the order the class listed them. A class may override update() and order its work explicitly; see Entities.

NamedBehavior ​

A per-instance label. Its one state field is displayName, defaulting to ''.

typescript
import { NamedBehavior } from '@skewedaspect/sage-core';

const behaviors = [ HealthBehavior, NamedBehavior ] as const;

That one-field slice is exported as NamedState, so a behavior that reads or writes the label declares the field by naming the type rather than repeating it.

An entity's own name is the registry key and never holds a per-instance label. The per-instance label is SpawnDefinition.displayName, which the level writes into the state field of the same name. EntityDefinition is the same type under a second name, so an entity marker's definition carries displayName too. Neither has a name field.

StateMachineBehavior ​

Named states, a current state read straight from the state bag, and one operation, transitionTo, to move between them. It lives in sage-core and works on a plain GameEntity as well as a NodeEntity.

typescript
import { StateMachineBehavior, defineStates } from '@skewedaspect/sage-core';

interface GuardState
{
    currentState : 'patrol' | 'alert' | 'chase';
    alarm : number;
}

export class GuardBehavior extends StateMachineBehavior<GuardState>
{
    readonly defaults : GuardState = { currentState: 'patrol', alarm: 0 };

    readonly states = defineStates(this, {
        patrol: {
            update(dt)
            {
                this.state.alarm = Math.max(0, this.state.alarm - dt);
            },
        },
        alert: {
            enter(from)
            {
                this.state.alarm = 5;
            },
            update(dt)
            {
                this.state.alarm -= dt;

                if(this.state.alarm <= 0)
                {
                    this.transitionTo('patrol');
                }
            },
        },
        chase: {
            exit(to)
            {
                this.state.alarm = 0;
            },
        },
    });
}

Declaring states ​

states is a static table keyed by state name. Each entry's three fields are optional:

FieldWhenTakes
enterOnce, when a transition lands the entity in this stateThe state transitioned from
exitOnce, when a transition leaves this stateThe state transitioning to
updateOnce per fixed step, while this is the current stateThe step's duration

Each entry is a StateDefinition<Machine, States>, exported beside defineStates, where Machine is the behavior the three functions are bound to and States the union of state names a transition can name.

All three run with this bound to the behavior instance. A subclass writes states through defineStates(this, { ... }) rather than assigning the table directly: a class field with no type annotation takes its type from what it is assigned, and defineStates gives the literal the contextual type that makes this.state, this.ops, and this.entity resolve inside it.

The state bag holds exactly one field for the machine, currentState, and its valid values are the keys of states. A currentState default naming a state the table does not declare, or a table missing a key the type allows, does not compile.

Transitioning ​

transitionTo(name) is the operation. It runs the outgoing state's exit, writes currentState, then runs the incoming state's enter. Naming a state the table does not declare throws. Naming the state already current is a no-op.

typescript
if(guard.hasBehavior(GuardBehavior))
{
    guard.ops.transitionTo('alert');
}

transitionTo carries no guard and emits nothing. The state an entity is created or restored into never runs its own enter; whatever a state's enter would set up, the starting state gets in defaults or in the behavior's own onCreate. Restore never calls transitionTo either.

CharacterControllerBehavior ​

Provided by sage. It moves a NodeEntity as a character, sweeping a capsule through the scene on every fixed step from state a game writes. See Character Controller.

SoundBehavior ​

Provided by sage. It gives a NodeEntity one or more named sound sources built from its own state, played and stopped through ops. See Audio.

Testing a behavior ​

A behavior is a plain class, and the entity that runs it needs only a simulation to be created with. The lightest way to exercise one is through a GameSimulation with no scene:

typescript
import { describe, expect, it } from 'vitest';
import { GameEntity, GameSimulation } from '@skewedaspect/sage-core';

class DoorEntity extends GameEntity
{
    static override readonly behaviors = [ LockBehavior ] as const;
}

describe('LockBehavior', () =>
{
    it('unlocks on a matching code', () =>
    {
        const simulation = new GameSimulation({
            id: 'lab', fixedStepDuration: 1 / 20, entityClasses: { DoorEntity },
        });
        const door = simulation.spawn('DoorEntity');

        if(!door.hasBehavior(LockBehavior)) { throw new Error('composition changed'); }

        expect(door.ops.unlock('1234')).toBe(true);
        expect(door.state.locked).toBe(false);
    });
});

Released under the MIT License.