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, heal and shield requests
│ ├── 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 class table
├── input/
│ ├── actions.ts # Actions, contexts, and bindings onto InputManager
│ └── helpers.ts
└── game/
├── GameController.ts # Spawning, game state, polling
├── BehaviorDemoLevel.ts # Custom Level: builds the scene, binds the simulation
├── scene.ts # BabylonJS scene setup
├── meshes.ts # Live material updates
└── loop.ts # Per-frame AI targeting and combatStep 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 };
'entity:died' : { entityId : string };
'action:cmdMoveForward' : number;
'action:cmdMoveRight' : number;
'action:cmdHeal' : boolean;
'action:cmdShield' : boolean;
}
}
export {};The first four are the game's own commands, emitted by the game loop at one entity. 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 { GameEntityEvent as 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', { entityId: this.entity.id });
}
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, and NodeEntity writes it onto the Babylon node after every behavior has updated. 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' ] as const;
readonly defaults : ShieldState = { mana: 0, shieldActive: false };
onEvent(event : GameEvent) : boolean
{
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;
}
return true;
}
}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.
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: Count requests in ManaBehavior
Heal and shield are raw input actions. Whether a request does anything depends on mana, health, and whether the game is running, which is the game controller's call. So ManaBehavior counts the requests, and the controller decides:
export interface ManaState
{
health : number;
mana : number;
maxMana : number;
shieldActive : boolean;
healRequested : number;
shieldRequested : number;
}
export class ManaBehavior extends Behavior<ManaState>
{
static readonly events = [ 'action:drain-mana', 'action:cmdHeal', 'action:cmdShield' ] as const;
readonly defaults : ManaState = {
health: 0,
mana: 0,
maxMana: 0,
shieldActive: false,
healRequested: 0,
shieldRequested: 0,
};
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:cmdHeal')
{
this.state.healRequested += 1;
return true;
}
if(event.type === 'action:cmdShield')
{
this.state.shieldRequested += 1;
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;
}
}
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.
A counter rather than a flag means a request is never lost when two actions land in the same drain.
Step 6: Chase AI
ChaseAIBehavior reads the entity's own state and nothing else. The game loop writes targetPosition onto every hostile each frame:
export type ChaseAIState = 'idle' | 'chase' | 'attack';
export interface ChaseState
{
position : { x : number; y : number; z : number };
speed : number;
targetPosition : { x : number; y : number; z : number } | null;
aiState : ChaseAIState;
}
export class ChaseAIBehavior extends Behavior<ChaseState>
{
readonly defaults : ChaseState = {
position: { x: 0, y: 0, z: 0 },
speed: 0,
targetPosition: null,
aiState: 'idle',
};
onUpdate(dt : number) : void
{
const target = this.state.targetPosition;
if(!target)
{
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;
}
}
}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, type Mesh, StandardMaterial } from '@babylonjs/core';
import { Behavior, NodeEntity } from '@skewedaspect/sage';
export class CollectibleBehavior extends Behavior<CollectibleState>
{
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 as NodeEntity).mesh as Mesh | undefined;
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);
}
}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 and this.entity.node are safe to read; from onCreate, neither exists yet.
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 type { OpsFor, StateFor } from '@skewedaspect/sage';
import { NamedBehavior } from '@skewedaspect/sage-core';
import { HealthBehavior, ManaBehavior, MovementBehavior, ShieldBehavior } from '../behaviors/index.ts';
const behaviors = [ ShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior, NamedBehavior ] as const;
export class PlayerEntity extends NodeEntity
{
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 },
},
};
declare readonly state : StateFor<typeof behaviors>;
declare readonly ops : OpsFor<typeof behaviors>;
}The compiler checks the tuple. The two declare readonly lines re-type state and ops over it, so that instanceof PlayerEntity narrows state everywhere downstream; without them, 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 class-table 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
{
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 },
},
};
declare readonly state : StateFor<typeof behaviors>;
declare readonly ops : OpsFor<typeof behaviors>;
}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 class table is what createGameEngine receives:
import type { GameEntityType } from '@skewedaspect/sage';
export const entityClasses : Readonly<Record<string, GameEntityType>> = {
PlayerEntity,
EnemyEntity,
BomberEntity,
CollectibleEntity,
};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 { Level } from '@skewedaspect/sage';
import type { LevelConfig, LevelContext } from '@skewedaspect/sage';
export class BehaviorDemoLevel extends Level
{
protected async buildScene() : Promise<Scene>
{
const scene = setupScene(this.config.engine, this.config.canvas);
this.config.engine.rebuildSimulation(scene, this.config.meshSource);
return scene;
}
}The Vue component registers the level with a mesh source built from the class table, and activates it:
import { buildStandardMeshSource } from '@skewedaspect/sage';
engine.managers.levelManager.registerLevelClass('behavior-demo', BehaviorDemoLevel);
engine.managers.levelManager.registerLevelConfig({
name: 'demo',
class: 'behavior-demo',
engine,
canvas,
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 class-table name, an optional ID, and initial state. It is synchronous; the mesh loads in the background and entity.mesh is undefined until it arrives.
this.playerEntity = simulation.spawn(PlayerEntity.name, undefined, {
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;The example's class table uses each class's own name as its key, which is why PlayerEntity.name works here. 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. ManaBehavior counts heal and shield requests, and the controller polls the counters each frame and decides whether a request goes through.
Step 12: The game loop
Cross-entity coordination is the game loop's job. Each frame, after the fixed steps have run, it points every hostile at the player, checks distances, and emits targeted events:
for(const entity of simulation.entities)
{
if(entity instanceof EnemyEntity)
{
entity.state.targetPosition = { ...playerEntity.state.position };
enemies.push(entity);
}
}
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);
}
}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. instanceof EnemyEntity narrows enemy.state because the class re-declared it.
Writing a state field notifies nobody, so the controller polls. Once a frame it reads the player's health, mana, and shield state for the UI, reads each hostile's health to score kills and drop loot, and reads the request counters:
private pollHostileDeaths() : void
{
for(const entity of simulation.entities)
{
if(entity instanceof EnemyEntity && entity.state.health <= 0)
{
this.score += ENEMY_KILL_SCORE;
simulation.despawn(entity.id);
}
}
}HealthBehavior still emits entity:died. Nothing outside the entity system subscribes to the simulation bus in this example, so the controller reads health instead. Anything holding the simulation may register a subscriber on simulation.bus; 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
'-> next frame, the controller polls player.state.healthWhat 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
NodeEntitysubclass with anas constbehavior tuple and ameshConfig - The
declare readonly stateandopslines makeinstanceofnarrowing usable at every call site - Two behaviors declaring one field share it
NamedBehavior.displayNameis the per-instance label;nameis the class-table key- A level binds a simulation to its scene with
rebuildSimulationbefore anything spawns - Targeted emits reach one entity; input actions arrive as broadcasts
- The game loop coordinates between entities and polls state for the UI
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
