Tutorial · GameDev
⏱️ ~50 minutes 🎓 Intermediate 🛠️ JavaScript · Game AI · Design Patterns

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.

Prerequisites

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:

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.

Continue Learning

🛠

Experiment in Playground

Write your own composite or decorator node and test it directly in the browser.

Open Playground →