Entity Behaviors
This guide walks through SAGE's entity system by building a survival game: a player with health, mana, and a shield; enemies and bombers with chase AI; collectible pickups; and a game loop that ties it together.
The full source is in examples/src/examples/entity-behaviors/.
Try it live
Run this example at Examples > Entity Behaviors.
What composition is
An entity is an ID, one shared state bag, and an ordered list of behavior instances. Each behavior declares the slice of state it owns and the logic that reads and writes it. Different entity classes compose different behaviors:
| Entity | Behaviors |
|---|---|
PlayerEntity | ShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior, NamedBehavior |
EnemyEntity | HealthBehavior, ChaseAIBehavior, NamedBehavior |
BomberEntity | HealthBehavior, ChaseAIBehavior, NamedBehavior |
CollectibleEntity | CollectibleBehavior, NamedBehavior |
HealthBehavior is one class, attached to the player, enemies, and bombers alike. An enemy and a bomber compose the same behaviors; what makes a bomber a bomber is its state defaults and how the game loop treats its death.
Project structure
entity-behaviors/
├── index.vue # Vue component: UI and boot
├── playerStats.vue # Player stats panel, driven by props
├── events.ts # EventMap augmentation
├── constants.ts # Game balance values
├── behaviors/
│ ├── HealthBehavior.ts # Damage, healing, death
│ ├── MovementBehavior.ts # Position, velocity, arena bounds, movement actions
│ ├── ManaBehavior.ts # Mana regen, shield drain, restore
│ ├── ShieldBehavior.ts # Intercepts damage while shielded
│ ├── ChaseAIBehavior.ts # Chase AI shared by enemies and bombers
│ └── CollectibleBehavior.ts # Bobbing animation, per-kind material
├── entities/
│ ├── PlayerEntity.ts # NodeEntity subclasses, each with a behavior tuple and a meshConfig
│ ├── EnemyEntity.ts
│ ├── BomberEntity.ts
│ ├── CollectibleEntity.ts
│ └── index.ts # The entity registry
├── input/
│ ├── actions.ts # Actions, contexts, and bindings onto InputManager
│ └── helpers.ts
└── game/
├── GameController.ts # Spawning, game state, event subscriptions
├── BehaviorDemoLevel.ts # Custom Level: builds the scene, binds the simulation
├── scene.ts # BabylonJS scene setup
├── meshes.ts # Live material updates
└── loop.ts # Per-frame contact damage between hostiles and the playerStep 1: Declare the events
The event vocabulary is closed. Every event the game emits or subscribes to is declared once, by augmenting sage-core's EventMap:
// events.ts
declare module '@skewedaspect/sage-core'
{
interface EventMap
{
'action:damage' : { amount : number };
'action:heal' : { amount : number };
'action:drain-mana' : { amount : number };
'action:restore-mana' : object;
'action:raise-shield' : object;
'entity:died' : object;
'shield:raised' : object;
'shield:depleted' : object;
'action:cmdMoveForward' : number;
'action:cmdMoveRight' : number;
'action:cmdHeal' : boolean;
'action:cmdShield' : boolean;
}
}
export {};The action: events above the blank line are the game's own commands, targeted at one entity; entity:died and the shield: events are news an entity announces about itself, with no payload beyond the fact itself -- the sender, not the payload, says who it happened to. The last four are raw input actions, broadcast by InputManager. Both families are action:<name> events on one bus, so the cmd prefix keeps them apart.
Step 2: Write HealthBehavior
A behavior extends Behavior<State>, where State is the slice of the entity's state bag it declares. defaults are required, and static events lists the events it subscribes to.
import { Behavior } from '@skewedaspect/sage';
import type { GameEvent } from '@skewedaspect/sage';
export interface HealthState
{
health : number;
maxHealth : number;
}
export class HealthBehavior extends Behavior<HealthState>
{
static readonly events = [ 'action:damage', 'action:heal' ] as const;
readonly defaults : HealthState = { health: 0, maxHealth: 0 };
onEvent(event : GameEvent) : boolean
{
if(event.type === 'action:damage')
{
const wasAlive = this.state.health > 0;
this.state.health = Math.max(0, this.state.health - event.payload.amount);
if(wasAlive && this.state.health <= 0)
{
this.entity.emit('entity:died', {});
}
return true;
}
if(event.type === 'action:heal')
{
this.state.health = Math.min(this.state.maxHealth, this.state.health + event.payload.amount);
return true;
}
return false;
}
}this.stateis the behavior's own slice, writable. Narrowing onevent.typenarrowsevent.payload.- Returning
truefromonEventconsumes the event: later behaviors on the same entity do not see it. this.entity.emitqueues an event with this entity as sender. NotargetIDmeans a broadcast.- There is no
onUpdate. Health changes only in response to events.
Damage and heal arrive as targeted events, so this behavior only ever sees events addressed to its own entity. There is no ID in the payload to check.
Step 3: Write MovementBehavior
MovementBehavior shows both hooks: onEvent records the raw movement axes the input system broadcasts, and onUpdate turns them into motion once per fixed step.
export interface MovementState
{
position : { x : number; y : number; z : number };
velocity : { x : number; y : number; z : number };
speed : number;
moveForward : number;
moveRight : number;
}
export class MovementBehavior extends Behavior<MovementState>
{
static readonly events = [ 'action:cmdMoveForward', 'action:cmdMoveRight' ] as const;
readonly defaults : MovementState = {
position: { x: 0, y: 0, z: 0 },
velocity: { x: 0, y: 0, z: 0 },
speed: 0,
moveForward: 0,
moveRight: 0,
};
onEvent(event : GameEvent) : boolean
{
if(event.type === 'action:cmdMoveForward')
{
this.state.moveForward = event.payload;
return true;
}
if(event.type === 'action:cmdMoveRight')
{
this.state.moveRight = event.payload;
return true;
}
return false;
}
onUpdate(dt : number) : void
{
let x = -this.state.moveRight;
let z = -this.state.moveForward;
const length = Math.sqrt((x * x) + (z * z));
if(length > 1)
{
x /= length;
z /= length;
}
if(x !== 0 || z !== 0)
{
this.state.velocity.x = x * this.state.speed;
this.state.velocity.z = z * this.state.speed;
}
this.state.position.x += this.state.velocity.x * dt;
this.state.position.z += this.state.velocity.z * dt;
this.state.position.x = Math.max(-ARENA_HALF_SIZE, Math.min(ARENA_HALF_SIZE, this.state.position.x));
this.state.position.z = Math.max(-ARENA_HALF_SIZE, Math.min(ARENA_HALF_SIZE, this.state.position.z));
this.state.velocity.x *= MOVEMENT_FRICTION;
this.state.velocity.z *= MOVEMENT_FRICTION;
}
}onUpdate runs once per fixed step with the step's duration, so speed is in units per second. position is a state field computed from the Babylon node, so writing it moves the node as the write is made. Nothing syncs mesh positions by hand.
Step 4: Intercept damage with ShieldBehavior
Behaviors hear an event in attachment order, and the first one to return true stops the chain. The player lists ShieldBehavior before HealthBehavior:
export interface ShieldState
{
mana : number;
shieldActive : boolean;
}
export class ShieldBehavior extends Behavior<ShieldState>
{
static readonly events = [ 'action:damage', 'action:raise-shield' ] as const;
readonly defaults : ShieldState = { mana: 0, shieldActive: false };
onEvent(event : GameEvent) : boolean
{
if(event.type === 'action:raise-shield')
{
this.#raise();
return true;
}
if(event.type !== 'action:damage')
{
return false;
}
if(!this.state.shieldActive)
{
return false;
}
this.state.mana = Math.max(0, this.state.mana - event.payload.amount);
if(this.state.mana <= 0)
{
this.state.shieldActive = false;
this.entity.emit('shield:depleted', {});
}
return true;
}
// Nothing to raise with no mana behind it, and raising an already-raised shield is not news.
#raise() : void
{
if(this.state.shieldActive || this.state.mana <= 0)
{
return;
}
this.state.shieldActive = true;
this.entity.emit('shield:raised', {});
}
}With the shield up, ShieldBehavior drains mana and returns true, and HealthBehavior never sees the damage. With it down, the event passes through. Swap the order in the tuple and the shield does nothing. action:raise-shield is a semantic command the game controller issues, not the raw input action; whether a shield request should even become that command is the controller's call, covered in the next step.
ShieldBehavior and ManaBehavior both declare mana and shieldActive. Two behaviors declaring the same field is how they share a fact. The compiler rejects the composition if the two declarations have incompatible types.
Step 5: Regenerate mana in ManaBehavior
ManaBehavior owns the mana pool: draining, restoring, and regenerating it while the shield is down. It has nothing to do with whether a heal or a shield request should go through -- that depends on mana, health, and whether the game is running, none of which is one entity's business to decide about a request from the player. The game controller decides, and answers by emitting the semantic action (action:heal, action:raise-shield) the right behavior already answers:
export interface ManaState
{
health : number;
mana : number;
maxMana : number;
shieldActive : boolean;
}
export class ManaBehavior extends Behavior<ManaState>
{
static readonly events = [ 'action:drain-mana', 'action:restore-mana' ] as const;
readonly defaults : ManaState = {
health: 0,
mana: 0,
maxMana: 0,
shieldActive: false,
};
onEvent(event : GameEvent) : boolean
{
if(event.type === 'action:drain-mana')
{
this.state.mana = Math.max(0, this.state.mana - event.payload.amount);
return true;
}
if(event.type === 'action:restore-mana')
{
this.state.mana = this.state.maxMana;
return true;
}
return false;
}
onUpdate(dt : number) : void
{
if(this.state.health <= 0) { return; }
if(this.state.shieldActive)
{
this.state.mana = Math.max(0, this.state.mana - (SHIELD_MANA_DRAIN_RATE * dt));
if(this.state.mana <= 0)
{
this.state.shieldActive = false;
this.entity.emit('shield:depleted', {});
}
}
else if(this.state.mana < this.state.maxMana)
{
this.state.mana = Math.min(this.state.maxMana, this.state.mana + (MANA_REGEN_RATE * dt));
}
}
}ManaBehavior declares health even though it never writes it. A behavior only sees its own declared slice, so reading a field a sibling owns still means declaring it.
The raw input actions, action:cmdHeal and action:cmdShield, reach neither behavior. The game controller subscribes to them directly on the engine bus, checks the player's own state (mana, health, whether a run is under way), and only then issues the semantic action -- one more instance of the pattern Step 13 covers in full.
Step 6: Chase AI
ChaseAIBehavior chases an ID in its own state, targetID, rather than a live position the game loop would have to keep writing onto it. Resolving that ID is cross-entity access, the same getEntity/hasBehavior pair Talking to One Entity covers, so the position it chases is read fresh, and never held, at the top of every step:
export type ChaseAIState = 'idle' | 'chase' | 'attack';
export interface ChaseState
{
position : { x : number; y : number; z : number };
speed : number;
targetID : string | null;
aiState : ChaseAIState;
}
export class ChaseAIBehavior extends Behavior<ChaseState>
{
readonly defaults : ChaseState = {
position: { x: 0, y: 0, z: 0 },
speed: 0,
targetID: null,
aiState: 'idle',
};
onUpdate(dt : number) : void
{
const target = this.#targetPosition();
if(target === undefined)
{
this.state.aiState = 'idle';
return;
}
const deltaX = target.x - this.state.position.x;
const deltaZ = target.z - this.state.position.z;
const distance = Math.sqrt((deltaX * deltaX) + (deltaZ * deltaZ));
if(distance <= AI_ATTACK_DISTANCE && this.state.aiState === 'chase')
{
this.state.aiState = 'attack';
}
else if(distance > AI_ATTACK_DISTANCE && this.state.aiState === 'attack')
{
this.state.aiState = 'chase';
}
else if(this.state.aiState === 'idle')
{
this.state.aiState = 'chase';
}
const chasing = this.state.aiState === 'chase' || this.state.aiState === 'attack';
if(chasing && distance > AI_STOP_DISTANCE)
{
this.state.position.x += (deltaX / distance) * this.state.speed * dt;
this.state.position.z += (deltaZ / distance) * this.state.speed * dt;
}
}
// Where the target is right now, or nothing when it has gone or never moved under its own power.
#targetPosition() : { x : number; z : number } | undefined
{
if(this.state.targetID === null)
{
return undefined;
}
const target = this.entity.getEntity(this.state.targetID);
if(target === undefined || !target.hasBehavior(MovementBehavior))
{
return undefined;
}
return target.state.position;
}
}One behavior serves both EnemyEntity and BomberEntity: what makes a bomber a bomber is its state defaults and how the game answers its death, not different chase logic. The game controller sets targetID to the player's own ID in the state it spawns each hostile with, alongside position and speed, the same way Step 10 sets the player's own initial state. aiState is a plain state field; for a machine with enter and exit work per state, sage-core provides StateMachineBehavior; see Behaviors.
Step 7: Build a material in onMeshLoaded
CollectibleBehavior bobs its entity and colors its mesh by kind. The mesh's shape comes from the entity class's meshConfig, but the color depends on per-spawn state, so the behavior applies it once the mesh exists:
import { Color3, StandardMaterial } from '@babylonjs/core';
import { Behavior } from '@skewedaspect/sage';
import type { NodeBehaviorHooks, NodeEntity } from '@skewedaspect/sage';
export interface CollectibleState
{
position : { x : number; y : number; z : number };
kind : 'health' | 'mana';
value : number;
bobOffset : number;
spawnTime : number;
}
const MANA_COLOR = { diffuse: new Color3(0.3, 0.3, 1.0), emissive: new Color3(0.15, 0.1, 0.5) };
const HEALTH_COLOR = { diffuse: new Color3(0.2, 0.9, 0.3), emissive: new Color3(0.1, 0.5, 0.15) };
export class CollectibleBehavior extends Behavior<CollectibleState, object, NodeEntity> implements NodeBehaviorHooks
{
readonly defaults : CollectibleState = {
position: { x: 0, y: COLLECTIBLE_MESH_HEIGHT, z: 0 },
kind: 'health',
value: 0,
bobOffset: 0,
spawnTime: 0,
};
onMeshLoaded() : void
{
const mesh = this.entity.mesh;
if(mesh === undefined)
{
return;
}
const colors = this.state.kind === 'mana' ? MANA_COLOR : HEALTH_COLOR;
const material = new StandardMaterial(`${ mesh.name }-material`, mesh.getScene());
material.diffuseColor = colors.diffuse;
material.emissiveColor = colors.emissive;
material.alpha = 0;
mesh.material = material;
}
onUpdate(dt : number) : void
{
this.state.bobOffset += dt * COLLECTIBLE_BOB_SPEED;
this.state.position.y = COLLECTIBLE_MESH_HEIGHT + (Math.sin(this.state.bobOffset) * COLLECTIBLE_BOB_AMPLITUDE);
}
}Behavior's third type parameter names the entity class it attaches to, GameEntity unless it says otherwise; naming NodeEntity here is what makes this.entity.mesh and this.entity.node reachable with no cast. implements NodeBehaviorHooks is what documents that a behavior implements onMeshLoaded or onDestroy; a NodeEntity recognizes the hook structurally either way.
onMeshLoaded runs once per entity when its mesh loading settles, on a fresh spawn and on a restored one. It is the first hook where this.entity.mesh is safe to read. The entity acquires its node before any hook runs, so this.entity.node reads from onStateReady onwards.
Step 8: Entity classes
An entity class extends NodeEntity, names its behaviors in an as const tuple, and declares its mesh:
import { NodeEntity } from '@skewedaspect/sage';
import { NamedBehavior } from '@skewedaspect/sage-core';
const behaviors = [ ShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior, NamedBehavior ] as const;
export class PlayerEntity extends NodeEntity<typeof behaviors>
{
static override readonly behaviors = behaviors;
static override readonly meshConfig = {
source: 'sphere',
params: { diameter: 1.0, segments: 16 },
material: {
color: { r: 0.2, g: 0.5, b: 1.0 },
emissive: { r: 0.05, g: 0.12, b: 0.3 },
},
};
}The compiler checks the tuple. Naming it in NodeEntity<typeof behaviors> types state and ops over the composition, so instanceof PlayerEntity narrows state everywhere downstream; extended bare, with no generic, NodeEntity types state as object.
NamedBehavior comes from @skewedaspect/sage-core. Its one state field, displayName, is the per-instance label the entity list shows. An entity's own name is the registry key, the same for every instance.
Enemies, bombers, and collectibles are spawned and destroyed constantly, so their classes are poolable:
const behaviors = [ HealthBehavior, ChaseAIBehavior, NamedBehavior ] as const;
export class EnemyEntity extends NodeEntity<typeof behaviors>
{
static override readonly poolable = true;
static override readonly behaviors = behaviors;
static override readonly meshConfig = {
source: 'box',
params: { size: 0.7 },
material: {
color: { r: 1.0, g: 0.15, b: 0.15 },
emissive: { r: 0.25, g: 0.03, b: 0.03 },
},
};
}Pooling reuses the Babylon node and mesh set. The entity and its behaviors are constructed fresh on every spawn, so nothing carries over from a previous life.
The entity registry is what createGameEngine receives:
import type { NodeEntityRegistry } from '@skewedaspect/sage';
export const entityClasses = {
PlayerEntity,
EnemyEntity,
BomberEntity,
CollectibleEntity,
} satisfies NodeEntityRegistry;satisfies checks the object against NodeEntityRegistry without widening it to that type, so entityClasses keeps its own literal keys. That literal typing is what lets GameEngine<typeof entityClasses> and Level<typeof entityClasses> type spawn through the engine and inside a level, covered next.
Step 9: Bind the simulation to a scene
A NodeEntity builds its node in the simulation's own scene, so a level binds a simulation to its scene before anything spawns. The example's custom Level does that in buildScene:
import type { Scene } from '@babylonjs/core';
import { Level } from '@skewedaspect/sage';
import type { LevelConfig, LevelContext, MeshLoader } from '@skewedaspect/sage';
export class BehaviorDemoLevel extends Level<typeof entityClasses>
{
readonly #meshSource : MeshLoader | undefined;
constructor(config : LevelConfig, context : LevelContext<typeof entityClasses>)
{
super(config, context);
this.#meshSource = config.meshSource;
}
protected async buildScene() : Promise<Scene>
{
const scene = setupScene(this.context.sceneEngine);
this.rebuildSimulation(scene, this.#meshSource);
return scene;
}
}config.meshSource is only there in the constructor, so a level that needs it later keeps its own copy. this.context carries the level's dependencies by name -- the scene engine among them -- and this.rebuildSimulation(scene, meshSource?) is Level's own method, building the simulation as the level's. Naming entityClasses's own type, typeof entityClasses, in both Level and LevelContext is what types this level's own spawn through its registry, the same way it types the engine's.
The Vue component registers the level with a mesh source built from the entity registry, and activates it:
import { buildStandardMeshSource } from '@skewedaspect/sage';
engine.managers.levelManager.registerLevelClass('behavior-demo', BehaviorDemoLevel);
engine.managers.levelManager.registerLevelConfig({
name: 'demo',
class: 'behavior-demo',
meshSource: buildStandardMeshSource(entityClasses, engine.managers.assetManager),
});
await engine.managers.levelManager.activateLevel('demo');The standard mesh source reads each class's meshConfig. After this, engine.simulation holds a NodeSimulation bound to the scene.
Step 10: Spawn
spawn takes the registry name, the initial state, and an optional ID. It is synchronous; the mesh loads in the background and entity.mesh is undefined until it arrives.
const playerEntity = simulation.spawn('PlayerEntity', {
displayName: 'Hero',
tags: [ 'character', 'controllable' ],
position: { x: 0, y: PLAYER_MESH_HEIGHT, z: 0 },
velocity: { x: 0, y: 0, z: 0 },
speed: PLAYER_SPEED,
health: PLAYER_MAX_HEALTH,
maxHealth: PLAYER_MAX_HEALTH,
mana: PLAYER_MAX_MANA,
maxMana: PLAYER_MAX_MANA,
shieldActive: false,
}) as PlayerEntity;Every spawn takes the registry key as a literal, 'PlayerEntity', never PlayerEntity.name: a minifier renames the class, and the registry (entities/index.ts) is keyed by the name written there, which survives minification unchanged. Supplied state layers over the behaviors' defaults, field by field.
Step 11: Input
The game declares its actions, a context, and bindings onto engine.managers.inputManager, and activates the context. InputManager captures device state and broadcasts each fired binding as an action:<name> event once a frame, before the fixed steps. The Input System guide walks through that setup; this game's version lives in input/actions.ts.
MovementBehavior answers the movement axes directly. The raw action:cmdHeal and action:cmdShield actions reach the game controller, not a behavior; it decides whether a request goes through and answers with a semantic action of its own, the way Step 5 describes.
Step 12: The game loop
Cross-entity coordination is the game loop's job. Each frame, after the fixed steps have run, it checks distances between the player and every hostile and emits targeted events. This is the enemy half of the example's game/loop.ts; the file does the same for bombers, which explode on contact, and for the collectibles the player walks over:
const enemies = simulation.getEntitiesOfClass(EnemyEntity);
for(const enemy of enemies)
{
if(distance2D(enemy.state.position, playerEntity.state.position) < CONTACT_DISTANCE)
{
const healthRatio = enemy.state.health / enemy.state.maxHealth;
const damageRate = ENEMY_BASE_DAMAGE_RATE + (ENEMY_WOUNDED_DAMAGE_BONUS * (1 - healthRatio));
enemy.emit('action:damage', { amount: damageRate * dt }, playerEntity.id);
}
}getEntitiesOfClass(EnemyEntity) answers every entity of that class, typed as it. enemy.emit(type, payload, playerEntity.id) is a targeted event: the enemy is the sender, the player is the one subscriber that hears it, and it lands at the next drain.
Chasing needs no help from this loop: ChaseAIBehavior already resolves its own targetID each step (see Step 6), and an entity's own state, read from outside, is deep read-only, so the loop could not write a live position onto another entity even if it wanted to.
Step 13: Hearing news from outside the entity system
Writing a state field notifies nobody, but the engine has one bus, and anything holding it may register on it the way an entity does. The controller subscribes once, in its own setup, for the news it cares about:
const bus = engine.bus;
bus.on('entity:died', (event) =>
{
const entity = simulation.getEntity(event.senderID);
if(entity instanceof EnemyEntity)
{
score += ENEMY_KILL_SCORE;
simulation.despawn(entity.id);
}
});HealthBehavior emits entity:died with the entity's own ID as the sender, from inside the entity system. The subscription above hears it from outside: no polling, and no per-frame scan of the entity list to notice a health dropped to zero. See Events.
Event flow
When an enemy touches the player:
game loop detects contact
'-> enemy.emit('action:damage', { amount }, player.id) queued
'-> drain, at the end of the fixed step
'-> ShieldBehavior.onEvent
shield up: drain mana, return true HealthBehavior never sees it
shield down: return false
'-> HealthBehavior.onEvent
reduce health
health <= 0: emit entity:died queued for the next drain pass
'-> same drain, next pass
'-> the controller's bus.on('entity:died', ...) runsWhat you learned
- A behavior declares its state slice, its defaults, and the events it subscribes to
onEventhandles news andonUpdateruns once per fixed step; returningtrueconsumes an event- An entity class is a
NodeEntity<Behaviors>subclass with anas constbehavior tuple and ameshConfig - Naming the tuple in
NodeEntity<typeof behaviors>makesinstanceofnarrowing usable at every call site - Two behaviors declaring one field share it
NamedBehavior.displayNameis the per-instance label;nameis the registry key- A level binds a simulation to its scene with
rebuildSimulationbefore anything spawns - Targeted emits reach one entity; input actions arrive as broadcasts
- A relationship such as a chase target is an ID in state, resolved with
getEntityat the moment it is needed - The engine has one bus; the game controller subscribes on it directly instead of polling state
Next steps
- Input System: the actions and bindings this game declares
- Behaviors: the full behavior reference, including
StateMachineBehavior - Entities: cross-entity access, the hierarchy, and pooling
