Skip to content

Gameplay AI

Estella’s AI layer turns an entity into an actor that senses, decides, and moves. It is built from four cooperating pieces that share one design:

Perception

A Perceiver senses nearby PerceptionTargets within a sight cone and writes what it sees into a Perception component.

Navigation

A NavGrid + A* pathfinding moves a NavAgent to any world point, replanning as the target moves.

State machines

A StateMachineAgent runs an .esfsm graph — states with enter/update/exit hooks and guarded transitions.

Behavior trees

A BehaviorTreeAgent runs an .esbt graph — selectors, sequences, decorators, and leaves.

The unifying idea: state machines and behavior trees are two authoring paradigms over the same building blocks. You register named actions and conditions once, in code, then wire them into either an FSM or a BT — graphs authored visually in the editor, not hardcoded. Perception feeds the decision; the decision drives navigation.

An action does something (chase, attack, play a sound); a condition answers a yes/no question (do I see the player?). You register each under a name, once, and both the FSM and the BT resolve those names against the same registry — so one set of leaves serves both paradigms.

import { registerAction, registerCondition, Status, Perception, setNavDestination } from 'esengine';
registerCondition('seesPlayer', (ctx) =>
ctx.has(Perception) && ctx.get(Perception).visible);
registerAction('chase', (ctx) => {
const per = ctx.get(Perception);
setNavDestination(ctx.world, ctx.entity, { x: per.targetX, y: per.targetY });
});

Every action and condition receives an AiContext for the agent it runs on. It is the same programming model as a defineBehavior update — entity, world, and commands access — plus a per-agent blackboard:

Member Type Description
ctx.entity Entity The agent this action/condition runs for.
ctx.dt number Frame delta in seconds.
ctx.world World Full world access for cross-entity reads.
ctx.commands Commands Deferred spawn/despawn, safe mid-tick.
ctx.blackboard Blackboard This agent’s AI data plane (see Blackboard).
ctx.get(Comp) ComponentData Read another component on this entity.
ctx.set(Comp, data) void Write a component on this entity.
ctx.has(Comp) boolean Whether this entity has Comp.

The system that ticks your state machines reaches for whatever its leaves reach for, which is knowable only once the graphs are loaded. Declare it on the registration and the schedule can tell that system apart from one that might touch anything:

registerAction('chase', {
run: (ctx) => { /* ... */ },
touches: { reads: ['Perception'], writes: ['NavAgent'] },
});
registerCondition('seesPlayer', {
check: (ctx) => ctx.has(Perception) && ctx.get(Perception).visible,
touches: { reads: ['Perception'] },
});

Optional, and worth knowing what leaving it out costs: one undeclared leaf makes the whole system opaque, because a union that quietly dropped it would be a claim the scheduler trusts and the frame disproves. See What a system touches.

A condition always returns a boolean. An action may return a Status or nothing:

export enum Status { Success = 'success', Failure = 'failure', Running = 'running' }
  • In a behavior tree, a leaf returns Status to run across frames (Running), succeed, or fail. Returning nothing counts as Success.
  • In a state machine, the return value is ignored — FSM actions are one-shot side effects.

Writing an action that returns Status lets the same action serve as a BT leaf and an FSM hook.

The engine pre-registers a few names, so common glue needs no code at all — they appear in the editor palettes next to your own. All of them operate on components of the agent entity (the same channel your code and the editor use):

Name Kind Effect
timeline.play action Raise the agent’s TimelinePlayer play flag. On a finished clip this replays it from the top.
timeline.pause action Lower the play flag (resume with timeline.play).
timeline.finished condition True once the clip has completed (a once clip that ran to its end) and is not playing.
spriteAnim.play action Play the agent’s sprite flipbook; the argument switches to that clip.
spriteAnim.restart action Rewind the flipbook to frame 0 and play (argument switches clip).
spriteAnim.stop action Pause the flipbook.
spriteAnim.finished condition True once a one-shot sprite clip has finished.

Actions can carry an optional string argument — set it next to the action name in the FSM state inspector (or on a BT action node). Built-ins use it for data the component can’t carry per-state, like which clip spriteAnim.play should switch to; your own registered actions receive it as their third parameter: (ctx, blackboard, arg) => ….

Together they make a code-free cutscene state: entering the state starts the clip, and its completion drives the transition out.

registerFsm('intro', {
initial: 'Cutscene',
states: [
{
name: 'Cutscene',
onEnter: 'timeline.play',
transitions: [{ to: 'Gameplay', condition: 'timeline.finished' }],
},
{ name: 'Gameplay' },
],
});

The agent entity carries the TimelinePlayer (which clip, speed, wrap mode) — the FSM only flips its flags. Built-in names never shadow yours: if you register an action or condition under the same name, your registration wins. Avoid the timeline. prefix for game names to keep the namespaces apart.

In the FSM / behavior-tree editors, the action and condition fields suggest every registered name as you type — grouped by namespace, with built-ins carrying a short description of what they do. Free text stays legal for names your game registers at runtime.

The cutscene example is this pattern end-to-end: an intro timeline plays on scene start, gameplay unlocks when it completes, and R replays it — with zero registered actions.

The enemy-ai example hunts a player with two enemies that share the same senses and leaves — one driven by a state machine, the other by a behavior tree:

import {
defineSystem, Res, Nav, NavGrid,
registerAction, registerCondition, setNavDestination, Perception,
} from 'esengine';
// Shared leaves — the same names serve BOTH the .esfsm and the .esbt.
registerCondition('seesPlayer', (ctx) => ctx.has(Perception) && ctx.get(Perception).visible);
registerCondition('lostPlayer', (ctx) => !ctx.has(Perception) || !ctx.get(Perception).visible);
registerAction('chase', (ctx) => {
if (!ctx.has(Perception)) return;
const per = ctx.get(Perception);
if (per.visible) setNavDestination(ctx.world, ctx.entity, { x: per.targetX, y: per.targetY });
});
registerAction('patrol', () => { /* hold position until the player is seen */ });
// One open nav grid over the arena.
export const setupNavGrid = defineSystem([Res(Nav)], (nav) => {
nav.setGrid(new NavGrid({ width: 60, height: 44, cellSize: 20, origin: { x: -600, y: -440 } }));
}, { name: 'SetupNavGrid' });

The two enemies’ StateMachineAgent.fsm and BehaviorTreeAgent.bt point at enemy.esfsm / enemy.esbt — authored in the editor, loaded by the engine. Perception, the FSM/BT ticks, and nav following are all built-in, so the only game code is the shared leaves above plus the one-time grid.

  • Scripting — behaviors are declarative systems; leaves are plain functions.
  • Animation — drive sprite clips from a state machine.
  • Scenes.esfsm / .esbt assets load and serialize with the scene.
  • Event Binding — a click or a trigger can fsm.fire into a state machine; actions and conditions come from this same registry.