AI & simulation tools
Behavior trees, finite state machines, Monte-Carlo Tree Search, a resource-allocation planner, blackboards, and navmesh pathfinding — decision-making and planning primitives most JavaScript engines don't ship.
Most web engines stop at rendering and physics and leave agent logic to you. Meep ships the decision-making layer too: a full behavior-tree toolkit, finite state machines, a Monte-Carlo Tree Search planner, a resource-allocation solver, blackboards for shared state, a couple of optimizers, and navmesh pathfinding. These are the same primitives you’d reach for in a native engine, written in plain JavaScript and wired through the ECS.
Almost everything below lives under @woosh/meep-engine/src/engine/intelligence/,
with one self-contained subdirectory per subsystem and its own tests. Pick the
tool that fits the problem — reactive moment-to-moment AI wants a behavior tree or
a state machine; turn-based or adversarial planning wants MCTS; budgeting limited
resources across competing actions wants the allocation solver.
Behavior trees
A behavior tree is a hierarchy of nodes that runs top-down each tick. Every node’s
tick(dt) returns a status, and parent nodes route control based on what their
children return. The base type is Behavior; the status values are:
| Status | Meaning |
|---|---|
Running | still working — tick me again next frame |
Succeeded | finished, success |
Failed | finished, failure |
Initial / Suspended / Invalid | lifecycle states, set by the framework |
A tree has three lifecycle calls. initialize(context) runs once and recursively
seeds every node with a shared context object (commonly a blackboard).
tick(dt) advances the tree and returns a status. finalize() cleans up when the
tree completes or is interrupted. Trees are stateful — a sequence remembers which
child it’s on, a delay remembers how long it’s waited — so a tree resumed next
frame picks up exactly where it left off.
The node catalog
Composites run multiple children. Each has a .from([...]) factory:
| Node | Logic |
|---|---|
SequenceBehavior | AND — run children in order; fail fast, succeed only if all succeed |
SelectorBehavior | OR (fallback) — try children in order; succeed on the first success |
ParallelBehavior | run all children together; RequireOne or RequireAll success/failure policies |
Decorators wrap a single child and transform its result:
| Node | Effect |
|---|---|
InvertStatusBehavior | swap Succeeded ↔ Failed |
RepeatBehavior | loop the child N times (default forever) |
RepeatUntilSuccessBehavior | retry the child until it succeeds (optional attempt cap) |
IgnoreFailureBehavior | turn Failure into Success |
OverrideContextBehavior | run the child against a different context object |
Leaves do the actual work:
| Node | Purpose |
|---|---|
ActionBehavior | run a synchronous function (dt, context) => …; succeeds (fails if it throws) |
ConditionBehavior | test a predicate; Succeeded if true, Failed if false |
PromiseBehavior | wrap a promise — Running until it settles, then Succeeded / Failed |
DelayBehavior / RandomDelayBehavior | wait a fixed or random number of seconds |
SucceedingBehavior / FailingBehavior | constant results |
BranchBehavior | if/else — run a condition, then one of two branches |
ConditionalBehavior / FilterBehavior | gate an action (or a set of actions) behind condition(s) |
LogMessageBehavior | print a message (debugging) |
A worked example
import { SelectorBehavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/SelectorBehavior.js";
import { SequenceBehavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/composite/SequenceBehavior.js";
import { ActionBehavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/primitive/ActionBehavior.js";
import { ConditionBehavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/util/ConditionBehavior.js";
import { DelayBehavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/util/DelayBehavior.js";
import { BehaviorStatus } from "@woosh/meep-engine/src/engine/intelligence/behavior/BehaviorStatus.js";
const guard = { player_visible: false /* …shared state… */ };
// Chase if the player is visible; otherwise patrol with a pause between waypoints.
const tree = SelectorBehavior.from([
SequenceBehavior.from([
new ConditionBehavior(() => guard.player_visible),
ActionBehavior.from(() => chasePlayer(guard)),
]),
SequenceBehavior.from([
ActionBehavior.from(() => moveToNextWaypoint(guard)),
DelayBehavior.from(2.0),
]),
]);
tree.initialize(guard); // recursively seeds the context
// each frame:
const status = tree.tick(dt); // Running | Succeeded | Failed
if (status !== BehaviorStatus.Running) tree.finalize();
The selector tries the chase branch first; its condition fails while the player is
hidden, so control falls through to the patrol branch. The moment player_visible
flips true, the next tick takes the chase branch instead — that re-evaluation every
tick is what makes a behavior tree reactive rather than a fixed script.
Running trees on entities
BehaviorComponent attaches a tree to an entity, and BehaviorSystem ticks every
such component each frame:
import { BehaviorComponent } from "@woosh/meep-engine/src/engine/intelligence/behavior/ecs/BehaviorComponent.js";
import { BehaviorSystem } from "@woosh/meep-engine/src/engine/intelligence/behavior/ecs/BehaviorSystem.js";
await entityManager.addSystem(new BehaviorSystem(engine));
new Entity()
.add(BehaviorComponent.from(tree))
.add(transform)
.build(ecd);
BehaviorComponent.from(tree) wraps an existing tree; BehaviorComponent.loop(fn)
is shorthand for “run this function every tick, forever”. Components tick on the
simulation clock by default (ClockChannelType.Simulation), so tree timing is
deterministic and pause-aware; switch to System time for UI-driven logic.
A set of entity behaviors lets a tree act on the world directly:
SendEventBehavior and WaitForEventBehavior (fire and await entity events —
the basis for cross-agent coordination), DieBehavior and KillBehavior (destroy
self or another entity), and OrbitingBehavior / RotationBehavior (drive simple
motion). Because event behaviors can suspend a branch until a signal arrives,
multi-agent choreography — “wait for the door to open, then advance” — is just a
sequence.
Weighted choice, serialization, and debugging
WeightedRandomBehavior (with WeightedElement) picks one child at random by
weight and runs it to completion — variety without hand-authored randomness.
Every stateful node has a serialization adapter, and BehaviorComponent saves the
in-flight tree — mid-delay, mid-sequence, pending promises and all — so a save
made while an agent is halfway through an action restores to exactly that point.
behavior_to_dot() renders a tree to GraphViz DOT for inspection, and
syncExecuteBehaviorToCompletion() ticks a tree to completion in a loop for tests.
Blackboards
A Blackboard is a typed key-value store for the state a tree (or several agents)
shares. Values are observed — reading and writing goes through small wrappers
that emit change events, so other systems can react without polling:
import { Blackboard } from "@woosh/meep-engine/src/engine/intelligence/blackboard/Blackboard.js";
const bb = new Blackboard();
const health = bb.acquireNumber("health", 100); // observed number
bb.acquireBoolean("alerted", false);
health.set(80); // observable write
bb.on.added.add((name, value) => console.log(`+${name}`));
acquireNumber / acquireBoolean / acquireString create or fetch a typed slot;
contains, getKeys, and traverse introspect it. A Blackboard is the natural
context to hand a behavior tree — every node then reads and writes the same
shared state. BlackboardStack cascades several blackboards into a scope chain
(look-ups fall through from the top down, writes land in a designated slot), which
is how you give an agent both shared squad state and private per-agent state
without copying.
Finite state machines
When behavior is better described as discrete modes with explicit transitions between them — idle, chase, attack — a state machine is clearer than a tree. You declare the graph once, attach a transition function per state, then feed it input:
import { SimpleStateMachineDescription } from "@woosh/meep-engine/src/core/fsm/simple/SimpleStateMachineDescription.js";
import { SimpleStateMachine } from "@woosh/meep-engine/src/core/fsm/simple/SimpleStateMachine.js";
const d = new SimpleStateMachineDescription();
const IDLE = d.createState(), CHASE = d.createState(), ATTACK = d.createState();
d.createEdge(IDLE, CHASE);
d.createEdge(CHASE, ATTACK); d.createEdge(CHASE, IDLE);
d.createEdge(ATTACK, CHASE);
// each state maps the current input to the next state id
d.setAction(IDLE, (i) => i.sees_player ? CHASE : IDLE);
d.setAction(CHASE, (i) => i.in_range ? ATTACK : (i.sees_player ? CHASE : IDLE));
d.setAction(ATTACK, (i) => i.in_range ? ATTACK : CHASE);
const fsm = new SimpleStateMachine(d);
fsm.setState(IDLE);
fsm.addEventHandlerStateEntry(ATTACK, () => startAttackAnimation());
fsm.advance({ sees_player, in_range }); // each frame — fires entry/exit hooks on change
States and edges are plain integer ids; advance(input) runs the current state’s
action and transitions if it returns a different state, firing the state’s exit
hook and the new state’s entry hook. findPath and navigateTo route the machine
through intermediate states when you want to reach a target without enumerating
every transition. For ECS use, StateGraph wraps a machine and StateGraphSystem
drives it per entity.
Monte-Carlo Tree Search
For turn-based and adversarial decisions — board games, card games, tactical
planning — MonteCarloTreeSearch finds strong moves by simulating thousands of
random playouts and concentrating its search where the rewards are. It’s the
AlphaGo family of algorithm, and it’s domain-agnostic: you describe your game
through six callbacks and the search does the rest.
import { MonteCarloTreeSearch } from "@woosh/meep-engine/src/engine/intelligence/mcts/MonteCarlo.js";
import { MoveEdge } from "@woosh/meep-engine/src/engine/intelligence/mcts/MoveEdge.js";
import { StateType } from "@woosh/meep-engine/src/engine/intelligence/mcts/StateNode.js";
const search = new MonteCarloTreeSearch();
search.initialize({
rootState: position,
numPlayers: 2,
cloneState: (s) => s.clone(), // MUST return a fresh copy
computeActivePlayer: (s) => s.turn, // whose move it is (0-based)
computeTerminalFlag: (s) => s.isOver() ? StateType.Terminal : StateType.Undecided,
computeOutcome: (s) => s.payoffs(), // Float64Array, one entry per player
computeValidMoves: (s) => s.legalMoves().map(
(m) => MoveEdge.fromFunction((state) => m.applyTo(state)),
),
});
for (let i = 0; i < 5000; i++) search.playout(); // selection → expansion → rollout → backup
const best = search.root.pickBestMoves(); // best move(s) for the active player
Each playout() walks the tree by UCB1 (balancing exploitation of good moves
against exploration of untried ones), expands a new node, rolls out to a terminal
state, and backs the result up the path it took. Outcomes are per-player payoff
vectors, so the same engine handles zero-sum duels, n-player free-for-alls, and
cooperative or asymmetric games — you decide the shape of the reward. The only hard
rule is that cloneState returns a genuinely independent copy, since every playout
mutates its own branch. Playouts run on a seeded RNG, so a given search is
reproducible.
Resource-allocation planner
A recurring strategic problem: many possible actions compete for a limited pool of
resources — action points, ammo, mana, currency, time — and you want the
combination that maximises value without overspending. The resource/ solver
models this directly.
You describe each candidate action as a bid: the resources it needs, a
normalized value in [0, 1], and the action(s) to run if it wins. The solver takes
the available resources and the bids and greedily allocates by value × weight,
committing each bid all-or-nothing if its resources are still available.
import { Resource } from "@woosh/meep-engine/src/engine/intelligence/resource/Resource.js";
import { ResourceAllocation } from "@woosh/meep-engine/src/engine/intelligence/resource/ResourceAllocation.js";
import { ResourceAllocationBid } from "@woosh/meep-engine/src/engine/intelligence/resource/ResourceAllocationBid.js";
import { ResourceAllocationSolver } from "@woosh/meep-engine/src/engine/intelligence/resource/ResourceAllocationSolver.js";
const fireball = new ResourceAllocationBid(
new ResourceAllocation([new Resource(3, "ap"), new Resource(30, "mana")]),
0.9, // value
);
fireball.actions.add("CAST_FIREBALL");
const solver = new ResourceAllocationSolver();
solver.addResources([new Resource(10, "ap"), new Resource(50, "mana")]);
solver.addBids([fireball /* , … */]);
const winners = solver.solve(); // the bids that fit, highest value first
For larger AI, TacticalModules generate bids from the current situation
(collectBids(resources)), and a StrategicResourceAllocator runs every module,
pools their bids, solves, sorts the resulting actions by priority, and applies any
post-processing transformers — turning “what does each part of my AI want, and what
can I afford?” into a single ordered plan.
Optimization
optimization/ holds two general-purpose optimizers. RandomOptimizer is a hill
climber: give it a state, a cloneState, a scoreFunction, and either a list of
valid moves or a randomAction, then call step() (apply a random move, keep it
if the score improved) or stepThrough(n). OptimizationTask wraps it for the
engine’s async task system, stopping after a number of no-improvement steps or a
time limit. optimize_cyclic_sequence_pairwise does pairwise local search over a
cyclic sequence (its original use was scheduling sample jitter). They’re handy for
procedural placement, scheduling, and any “good enough, fast” combinatorial tuning.
Moving agents through the world
Once an agent has decided where to go, navigation gets it there. NavigationMesh
bakes a walkable mesh from scene geometry — parameterised by agent radius, height,
and maximum climb angle — and accelerates queries with a BVH:
const out = new Float32Array(256 * 3);
const count = navmesh.find_path(out, sx, sy, sz, gx, gy, gz); // packed XYZ triples
find_path snaps the endpoints to the mesh, runs A* over the face graph, then
refines the corridor to a short list of waypoints with the funnel (“string-pull”)
algorithm — returning the number of points written. To make an entity follow a
path, add a Path (the waypoints, linearly or Catmull-Rom interpolated) and a
PathFollower (speed, rotation, looping) component; PathFollowingSystem advances
the entity along it and fires an event when it arrives. For grid-based worlds,
find_path_on_grid_astar is a direct A* over a 2D field. (The navmesh also appears
under the engine’s navigation features — this section is the
AI-agent view of it.)
Determinism and persistence
Two properties run through all of these. Determinism: behavior trees tick on the simulation clock, FSM transitions are pure functions of input, and MCTS playouts use a seeded RNG — so the same inputs produce the same decisions across runs and across V8 builds, the foundation the engine’s determinism guarantees extend to AI for lockstep netcode and replays. Persistence: behavior trees and their in-flight state serialize through the same binary save system as everything else, so an agent mid-decision survives a save/load without losing its place.