Article
Game Development · AI Design Patterns · ⏱ ≈ 12 хв читання

Behavior Trees for NPC AI — Design, Tick, and Blackboard

Behavior trees (BTs) are now the dominant architecture for complex game AI — used in Halo, Unreal Engine, ROS robot stacks, and autonomous driving pipelines alike. Compared to finite state machines, BTs compose naturally: you can re-use sub-trees, add modular decorators, and reason about the tree's structure visually. Understanding the tick protocol, the four core node types, and Blackboard communication is enough to build sophisticated NPC behavior from first principles.

1. Why FSMs Break Down

A Finite State Machine models AI as a graph: nodes are states, edges are transitions. This works for simple characters but degrades fast:

FSM state complexity: T = O(S²) transitions for S states BT subtree complexity: T = O(S log S) with hierarchical re-use Key insight: BTs express priority explicitly via tree structure → leftmost child = highest priority in a Selector → parent Sequence blocks all children until one fails

2. The Four Core Node Types

Sequence (→)

Executes children left-to-right. Returns FAILURE immediately if any child fails. Returns SUCCESS only if all succeed. Like logical AND.

Selector (?)

Executes children left-to-right. Returns SUCCESS on the first success. Returns FAILURE only if all fail. Like logical OR with priority.

Leaf / Action

Implements actual behaviour: MoveTo, Attack, PlayAnim. Returns SUCCESS, FAILURE, or RUNNING (for multi-frame tasks).

Condition

A leaf that queries world state. IsEnemyVisible? HasAmmo? Returns SUCCESS or FAILURE synchronously, never RUNNING.

Sequence: tick until first FAILURE or all SUCCESS [Patrol → SeeEnemy? → Chase → Attack] If patrol succeeds but SeeEnemy fails → Sequence = FAILURE → parent Selector tries next option Selector: tick until first SUCCESS [AttackIfCanSee? → InvestigateNoise? → Patrol] Priority: attacking > investigating > patrolling If attack condition fails, tries investigate

3. Tick Protocol and Status Flow

Each game frame: root.tick(blackboard) → propagates depth-first, returns: SUCCESS | FAILURE | RUNNING RUNNING status: action is multi-frame (pathfinding, animation) Next tick re-enters the tree directly to the running node Memory variants: Stateless BT: re-ticks from root every frame → simpler, can interrupt actions immediately Stateful (with memory): remembers which child was running → resumes from that child; children not re-evaluated until done Stateless re-entry each frame is usually preferred because it lets higher-priority conditions interrupt ongoing actions (enemy spotted → abort patrol midway) Tick rate: 10–60 Hz for real-time games, 1–5 Hz for ROS robots

4. The Blackboard — Shared Memory

The Blackboard is a shared key-value store that decouples perception, reasoning, and action. Nodes read and write the Blackboard instead of calling each other directly.

Blackboard pattern: Perception: SightSensor writes bb.set("enemy_pos", {x,y}) Condition: IsEnemyVisible → reads bb.get("enemy_visible") Action: MoveTo → reads bb.get("move_target") Scope: global (entire NPC), subtree (temporary local context) Subtree scope lets the same SubTree node run in parallel on different targets by having separate Blackboard scopes Memory management: clear stale entries when actions complete or use TTL (time-to-live) expiration on sensor entries Blackboard observers: actions can listen for changes and return RUNNING until an event arrives → avoids polling every frame for low-frequency events

5. Decorators and Subtree Reuse

Decorators: single-child wrapper nodes that modify behaviour Inverter: returns the opposite of child's status (NOT gate) Repeater: re-runs child N times or until it fails (loops) Succeeder: always returns SUCCESS regardless of child ForceFailure: always returns FAILURE regardless of child Cooldown decorator: If last execution < cooldown period → return FAILURE immediately Otherwise: tick child and record timestamp Until Fail / Until Success: loop child until specified result SubTree: reference to an externally defined tree → import RetreatBehavior subtree into multiple characters → change the subtree definition once, all characters updated Example: throttled combat tree Selector ├── Cooldown(0.5s) → Sequence │ ├── IsEnemyInMeleeRange │ └── MeleeAttack └── Sequence ├── IsEnemyVisible └── ShootAtEnemy

6. BT vs FSM — Design Trade-offs

BT Advantages

Hierarchical, modular, visual debugging, natural priority, no transition spaghetti, easy subtree reuse, industry standard in AAA game AI.

FSM Advantages

Simpler for very few states, deterministic transitions, trivial to implement, no tick overhead, easy to formally verify.

BT Limitations

Re-tickling can be expensive if tree is huge. Stateless BTs can "flicker" between actions if conditions change every frame.

GOAP Alternative

Goal-Oriented Action Planning plans sequences at run-time using A*. More flexible than BT for complex goal hierarchies (used in F.E.A.R.).

7. JavaScript Behavior Tree Engine

// Minimal synchronous Behavior Tree engine
const Status = Object.freeze({ SUCCESS: 'S', FAILURE: 'F', RUNNING: 'R' });

class Sequence {
  constructor(...children) { this.children = children; }
  tick(bb) {
    for (const c of this.children) {
      const s = c.tick(bb);
      if (s !== Status.SUCCESS) return s;
    }
    return Status.SUCCESS;
  }
}

class Selector {
  constructor(...children) { this.children = children; }
  tick(bb) {
    for (const c of this.children) {
      const s = c.tick(bb);
      if (s !== Status.FAILURE) return s;
    }
    return Status.FAILURE;
  }
}

class Action {
  constructor(fn) { this.fn = fn; }
  tick(bb) { return this.fn(bb); }
}

class Condition {
  constructor(pred) { this.pred = pred; }
  tick(bb) { return this.pred(bb) ? Status.SUCCESS : Status.FAILURE; }
}

class Inverter {
  constructor(child) { this.child = child; }
  tick(bb) {
    const s = this.child.tick(bb);
    if (s === Status.SUCCESS) return Status.FAILURE;
    if (s === Status.FAILURE) return Status.SUCCESS;
    return Status.RUNNING;
  }
}

class Blackboard {
  constructor() { this.data = {}; }
  set(k, v) { this.data[k] = v; }
  get(k) { return this.data[k]; }
  has(k) { return k in this.data; }
}

// Example: Guard NPC tree
const tree = new Selector(
  new Sequence(
    new Condition(bb => bb.get('enemy_visible')),
    new Action(bb => { console.log('Chasing enemy'); return Status.RUNNING; })
  ),
  new Sequence(
    new Condition(bb => bb.get('noise_heard')),
    new Action(bb => { console.log('Investigating'); return Status.SUCCESS; })
  ),
  new Action(bb => { console.log('Patrolling'); return Status.RUNNING; })
);

const bb = new Blackboard();
bb.set('enemy_visible', false);
bb.set('noise_heard', true);
tree.tick(bb);  // → "Investigating"

8. Parallel Nodes, Event-Driven, and HFSM Hybrids