Behavior Trees: AI for NPCs
Build a behavior tree engine from first principles — the same decision-making pattern that drives NPCs in Halo, The Last of Us and countless indie games. You'll implement composite nodes, decorators, a shared blackboard, and wire it all up into a guard NPC that patrols a level, spots and chases an intruder, attacks in range, and retreats when low on health.
- JavaScript classes, closures and arrays
- Basic game-loop concept (an update function called every frame)
- Familiarity with finite state machines is helpful but not required
Why Not a State Machine?
A finite state machine (FSM) works fine for a handful of states —
Idle, Patrol, Chase,
Attack — with explicit transitions between each pair.
The problem is that transitions multiply combinatorially: add a
Flee state and a CallForHelp state, and
you suddenly need to reason about a dozen new edges, several of
which should really share the same "am I in danger?" check.
A behavior tree (BT) replaces the tangled transition graph with a tree of small, reusable nodes evaluated top-to-bottom, left-to-right, every frame ("ticked"). Complex behavior emerges from composing simple nodes rather than hand-wiring transitions, and branches can be reused across many different NPCs just by re-parenting them.
Finite State Machine
- States + explicit transition edges
- Transitions grow O(n²) with states
- Hard to reuse sub-behaviors
- Great for small, well-understood AI
Behavior Tree
- Tree of composable nodes, no edges to hand-wire
- Priority is just left-to-right node order
- Sub-trees are trivially reusable
- Industry standard for complex NPC AI
The Node Contract — SUCCESS / FAILURE / RUNNING
Every node in the tree, whether a leaf action or a composite,
implements one method: tick(blackboard). It returns one
of three statuses. This tiny contract is the entire engine — every
other node type is built purely by combining calls to
tick() on its children.
const Status = Object.freeze({
SUCCESS: 'SUCCESS', // the node achieved its goal this tick
FAILURE: 'FAILURE', // the node could not achieve its goal
RUNNING: 'RUNNING', // still in progress, call tick() again next frame
});
class Node {
// Override in subclasses. Must return a Status value.
tick(blackboard) {
throw new Error('tick() not implemented');
}
}
RUNNING is what makes behavior trees suitable for
real-time games rather than just turn-based AI: a "move to
target" leaf can return RUNNING for many frames while
the NPC walks, and the tree simply re-ticks it each frame until it
reports SUCCESS (arrived) or FAILURE
(path blocked).
Composite Nodes — Sequence and Selector
Composites have children and decide which ones to tick, and how to combine their results. The two workhorses cover almost every decision you'll need:
-
Sequence— "AND" logic. Ticks children left to right; stops and returnsFAILUREthe moment one fails; returnsSUCCESSonly if all children succeed. Represents a plan — a list of steps that must all work. -
Selector— "OR" logic. Ticks children left to right; stops and returnsSUCCESSthe moment one succeeds; returnsFAILUREonly if all children fail. Represents a priority list of alternative strategies.
class Composite extends Node {
constructor(children = []) {
super();
this.children = children;
}
}
class Sequence extends Composite {
tick(bb) {
for (const child of this.children) {
const status = child.tick(bb);
if (status !== Status.SUCCESS) return status; // FAILURE or RUNNING
}
return Status.SUCCESS; // every child succeeded
}
}
class Selector extends Composite {
tick(bb) {
for (const child of this.children) {
const status = child.tick(bb);
if (status !== Status.FAILURE) return status; // SUCCESS or RUNNING
}
return Status.FAILURE; // every child failed
}
}
Reading a tree becomes reading English: a Selector of
[Attack, Chase, Patrol] means "attack if you can,
otherwise chase if you can, otherwise patrol." Each of those is
itself a Sequence of conditions and actions, e.g.
Attack = Sequence[InRange?, SwingWeapon].
Decorators — Inverter, Repeater, Cooldown
A decorator wraps exactly one child and modifies its result or how often it runs. They're where a lot of the reusable "glue" logic lives:
class Decorator extends Node {
constructor(child) {
super();
this.child = child;
}
}
// Flips SUCCESS <-> FAILURE. Useful for "is NOT visible" checks.
class Inverter extends Decorator {
tick(bb) {
const status = this.child.tick(bb);
if (status === Status.SUCCESS) return Status.FAILURE;
if (status === Status.FAILURE) return Status.SUCCESS;
return status; // RUNNING passes through unchanged
}
}
// Prevents the child from re-running until `seconds` have elapsed
// since it last returned something other than RUNNING.
class Cooldown extends Decorator {
constructor(child, seconds) {
super(child);
this.seconds = seconds;
this.readyAt = 0;
}
tick(bb) {
if (bb.time < this.readyAt) return Status.FAILURE;
const status = this.child.tick(bb);
if (status !== Status.RUNNING) this.readyAt = bb.time + this.seconds;
return status;
}
}
// Keeps ticking the child until it fails `n` times, or forever if n is
// omitted. Handy for idle "look around" loops.
class Repeater extends Decorator {
constructor(child, times = Infinity) {
super(child);
this.times = times;
this.count = 0;
}
tick(bb) {
if (this.count >= this.times) return Status.SUCCESS;
const status = this.child.tick(bb);
if (status === Status.FAILURE) { this.count++; return Status.RUNNING; }
return Status.RUNNING;
}
}
The Blackboard — Shared Memory Between Nodes
Nodes shouldn't hold direct references to game objects; instead
they read and write a shared key-value store — the
blackboard — passed into every tick()
call. This keeps nodes stateless and trivially reusable across
different NPCs, and gives you a single object to inspect for
debugging.
class Blackboard {
constructor(agent) {
this.agent = agent; // the NPC this tree controls
this.time = 0; // updated once per frame by the game loop
this.playerPos = null; // last known player position, or null
this.lastSeenAt = -Infinity;
this.patrolIndex = 0;
this.homePos = agent.position.clone();
}
}
Leaf Actions and Conditions for a Guard NPC
Leaves do the actual work — they're the only nodes that touch game
state directly. Conditions are leaves that never return
RUNNING; actions typically do, while they animate or
move the NPC over several frames.
class Condition extends Node {
constructor(predicate) { super(); this.predicate = predicate; }
tick(bb) { return this.predicate(bb) ? Status.SUCCESS : Status.FAILURE; }
}
class Action extends Node {
constructor(fn) { super(); this.fn = fn; }
tick(bb) { return this.fn(bb); }
}
const SEE_RANGE = 12;
const ATTACK_RANGE = 1.6;
const LOW_HEALTH = 0.25;
const canSeePlayer = new Condition((bb) => {
const dist = bb.agent.position.distanceTo(bb.world.player.position);
if (dist > SEE_RANGE) return false;
const blocked = bb.world.raycastBlocked(bb.agent.position, bb.world.player.position);
if (!blocked) { bb.playerPos = bb.world.player.position.clone(); bb.lastSeenAt = bb.time; }
return !blocked;
});
const inAttackRange = new Condition((bb) =>
bb.playerPos && bb.agent.position.distanceTo(bb.playerPos) <= ATTACK_RANGE
);
const isLowHealth = new Condition((bb) => bb.agent.health / bb.agent.maxHealth < LOW_HEALTH);
const attack = new Action((bb) => {
bb.agent.swingWeapon();
return bb.agent.attackFinished ? Status.SUCCESS : Status.RUNNING;
});
const chaseToPlayer = new Action((bb) => {
if (!bb.playerPos) return Status.FAILURE;
const reached = bb.agent.moveToward(bb.playerPos, bb.agent.runSpeed);
return reached ? Status.SUCCESS : Status.RUNNING;
});
const fleeToHome = new Action((bb) => {
const reached = bb.agent.moveToward(bb.homePos, bb.agent.runSpeed * 1.2);
return reached ? Status.SUCCESS : Status.RUNNING;
});
const patrolNextWaypoint = new Action((bb) => {
const target = bb.world.patrolRoute[bb.patrolIndex];
const reached = bb.agent.moveToward(target, bb.agent.walkSpeed);
if (reached) bb.patrolIndex = (bb.patrolIndex + 1) % bb.world.patrolRoute.length;
return Status.RUNNING; // patrol never "finishes" — always RUNNING
});
Assembling the Full Guard Tree
Wire the leaves into composites, top priority first. The
Selector root tries flee, then attack, then chase, and
falls back to patrol if nothing else applies:
const guardTree = new Selector([
// 1. Highest priority: run home and heal if badly hurt
new Sequence([ isLowHealth, fleeToHome ]),
// 2. If the player is close enough, attack
new Sequence([ canSeePlayer, inAttackRange, new Cooldown(attack, 0.8) ]),
// 3. If the player was spotted but is out of range, chase
new Sequence([ canSeePlayer, chaseToPlayer ]),
// 4. Remember the player for 4s after losing sight, keep chasing last known spot
new Sequence([
new Condition((bb) => bb.time - bb.lastSeenAt < 4),
chaseToPlayer,
]),
// 5. Default: walk the patrol route forever
new Repeater(patrolNextWaypoint),
]);
Because the root is a Selector, priority ordering
is the AI design: swap two lines and the guard's
personality changes completely — put chase above
flee and you get a reckless guard who fights to the
death instead of retreating.
Ticking the Tree in the Game Loop
One tick per frame per NPC is all the integration takes. Ticking is
cheap — it's just a handful of function calls down the active
branch — so hundreds of NPCs can share the same
guardTree definition, each with its own
Blackboard instance:
const guards = world.npcs.map((agent) => ({
agent,
bb: new Blackboard(agent),
}));
function update(dt) {
world.time += dt;
for (const { agent, bb } of guards) {
bb.time = world.time;
bb.world = world;
guardTree.tick(bb); // one call drives the whole decision this frame
}
}
Note that stateful decorators like Cooldown and
Repeater store their timers on this, not
on the blackboard — so a shared tree instance across multiple
NPCs would leak state between them. In production, either build a
fresh tree per agent (cheap, since trees are just plain objects)
or move all per-agent state onto the blackboard and make nodes
fully stateless. The example above assumes one tree per NPC.
Debugging — Visualising Which Branch Is Active
The single biggest productivity win when building behavior trees is
a debug overlay that shows the last status of every node. Wrap
tick() once to record it, then walk the tree to render
it as a colour-coded outline:
function instrument(node) {
const original = node.tick.bind(node);
node.tick = (bb) => {
const status = original(bb);
node.lastStatus = status; // green=SUCCESS, red=FAILURE, yellow=RUNNING
return status;
};
if (node.children) node.children.forEach(instrument);
if (node.child) instrument(node.child);
return node;
}
instrument(guardTree);
// In your debug HUD:
function drawNode(node, depth = 0) {
const color = { SUCCESS: '#22c55e', FAILURE: '#ef4444', RUNNING: '#facc15' }[node.lastStatus] || '#555';
console.log(' '.repeat(depth) + node.constructor.name, color);
(node.children || (node.child ? [node.child] : [])).forEach((c) => drawNode(c, depth + 1));
}
Watching the active branch light up yellow in real time makes it
immediately obvious why an NPC "won't attack" — usually a stale
lastSeenAt or a raycast blocked by its own collider.
When to Reach for Utility AI or HTNs Instead
Behavior trees excel when priorities are mostly fixed and hand-authored — exactly the guard above. They start to strain when you need many competing goals scored against continuous variables (hunger, ammo, threat level, squad cohesion) that trade off against each other; that's the sweet spot for utility AI, which scores every possible action with a function and picks the highest score each tick instead of a fixed priority order.
For NPCs that need to plan multi-step tactics (flank, then throw a grenade, then breach), a Hierarchical Task Network (HTN) planner — which searches over sequences of tasks to satisfy a goal — is a better fit than either. Many shipped games combine all three: a BT for reactive combat, utility scoring to pick which BT branch or target to prioritise, and squad-level HTN planning above both.
Rule of thumb: start with a behavior tree. It's the cheapest to build, easiest to debug, and covers the vast majority of NPC archetypes — guards, animals, turrets, companions. Only reach for utility AI or HTNs once you've hit a concrete wall a BT can't express cleanly.