AI

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:

StatusMeaning
Runningstill working — tick me again next frame
Succeededfinished, success
Failedfinished, failure
Initial / Suspended / Invalidlifecycle 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:

NodeLogic
SequenceBehaviorAND — run children in order; fail fast, succeed only if all succeed
SelectorBehaviorOR (fallback) — try children in order; succeed on the first success
ParallelBehaviorrun all children together; RequireOne or RequireAll success/failure policies

Decorators wrap a single child and transform its result:

NodeEffect
InvertStatusBehaviorswap Succeeded ↔ Failed
RepeatBehaviorloop the child N times (default forever)
RepeatUntilSuccessBehaviorretry the child until it succeeds (optional attempt cap)
IgnoreFailureBehaviorturn Failure into Success
OverrideContextBehaviorrun the child against a different context object

Leaves do the actual work:

NodePurpose
ActionBehaviorrun a synchronous function (dt, context) => …; succeeds (fails if it throws)
ConditionBehaviortest a predicate; Succeeded if true, Failed if false
PromiseBehaviorwrap a promise — Running until it settles, then Succeeded / Failed
DelayBehavior / RandomDelayBehaviorwait a fixed or random number of seconds
SucceedingBehavior / FailingBehaviorconstant results
BranchBehaviorif/else — run a condition, then one of two branches
ConditionalBehavior / FilterBehaviorgate an action (or a set of actions) behind condition(s)
LogMessageBehaviorprint 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();
Selector (fallback)Sequence: chaseSequence: patrolCondition: player_visibleAction: chasePlayerAction: moveToNextWaypointDelay: 2.0s

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.

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.