Skip to content

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:

EntityBehaviors
PlayerEntityShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior, NamedBehavior
EnemyEntityHealthBehavior, ChaseAIBehavior, NamedBehavior
BomberEntityHealthBehavior, ChaseAIBehavior, NamedBehavior
CollectibleEntityCollectibleBehavior, 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 combat

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

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

typescript
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.state is the behavior's own slice, writable. Narrowing on event.type narrows event.payload.
  • Returning true from onEvent consumes the event: later behaviors on the same entity do not see it.
  • this.entity.emit queues an event with this entity as sender. No targetID means 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.

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

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

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

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

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

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

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

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

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

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

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

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

typescript
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.health

What you learned

  • A behavior declares its state slice, its defaults, and the events it subscribes to
  • onEvent handles news and onUpdate runs once per fixed step; returning true consumes an event
  • An entity class is a NodeEntity subclass with an as const behavior tuple and a meshConfig
  • The declare readonly state and ops lines make instanceof narrowing usable at every call site
  • Two behaviors declaring one field share it
  • NamedBehavior.displayName is the per-instance label; name is the class-table key
  • A level binds a simulation to its scene with rebuildSimulation before 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

Released under the MIT License.