Behaviors
Behaviors are the building blocks that give entities their abilities. Each behavior is a reusable component that listens for events, processes state changes, and optionally runs per-frame updates. You compose entities by attaching multiple behaviors, each handling a single responsibility.
GameEntityBehavior
The abstract base class for all behaviors. Subclass it to define how an entity reacts to events and updates each frame.
import { GameEntityBehavior } from '@skewedaspect/sage';
import type { GameEvent } from '@skewedaspect/sage';Abstract Members
| Member | Type | Description |
|---|---|---|
name | string | Unique identifier for this behavior type |
eventSubscriptions | string[] | Event types this behavior listens for |
processEvent(event, state) | (GameEvent, S) => boolean | Promise<boolean> | Handle an incoming event. Return true to stop propagation. |
Optional Members
| Member | Type | Description |
|---|---|---|
update(dt, state) | (number, S) => void | Called every frame. dt is seconds since last frame. |
destroy() | () => Promise<void> | Cleanup when the entity is destroyed |
onNodeAttached(node, gameEngine) | (TransformNode, GameEngine) => void | Called when entity is attached to a scene node |
onNodeDetached() | () => void | Called when entity is detached from its scene node |
onReset(state) | (S) => void | Called when a poolable entity is recycled |
Protected Members
| Member | Type | Description |
|---|---|---|
entity | SimpleGameEntity | null | Reference to the owning entity (set automatically) |
Internal API
| Method | Signature | Description |
|---|---|---|
$emit(event) | (GameEvent) => void | Publish an event on the entity's event bus |
$emitStateChanged(state?, changes?) | (object?, Record<string, unknown>?) => void | Emit entity:state-changed for UI reactivity |
$setEntity(entity) | (SimpleGameEntity | null) => void | Set the owning entity (called by the framework) |
Defining a Behavior
Every behavior needs a name, an eventSubscriptions array, and a processEvent implementation. The generic parameter RequiredState documents what entity state properties the behavior depends on.
import { GameEntityBehavior } from '@skewedaspect/sage';
import type { GameEvent } from '@skewedaspect/sage';
interface HealthState
{
currentHealth : number;
maxHealth : number;
isAlive : boolean;
}
export class HealthBehavior extends GameEntityBehavior<HealthState>
{
name = 'HealthBehavior';
eventSubscriptions = [ 'entity:damage', 'entity:heal' ];
processEvent(event : GameEvent, state : HealthState) : boolean
{
if(event.type === 'entity:damage')
{
const damage = event.payload?.amount || 0;
state.currentHealth = Math.max(0, state.currentHealth - damage);
if(state.currentHealth === 0 && state.isAlive)
{
state.isAlive = false;
this.$emit({
type: 'entity:died',
payload: { causeOfDeath: 'damage', finalBlow: event.payload },
});
}
return true;
}
if(event.type === 'entity:heal' && state.isAlive)
{
const amount = event.payload?.amount || 0;
state.currentHealth = Math.min(state.maxHealth, state.currentHealth + amount);
this.$emit({ type: 'entity:healed', payload: { amount } });
return true;
}
return false;
}
}Lifecycle
When a behavior is attached to an entity, it follows this lifecycle:
- Instantiation -- The behavior is created via its zero-argument constructor
- Attachment --
$setEntity()is called, linking the behavior to its entity - Subscription -- The framework subscribes to every event type in
eventSubscriptions - Active phase --
processEvent()is called for matching events;update()runs each frame - Detachment / Destruction -- Event subscriptions are removed;
destroy()is called
Lifecycle Hooks
| Hook | When Called | Use Case |
|---|---|---|
onNodeAttached(node, gameEngine) | Entity is attached to a TransformNode | Initialize physics, particles, spatial audio |
onNodeDetached() | Entity is detached from its node | Dispose scene-dependent resources |
onReset(state) | Entity is recycled by the object pool | Clear internal timers, counters, cached refs |
destroy() | Entity is destroyed | Final cleanup of all resources |
TIP
If your behavior creates resources in onNodeAttached, implement both onNodeDetached (for detachment) and destroy (for entity destruction while still attached) to cover all cleanup paths.
onNodeAttached
Called when the entity receives a scene node, either at creation time or later via entityManager.attachToNode(). Access the scene via node.getScene() and engine subsystems via gameEngine.
import type { TransformNode } from '@babylonjs/core';
import type { GameEngine } from '@skewedaspect/sage';
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';
class PhysicsBodyBehavior extends GameEntityBehavior<{ mass : number }>
{
name = 'PhysicsBodyBehavior';
eventSubscriptions = [];
private _aggregate : PhysicsAggregate | null = null;
onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
{
const scene = node.getScene();
this._aggregate = new PhysicsAggregate(
node,
PhysicsShapeType.BOX,
{ mass: this.entity!.state.mass ?? 1 },
scene
);
}
onNodeDetached() : void
{
this._aggregate?.dispose();
this._aggregate = null;
}
processEvent() : boolean { return false; }
async destroy() : Promise<void>
{
this._aggregate?.dispose();
this._aggregate = null;
}
}onReset
Called when a poolable entity is returned to the pool and reused. The entity's state has already been restored to its defaults, so the state argument contains the freshly reset values. Use this to clear behavior-internal state that should not carry over between lives.
class CooldownBehavior extends GameEntityBehavior<{ attackSpeed : number }>
{
name = 'CooldownBehavior';
eventSubscriptions = [ 'combat:attack' ];
private cooldownTimer = 0;
private comboCount = 0;
update(dt : number, _state : { attackSpeed : number }) : void
{
if(this.cooldownTimer > 0)
{
this.cooldownTimer -= dt;
}
}
processEvent(event : GameEvent, state : { attackSpeed : number }) : boolean
{
if(event.type === 'combat:attack' && this.cooldownTimer <= 0)
{
this.comboCount++;
this.cooldownTimer = 1 / state.attackSpeed;
return true;
}
return false;
}
onReset(_state : { attackSpeed : number }) : void
{
this.cooldownTimer = 0;
this.comboCount = 0;
}
}Event Processing
processEvent
The core method where your behavior responds to events. It receives the event object and the entity's shared state, and returns true to stop propagation or false to let subsequent behaviors handle the event.
processEvent(event : GameEvent, state : RequiredState) : boolean | Promise<boolean>Behaviors are processed in attachment order. When a behavior returns true, no further behaviors see that event. This enables patterns like damage reduction chains:
const entityDef = {
type: 'character:tank',
defaultState: { /* ... */ },
behaviors: [
ShieldBehavior, // First chance to block damage
ArmorBehavior, // Second chance to reduce damage
HealthBehavior, // Finally, apply remaining damage
],
};Emitting Events
Use $emit() to publish events from within a behavior:
this.$emit({
type: 'entity:healed',
payload: { amount: 25 },
});The senderID is set automatically to the entity's ID.
State Change Notifications
Call $emitStateChanged() after significant mutations to notify UI layers and other observers:
this.$emitStateChanged();
// Or with specific changes
this.$emitStateChanged(undefined, { health: state.health });The update Method
The optional update method runs every frame. The dt parameter is the elapsed time in seconds since the last frame, enabling frame-rate independent logic.
update(dt : number, state : MovementState) : void
{
state.position.x += state.velocity.x * dt;
state.position.y += state.velocity.y * dt;
state.velocity.y -= 9.81 * dt; // gravity
}Dynamic Attachment
Behaviors can be attached and detached at runtime:
// Attach a new behavior
const invincibility = new InvincibilityBehavior();
entity.attachBehavior(invincibility);
// Detach by class (not instance)
entity.detachBehavior(InvincibilityBehavior);AttachBehaviorOptions
Control insertion position when attaching:
interface AttachBehaviorOptions
{
before ?: GameEntityBehaviorConstructor;
after ?: GameEntityBehaviorConstructor;
at ?: number;
}| Option | Description |
|---|---|
before | Insert before the behavior of this class |
after | Insert after the behavior of this class |
at | Insert at a specific 0-based index (clamped to valid range) |
Only one option may be specified per call.
entity.attachBehavior(new ArmorBehavior(), { before: ShieldBehavior });
entity.attachBehavior(new DebugBehavior(), { at: 0 });Querying Behaviors
// Check if a behavior class is attached
entity.hasBehavior(HealthBehavior); // boolean
// Get a behavior instance by class
const health = entity.getBehavior(HealthBehavior);Registering with Entity Definitions
Behaviors are listed in entity definitions and instantiated automatically when entities are created:
gameEngine.managers.entityManager.registerEntityDefinition({
type: 'character:hero',
defaultState: {
currentHealth: 100,
maxHealth: 100,
isAlive: true,
},
behaviors: [
HealthBehavior,
MovementBehavior,
],
});
const hero = await gameEngine.managers.entityManager.createEntity(
'character:hero',
{
initialState: { currentHealth: 80 },
name: 'Frodo',
tags: [ 'player', 'controllable' ],
}
);SoundBehavior
SAGE ships with a built-in SoundBehavior for managing sounds on entities. Sounds are configured in the entity's state and controlled at runtime via play/stop/pause methods.
import { SoundBehavior } from '@skewedaspect/sage';
import type { SoundConfig, SoundEntityState } from '@skewedaspect/sage';SoundConfig
| Property | Type | Default | Description |
|---|---|---|---|
url | string | -- | Path to the audio file (required) |
volume | number | 1 | Volume level (0-1) |
loop | boolean | false | Whether to loop the sound |
spatial | boolean | false | Use 3D positional audio (auto-attaches to entity node) |
maxDistance | number | 100 | Maximum distance for spatial audio |
autoplay | boolean | false | Start playing when registered |
channel | string | -- | Audio channel name (e.g., 'sfx', 'music'). Used with AudioV2. |
Entity definition
Configure sounds in your entity's defaultState.sounds map:
gameEngine.managers.entityManager.registerEntityDefinition({
type: 'character:player',
defaultState: {
sounds: {
jump: { url: 'audio/jump.ogg', volume: 0.8, channel: 'sfx' },
footstep: { url: 'audio/footstep.ogg', loop: true, channel: 'sfx' },
theme: { url: 'audio/player-theme.ogg', loop: true, channel: 'music', autoplay: true },
},
// ... other state
},
behaviors: [ SoundBehavior, /* ... */ ],
});Runtime control
Get the behavior instance from an entity and call its methods:
const soundBehavior = entity.getBehavior(SoundBehavior);
// Play a specific sound
soundBehavior?.play('jump');
// Stop a specific sound, or stop all
soundBehavior?.stop('footstep');
soundBehavior?.stop();
// Pause/check state
soundBehavior?.pause('theme');
soundBehavior?.isPlaying('theme'); // => false
// Adjust volume at runtime
soundBehavior?.setVolume(0.5, 'footstep');
soundBehavior?.setVolume(0.3); // all soundsIf the entity only has one sound, you can omit the name parameter from play(), pause(), and isPlaying().
Registering sounds at runtime
soundBehavior?.registerSound('alert', {
url: 'audio/alert.ogg',
volume: 1.0,
channel: 'sfx',
});
soundBehavior?.unregisterSound('alert');Querying sounds
soundBehavior?.getSoundNames(); // => ['jump', 'footstep', 'theme']
soundBehavior?.hasSound('jump'); // => trueAudioV2 integration
SoundBehavior requires AudioV2. It gets the AudioManager from gameEngine.managers.audioManager (passed automatically via onNodeAttached) and creates sounds via AudioManager.createSound(), routed to the specified channel. If no AudioManager is found, a warning is logged and no sounds are created.
When spatial is true, the sound is created with spatialEnabled and automatically attached to the entity's scene node, so the audio position tracks the entity in 3D space.
See Audio for how to set up AudioV2.
Composition Patterns
Filter Chain
Behaviors process events in sequence, with each potentially modifying or blocking the event:
Input -> ActionValidation -> ActionExecutionObserver Pattern
Multiple independent behaviors respond to the same events:
enemy:defeated -> AchievementBehavior
SoundBehavior
AnimationBehaviorEvent Relay
One behavior emits events that another consumes, without direct coupling:
// InventoryBehavior emits item:effect
// PotionEffectBehavior listens for item:effect, emits entity:heal
// HealthBehavior listens for entity:healCommunication Approaches
| Approach | Coupling | Best For |
|---|---|---|
| Direct state mutation | Tight | Closely related behaviors (movement + animation) |
| Event broadcasting | Loose | Cross-cutting concerns (sound, achievements) |
| Entity references in state | Medium | Entity-to-entity interactions (AI targeting) |
StateMachineBehavior
SAGE ships with a built-in StateMachineBehavior for finite state machines synced to entity state.
Creating with the Factory
Since behaviors use zero-argument constructors, StateMachineBehavior uses a static create() factory:
import { StateMachineBehavior } from '@skewedaspect/sage';
type EnemyStates = 'idle' | 'patrol' | 'chase' | 'attack' | 'dead';
interface EnemyState
{
aiState : EnemyStates;
health : number;
}
const EnemyAIBehavior = StateMachineBehavior.create<EnemyStates, EnemyState>({
initialState: 'idle',
stateKey: 'aiState',
transitions: [
{ from: 'idle', to: 'patrol' },
{ from: 'patrol', to: 'chase' },
{ from: 'chase', to: 'attack' },
{ from: 'attack', to: 'chase' },
{ from: 'attack', to: 'idle' },
],
wildcardTransitions: [
{ to: 'dead' },
],
});StateMachineBehaviorConfig
| Property | Type | Description |
|---|---|---|
initialState | States | The state the machine starts in |
stateKey | keyof EntityState | Entity state property updated on transitions |
transitions | { from, to, guard? }[] | Allowed transitions between specific states |
wildcardTransitions | { to, guard? }[] | Transitions allowed from any state |
Guards are optional functions returning boolean. A false return rejects the transition.
Triggering Transitions
const sm = entity.getBehavior(StateMachineBehavior);
sm?.transition('chase');
// Check current state
console.log(sm?.currentState); // 'chase'Standalone StateMachine
The StateMachine class can be used independently for game-level flow (screens, match phases) outside the entity system.
import { StateMachine } from '@skewedaspect/sage';
type GamePhase = 'title' | 'playing' | 'paused' | 'gameOver';
const gameFlow = new StateMachine<GamePhase>('title');
gameFlow.addTransition('title', 'playing');
gameFlow.addTransition('playing', 'paused');
gameFlow.addTransition('paused', 'playing');
gameFlow.addTransition('playing', 'gameOver');
gameFlow.addTransition('gameOver', 'title');
gameFlow.addTransitionFromAny('title');Lifecycle Callbacks
gameFlow.onEnter('playing', () => startGameLoop());
gameFlow.onExit('playing', () => stopGameLoop());Event Bus Integration
Pass a GameEventBus to emit events on transitions:
const gameFlow = new StateMachine<GamePhase>('title', eventBus);
// Events follow: state:enter:<stateName>, state:exit:<stateName>
// Payload: { from, to }
eventBus.subscribe('state:enter:playing', (event) =>
{
console.log(`Entered playing from ${ event.payload.from }`);
});Querying Transitions
if(gameFlow.canTransition('paused'))
{
gameFlow.transition('paused');
}
// Invalid transitions throw
gameFlow.transition('gameOver'); // throws if not defined from current stateTesting Behaviors
Behaviors are designed for isolated unit testing. Mock $emit and feed events directly:
import { describe, it, expect, vi } from 'vitest';
describe('HealthBehavior', () =>
{
it('should reduce health when damaged', () =>
{
const behavior = new HealthBehavior();
behavior.$emit = vi.fn();
const state = { currentHealth: 100, maxHealth: 100, isAlive: true };
behavior.processEvent(
{ type: 'entity:damage', payload: { amount: 30 } },
state
);
expect(state.currentHealth).toBe(70);
expect(state.isAlive).toBe(true);
});
it('should emit death event at zero health', () =>
{
const behavior = new HealthBehavior();
const emitted : unknown[] = [];
behavior.$emit = (event) => { emitted.push(event); };
const state = { currentHealth: 10, maxHealth: 100, isAlive: true };
behavior.processEvent(
{ type: 'entity:damage', payload: { amount: 50 } },
state
);
expect(state.currentHealth).toBe(0);
expect(state.isAlive).toBe(false);
expect(emitted[0]).toMatchObject({ type: 'entity:died' });
});
});