Behaviors
A behavior is a class with real methods, extending Behavior<State, Requires>. State is the slice of the entity's state bag the behavior declares and writes; Requires names the operations it needs from its siblings. An entity's behaviors are where its game logic lives.
import { Behavior } from '@skewedaspect/sage';
import type { GameEntityEvent as GameEvent } from '@skewedaspect/sage';What a behavior declares
| Declaration | Where | Meaning |
|---|---|---|
static readonly ops | Class | The operations it exposes on its entity, as [ 'name' ] as const. Each name must have a method behind it. |
static readonly events | Class | The events it subscribes to, exact types or patterns like door:* |
readonly defaults | Instance | Starting values for its own slice, typed against State. Required. |
Requires | Type parameter | The operations it calls on its entity through this.ops |
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
| Member | Type | Meaning |
|---|---|---|
this.state | State | The behavior's own slice, writable |
this.ops | Requires | The entity's operations, typed as what this behavior requires |
this.entity | GameEntity | The behavior's own entity |
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:
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.
Behavior instance fields are transient. Only state persists, so anything worth saving lives in the state bag.
Hooks
| Hook | Runs | Takes |
|---|---|---|
onCreate() | Once, after the entity is wired, on a newly created entity. Never on restore. | Nothing |
onUpdate(dt) | Once per fixed step | The 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 |
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.
| Hook | Runs |
|---|---|
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, and onCreate never observes a node or a mesh: NodeEntity's own construction, which assigns the node, only continues once GameEntity's construction has finished. Reading this.entity.node from onCreate throws. onMeshLoaded is the first hook where either is safe.
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.
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';
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 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. A throwing onDestroy propagates out of destroy() and out of despawn.
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:
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 ''.
import { NamedBehavior } from '@skewedaspect/sage-core';
const behaviors = [ HealthBehavior, NamedBehavior ] as const;An entity's own name is the class-table key and never holds a per-instance label. A level config's SpawnDefinition.name and EntityDefinition.name write displayName.
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.
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:
| Field | When | Takes |
|---|---|---|
enter | Once, when a transition lands the entity in this state | The state transitioned from |
exit | Once, when a transition leaves this state | The state transitioning to |
update | Once per fixed step, while this is the current state | The step's duration |
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.
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.
StateMachine, exported from @skewedaspect/sage, is a standalone finite state machine with no entity of its own. StateMachineBehavior shares no code with it.
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 GameSimulationContext. The lightest way to exercise one is through a GameSimulationManager with no scene:
import { describe, expect, it } from 'vitest';
import { GameEntity, GameSimulationManager } 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 GameSimulationManager({ 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);
});
});