Entity Behaviors
This guide walks you through SAGE's entity-behavior composition system by building a survival game. You will create a player with health, mana, and a shield; enemies with state-machine AI; collectible pickups with bobbing animations; and a game loop that ties it all together through events.
The full source is in examples/src/examples/entity-behaviors/.
Try it live
Run this example at Examples > Entity Behaviors.
What entity-behavior composition is
Traditional game engines use inheritance: Enemy extends Character extends GameObject. SAGE uses composition instead. An entity is a container that holds state (data) and behaviors (logic). Different entities compose different behaviors:
| Entity | Behaviors |
|---|---|
| Player | ShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior |
| Enemy | HealthBehavior, EnemyAIBehavior |
| Bomber | HealthBehavior, BomberAIBehavior |
| Collectible | CollectibleBehavior |
HealthBehavior is the same class used by the player, regular enemies, and bombers. Write it once, attach it to anything that has health.
Project structure
entity-behaviors/
├── index.vue # Vue component — UI and initialization
├── playerStats.vue # Player stats panel (uses useSageState)
├── types.ts # State interfaces and type guards
├── constants.ts # Game balance values
├── behaviors/
│ ├── HealthBehavior.ts # Damage, healing, death events
│ ├── MovementBehavior.ts# Velocity-based movement with friction
│ ├── ManaBehavior.ts # Mana pool, regen, shield drain
│ ├── ShieldBehavior.ts # Intercepts damage events
│ ├── EnemyAIBehavior.ts # Chase AI via StateMachineBehavior
│ ├── BomberAIBehavior.ts# Fast chase variant
│ └── CollectibleBehavior.ts # Bobbing animation
├── entities/
│ └── definitions.ts # Entity templates (type, state, behaviors)
├── input/
│ ├── bindings.ts # Input binding configuration
│ └── helpers.ts # Input descriptor factories
└── game/
├── GameController.ts # Main orchestration
├── BehaviorDemoLevel.ts # Custom Level subclass
├── scene.ts # BabylonJS scene setup
├── entities.ts # Entity creation and mesh management
└── loop.ts # Per-frame game logicStep 1: Define state interfaces
State is just data. Behaviors read and modify state, but the state itself is a plain object — no methods, no inheritance chains. SAGE entities use interface composition to build complex state from simple pieces:
// Every entity has a position
export interface BaseEntityState
{
position : { x : number; y : number; z : number };
}
// Entities that can take damage
export interface HealthState
{
health : number;
maxHealth : number;
}
// Entities that can move
export interface MovementState
{
velocity : { x : number; y : number; z : number };
speed : number;
}The player composes all of these plus its own unique properties:
export interface PlayerState extends BaseEntityState, HealthState, MovementState
{
score : number;
mana : number;
maxMana : number;
shieldActive : boolean;
}Enemies share BaseEntityState, HealthState, and MovementState but add AI-specific fields:
export interface EnemyState extends BaseEntityState, HealthState, MovementState
{
targetId : string | null;
targetPosition : { x : number; y : number; z : number } | null;
aiState : 'idle' | 'chase' | 'attack';
}Collectibles are simple — just a position and pickup metadata:
export interface CollectibleState extends BaseEntityState
{
kind : 'health' | 'mana';
value : number;
bobOffset : number;
spawnTime : number;
}Step 2: Write the HealthBehavior
Behaviors extend GameEntityBehavior<T> where T is the state shape they require. Here is the complete HealthBehavior:
import { GameEntityBehavior, type GameEvent } from '@skewedaspect/sage';
import type { HealthState } from '../types.ts';
export class HealthBehavior extends GameEntityBehavior<HealthState>
{
name = 'health';
eventSubscriptions = [ 'action:damage', 'action:heal' ];
processEvent(event : GameEvent, state : HealthState) : boolean
{
if(!this.entity) { return false; }
const payload = event.payload as { targetId ?: string; amount ?: number } | undefined;
if(event.type === 'action:damage')
{
// Event targeting — ignore damage meant for other entities
if(payload?.targetId && payload.targetId !== this.entity.id)
{
return false;
}
const wasAlive = state.health > 0;
const damage = payload?.amount ?? 10;
state.health = Math.max(0, state.health - damage);
// Notify listeners that state changed
this.$emitStateChanged(state, { health: state.health });
// Emit death event on the alive -> dead transition
if(wasAlive && state.health <= 0)
{
this.$emit({
type: 'entity:died',
payload: { entityId: this.entity.id },
});
}
return true; // Event consumed
}
if(event.type === 'action:heal')
{
if(payload?.targetId && payload.targetId !== this.entity.id)
{
return false;
}
const amount = payload?.amount ?? 10;
state.health = Math.min(state.maxHealth, state.health + amount);
this.$emitStateChanged(state, { health: state.health });
return true;
}
return false;
}
}Key concepts demonstrated here:
eventSubscriptions— declares which event types this behavior cares about. SAGE only delivers matching events toprocessEvent().processEvent()return value — returningtruemeans "I handled this, stop propagating." Later behaviors in the chain will not see the event. Returningfalsemeans "pass it along."- Event targeting — since all entities share an event bus, events include a
targetIdin their payload. Behaviors check this to ignore events meant for other entities. $emitStateChanged()— notifies the system (and any UI bindings) that state has changed.$emit()— publishes a new event to the entity's event bus.- No
update()method — health does not change over time on its own. It only changes in response to events. Compare this with ManaBehavior, which implementsupdate()for regeneration.
Step 3: Write the MovementBehavior
MovementBehavior demonstrates the difference between event-driven logic (processEvent) and per-frame logic (update):
export class MovementBehavior extends GameEntityBehavior<BaseEntityState & MovementState>
{
name = 'movement';
eventSubscriptions = [ 'action:move' ];
processEvent(event : GameEvent, state : BaseEntityState & MovementState) : boolean
{
if(event.type === 'action:move')
{
const payload = (event.payload ?? {}) as { x ?: number; z ?: number };
const { x = 0, z = 0 } = payload;
// Convert direction to velocity using the entity's speed
state.velocity.x = x * state.speed;
state.velocity.z = z * state.speed;
return true;
}
return false;
}
update(deltaTime : number, state : BaseEntityState & MovementState) : void
{
// Apply velocity to position (frame-rate independent)
state.position.x += state.velocity.x * deltaTime;
state.position.z += state.velocity.z * deltaTime;
// Clamp to arena bounds
state.position.x = Math.max(-ARENA_HALF_SIZE, Math.min(ARENA_HALF_SIZE, state.position.x));
state.position.z = Math.max(-ARENA_HALF_SIZE, Math.min(ARENA_HALF_SIZE, state.position.z));
// Apply friction for smooth deceleration
state.velocity.x *= MOVEMENT_FRICTION;
state.velocity.z *= MOVEMENT_FRICTION;
}
}The update() method is called every frame by the game loop. Use it for continuous logic: physics, animations, timers, cooldowns. Use processEvent() for reactive logic that only runs in response to something happening.
Note the generic type BaseEntityState & MovementState. This behavior works with any entity that has both position and velocity/speed in its state — the player, enemies, anything you want to move.
Step 4: Write the ShieldBehavior (event interception)
This is where behavior ordering gets interesting. The ShieldBehavior intercepts damage events before HealthBehavior sees them:
export class ShieldBehavior extends GameEntityBehavior<PlayerState>
{
name = 'shield';
eventSubscriptions = [ 'action:damage' ];
processEvent(event : GameEvent, state : PlayerState) : boolean
{
if(!this.entity) { return false; }
if(event.type === 'action:damage')
{
const payload = event.payload as { targetId ?: string; amount ?: number } | undefined;
if(payload?.targetId && payload.targetId !== this.entity.id)
{
return false;
}
// Shield not active — let damage pass through to HealthBehavior
if(!state.shieldActive)
{
return false;
}
// Shield active — convert damage to mana drain
const amount = payload?.amount ?? 10;
state.mana -= amount;
if(state.mana <= 0)
{
state.mana = 0;
state.shieldActive = false;
this.$emitStateChanged(state, { mana: state.mana, shieldActive: false });
}
else
{
this.$emitStateChanged(state, { mana: state.mana });
}
// CONSUME the event — HealthBehavior never sees this damage
return true;
}
return false;
}
}Behavior order matters
In the player's entity definition, behaviors are ordered [ ShieldBehavior, HealthBehavior, ... ]. SAGE delivers events to behaviors in this order. When ShieldBehavior returns true, the event is consumed and HealthBehavior never sees it. If the shield is down, ShieldBehavior returns false and the event passes through to HealthBehavior normally.
Swap the order and the shield does nothing — HealthBehavior would consume the damage first.
Step 5: Write the ManaBehavior
ManaBehavior handles the mana resource pool. It uses both processEvent() for instant mana costs and update() for continuous regeneration and shield drain:
export class ManaBehavior extends GameEntityBehavior<PlayerState>
{
name = 'mana';
eventSubscriptions = [ 'action:drain-mana' ];
processEvent(event : GameEvent, state : PlayerState) : boolean
{
if(!this.entity) { return false; }
if(event.type === 'action:drain-mana')
{
const payload = event.payload as { targetId ?: string; amount ?: number } | undefined;
if(payload?.targetId && payload.targetId !== this.entity.id)
{
return false;
}
const amount = payload?.amount ?? 10;
state.mana = Math.max(0, state.mana - amount);
this.$emitStateChanged(state, { mana: state.mana });
return true;
}
return false;
}
update(deltaTime : number, state : PlayerState) : void
{
if(state.health <= 0) { return; }
if(state.shieldActive)
{
// Shield drains mana continuously
state.mana = Math.max(0, state.mana - (SHIELD_MANA_DRAIN_RATE * deltaTime));
if(state.mana <= 0)
{
state.shieldActive = false;
this.$emitStateChanged(state, { mana: state.mana, shieldActive: false });
}
else
{
this.$emitStateChanged(state, { mana: state.mana });
}
}
else if(state.mana < state.maxMana)
{
// Regen when shield is off
state.mana = Math.min(state.maxMana, state.mana + (MANA_REGEN_RATE * deltaTime));
this.$emitStateChanged(state, { mana: state.mana });
}
}
}ManaBehavior and ShieldBehavior both read and write mana and shieldActive. This is intentional — they represent different aspects of the same resource system. ShieldBehavior handles the damage-to-mana conversion; ManaBehavior handles the passive costs and regeneration.
Step 6: Write enemy AI with StateMachineBehavior
SAGE provides StateMachineBehavior for building state-driven AI. You define states and allowed transitions, and the base class handles validation and entity state synchronization:
import { StateMachineBehavior } from '@skewedaspect/sage';
import type { EnemyState } from '../types.ts';
type EnemyAIState = 'idle' | 'chase' | 'attack';
const EnemyAIBase = StateMachineBehavior.create<EnemyAIState, EnemyState>({
initialState: 'idle',
stateKey: 'aiState', // Syncs to state.aiState automatically
transitions: [
{ from: 'idle', to: 'chase' },
{ from: 'chase', to: 'attack' },
{ from: 'chase', to: 'idle' },
{ from: 'attack', to: 'chase' },
],
wildcardTransitions: [
{ to: 'idle' }, // Any state can return to idle (for pooling)
],
});StateMachineBehavior.create() returns a class. Extend it to add per-frame logic:
export class EnemyAIBehavior extends EnemyAIBase
{
override name = 'enemy-ai';
update(deltaTime : number, state : EnemyState) : void
{
if(!state.targetPosition)
{
if(this.currentState !== 'idle')
{
this.transition('idle');
}
return;
}
// Calculate distance to target
const deltaX = state.targetPosition.x - state.position.x;
const deltaZ = state.targetPosition.z - state.position.z;
const distance = Math.sqrt((deltaX * deltaX) + (deltaZ * deltaZ));
// State transitions based on distance
if(distance <= AI_ATTACK_DISTANCE && this.currentState === 'chase')
{
this.transition('attack');
}
else if(distance > AI_ATTACK_DISTANCE && this.currentState === 'attack')
{
this.transition('chase');
}
else if(this.currentState === 'idle')
{
this.transition('chase');
}
// Move toward target
if((this.currentState === 'chase' || this.currentState === 'attack')
&& distance > AI_STOP_DISTANCE)
{
const moveX = (deltaX / distance) * state.speed * deltaTime;
const moveZ = (deltaZ / distance) * state.speed * deltaTime;
state.position.x += moveX;
state.position.z += moveZ;
}
}
// Reset to idle when the entity is recycled from the object pool
onReset(_state : EnemyState) : void
{
if(this.currentState !== 'idle')
{
this.transition('idle');
}
}
}The stateKey: 'aiState' configuration means the state machine automatically syncs its current state to state.aiState. You can read that property anywhere to know what AI state an enemy is in.
The onReset() method supports SAGE's object pooling. When an entity is recycled (created from the pool rather than freshly constructed), onReset fires so behaviors can reinitialize. Without it, a recycled enemy would start in whatever state it died in.
BomberAIBehavior is nearly identical but has different balance constants — faster speed, lower health, no contact damage (bombers explode on death instead, handled by the game loop).
Step 7: Write the CollectibleBehavior
The simplest behavior in the example. It demonstrates that behaviors do not need to be complex:
export class CollectibleBehavior extends GameEntityBehavior<CollectibleState>
{
name = 'collectible';
eventSubscriptions = [ 'action:collect' ];
processEvent(event : GameEvent, _state : CollectibleState) : boolean
{
if(event.type === 'action:collect')
{
return true; // Acknowledged — actual pickup handled by game loop
}
return false;
}
update(deltaTime : number, state : CollectibleState) : void
{
state.bobOffset += deltaTime * COLLECTIBLE_BOB_SPEED;
state.position.y = COLLECTIBLE_MESH_HEIGHT
+ (Math.sin(state.bobOffset) * COLLECTIBLE_BOB_AMPLITUDE);
}
}A sine wave on the Y position creates smooth up-and-down motion. The bobOffset accumulates over time, and its state is part of the entity — meaning it could be serialized and restored.
Step 8: Define entity templates
Entity definitions are templates that specify the type name, default state values, and which behaviors to attach. Here is the player definition:
import type { GameEntityDefinition } from '@skewedaspect/sage';
export function createPlayerDefinition(logEvent : LogEventFn) : GameEntityDefinition<PlayerState>
{
return {
type: 'player',
name: 'Player',
defaultState: {
position: { x: 0, y: 0.5, z: 0 },
velocity: { x: 0, y: 0, z: 0 },
health: 100,
maxHealth: 100,
speed: 5,
score: 0,
mana: 100,
maxMana: 100,
shieldActive: false,
},
// ORDER MATTERS — ShieldBehavior must come before HealthBehavior
behaviors: [ ShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior ],
tags: [ 'character', 'controllable' ],
onCreate: (state) =>
{
logEvent(`Player created with ${ state.health } HP`, 'success');
},
onDestroy: () =>
{
logEvent('Player destroyed!', 'danger');
},
};
}Enemy and bomber definitions follow the same pattern with different behaviors and state values. Enemies use [ HealthBehavior, EnemyAIBehavior ], bombers use [ HealthBehavior, BomberAIBehavior ]. Both mark themselves as poolable: true for object pooling since they are created and destroyed frequently:
export function createEnemyDefinition(logEvent : LogEventFn) : GameEntityDefinition<EnemyState>
{
return {
type: 'enemy',
name: 'Enemy',
defaultState: {
position: { x: 0, y: 0.4, z: 0 },
velocity: { x: 0, y: 0, z: 0 },
health: 30,
maxHealth: 30,
speed: 1.5,
targetId: null,
targetPosition: null,
aiState: 'idle' as const,
},
poolable: true,
behaviors: [ HealthBehavior, EnemyAIBehavior ],
tags: [ 'character', 'hostile' ],
};
}Register all definitions once during initialization:
entityManager.registerEntityDefinition(createPlayerDefinition(logEvent));
entityManager.registerEntityDefinition(createEnemyDefinition(logEvent));
entityManager.registerEntityDefinition(createBomberDefinition(logEvent));
entityManager.registerEntityDefinition(createCollectibleDefinition());Step 9: Create entities and attach meshes
With definitions registered, creating entities is straightforward. Each entity gets a BabylonJS mesh bound to it via attachToNode():
// Create the player entity
const player = await entityManager.createEntity<PlayerState>('player', {
name: 'Hero',
});
// Create a mesh for it
const mesh = MeshBuilder.CreateSphere('player', { diameter: 1.0 }, scene);
const mat = new StandardMaterial('playerMat', scene);
mat.diffuseColor = new Color3(0.2, 0.5, 1.0);
mesh.material = mat;
mesh.position.set(0, 0.5, 0);
// Bind mesh to entity — accessible later via entity.node
entityManager.attachToNode(player, mesh);attachToNode() is the proper way to associate meshes with SAGE entities. After binding, the mesh is accessible as entity.node throughout the codebase, including inside behaviors.
Enemies spawn at random positions on the arena edge:
const angle = Math.random() * Math.PI * 2;
const x = Math.cos(angle) * 4;
const z = Math.sin(angle) * 4;
const enemy = await entityManager.createEntity<EnemyState>('enemy', {
name: `Enemy ${ Math.floor(Math.random() * 1000) }`,
initialState: {
position: { x, y: 0.4, z },
targetId: player.id,
},
});The initialState in createEntity() is merged with the definition's defaultState. You only need to specify properties that differ from the defaults.
Step 10: The game loop
The game controller's update() method runs each frame. It handles cross-entity coordination that individual behaviors cannot do on their own:
- Send movement events to the player entity based on input
- Update AI targets — set each enemy's
targetPositionto the player's position - Sync meshes — copy entity
state.positionto mesh positions - Detect collisions — check distances for pickups, attacks, and explosions
// Send movement to the player via its event bus
if(moveX !== 0 || moveZ !== 0)
{
playerEntity.eventBus.publish({
type: 'action:move',
payload: { x: normalizedX, z: normalizedZ },
});
}
// Set enemy targets
for(const enemy of sageEntityManager.getByType('enemy'))
{
if(isEnemyState(enemy.state))
{
enemy.state.targetPosition = {
x: playerPos.x,
y: playerPos.y,
z: playerPos.z,
};
}
}
// Sync mesh positions
for(const entity of sageEntityManager.getAllEntities())
{
const mesh = entity.node as Mesh | undefined;
if(mesh && hasPosition(entity.state))
{
mesh.position.x = entity.state.position.x;
mesh.position.y = entity.state.position.y;
mesh.position.z = entity.state.position.z;
}
}Contact damage between enemies and the player is also handled in the game loop, not in behaviors. The game loop publishes action:damage events to the appropriate entity's event bus:
if(distance < CONTACT_DISTANCE)
{
// Wounded enemies deal more damage
const healthRatio = enemy.state.health / enemy.state.maxHealth;
const damageRate = ENEMY_BASE_DAMAGE_RATE
+ (ENEMY_WOUNDED_DAMAGE_BONUS * (1 - healthRatio));
playerEntity.eventBus.publish({
type: 'action:damage',
payload: { amount: damageRate * deltaTime, targetId: playerEntity.id },
});
}This is the correct split of responsibilities: behaviors handle what happens inside an entity (take damage, update health, check death). The game loop handles what happens between entities (who is close enough to hit whom).
Step 11: Using the Level system
The example uses SAGE's Level class to manage scene creation. A custom level class extends Level and implements buildScene():
import { Level, type LevelConfig } from '@skewedaspect/sage';
export class BehaviorDemoLevel extends Level
{
protected async buildScene() : Promise<Scene>
{
return setupScene(this.gameEngine, this.config.canvas);
}
async unload() : Promise<void>
{
this._scene?.dispose();
this._scene = null;
}
}Register the level class and activate it before creating entities:
engine.managers.levelManager.registerLevelClass('behavior-demo', BehaviorDemoLevel);
engine.managers.levelManager.registerLevelConfig({ name: 'demo', class: 'behavior-demo', engine, canvas });
await engine.managers.levelManager.activateLevel('demo');With the level system, SAGE handles entity updates and rendering automatically. You register a frame callback for your custom game logic, then call engine.start():
engine.managers.gameManager.registerFrameCallback((dt) =>
{
gameController.update(dt);
});
await engine.start();Event flow summary
Here is the complete event flow when an enemy hits the player:
Game Loop detects contact
└─> publishes action:damage to player's eventBus
└─> ShieldBehavior.processEvent()
├─ Shield active?
│ YES: drain mana, return true (consumed)
│ └─> HealthBehavior never sees the event
│
│ NO: return false (pass through)
└─> HealthBehavior.processEvent()
├─ Reduce health
├─ $emitStateChanged() → UI updates
└─ If health <= 0: $emit('entity:died')
└─> GameController handles death
├─ Award score
├─ Destroy entity
├─ Random loot drop
└─ Spawn replacementWhat you learned
- Entity state is composed from simple interfaces (
HealthState,MovementState, etc.) - Behaviors extend
GameEntityBehavior<T>and implementprocessEvent()and/orupdate() - Returning
truefromprocessEvent()consumes the event — later behaviors do not see it - Behavior order in entity definitions controls event processing order
StateMachineBehavior.create()builds state-driven AI with validated transitions$emitStateChanged()notifies the UI;$emit()publishes new eventsattachToNode()binds BabylonJS meshes to entities, accessible viaentity.nodepoolable: trueenables object pooling for frequently created/destroyed entities- The game loop handles cross-entity coordination; behaviors handle intra-entity logic
