Skip to content

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:

EntityBehaviors
PlayerShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior
EnemyHealthBehavior, EnemyAIBehavior
BomberHealthBehavior, BomberAIBehavior
CollectibleCollectibleBehavior

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 logic

Step 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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 to processEvent().
  • processEvent() return value — returning true means "I handled this, stop propagating." Later behaviors in the chain will not see the event. Returning false means "pass it along."
  • Event targeting — since all entities share an event bus, events include a targetId in 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 implements update() for regeneration.

Step 3: Write the MovementBehavior

MovementBehavior demonstrates the difference between event-driven logic (processEvent) and per-frame logic (update):

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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():

typescript
// 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:

typescript
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:

  1. Send movement events to the player entity based on input
  2. Update AI targets — set each enemy's targetPosition to the player's position
  3. Sync meshes — copy entity state.position to mesh positions
  4. Detect collisions — check distances for pickups, attacks, and explosions
typescript
// 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:

typescript
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():

typescript
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:

typescript
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():

typescript
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 replacement

What you learned

  • Entity state is composed from simple interfaces (HealthState, MovementState, etc.)
  • Behaviors extend GameEntityBehavior<T> and implement processEvent() and/or update()
  • Returning true from processEvent() 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 events
  • attachToNode() binds BabylonJS meshes to entities, accessible via entity.node
  • poolable: true enables object pooling for frequently created/destroyed entities
  • The game loop handles cross-entity coordination; behaviors handle intra-entity logic

Released under the MIT License.