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, 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 player

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 };
        '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.

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

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

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

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

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

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

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

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

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

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

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

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

typescript
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', ...) runs

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<Behaviors> subclass with an as const behavior tuple and a meshConfig
  • Naming the tuple in NodeEntity<typeof behaviors> makes instanceof narrowing usable at every call site
  • Two behaviors declaring one field share it
  • NamedBehavior.displayName is the per-instance label; name is the registry key
  • A level binds a simulation to its scene with rebuildSimulation before 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 getEntity at 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

Released under the MIT License.