Perception
A Perceiver senses nearby PerceptionTargets within a sight cone and writes
what it sees into a Perception component.
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' }Status to run across frames (Running),
succeed, or fail. Returning nothing counts as Success.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.
.esfsm / .esbt assets load and serialize with the scene.fsm.fire into a state
machine; actions and conditions come from this same registry.