Behaviour Trees

A Finite State Machine with four states is manageable. One with ten states, where any state might transition to any other, is a maintenance problem. Behaviour Trees solve this by replacing the transition table with a tree of composable nodes — small, reusable pieces that combine into arbitrarily complex behaviour without the O(n²) transition explosion.

A Behaviour Tree is evaluated top-down every frame. Each node returns one of three values: SUCCESS, FAILURE, or RUNNING. Parent nodes use these return values to decide which child to run next. The result is that you describe AI behaviour as a hierarchy of priorities rather than a flat list of states.

What You’ll Master:

  • The SUCCESS / FAILURE / RUNNING protocol
  • The two composite nodes: Selector and Sequence
  • Condition and Action leaf nodes
  • Building complex guard behaviour from simple, reusable pieces
  • How BTs compare to FSMs and when to prefer each

Prerequisites:

  • Finite State Machines (the FSM article explains the problem BTs solve — start there if you haven’t)
Loading editor…

The label above the guard shows the active tree path in real time — which nodes succeeded to reach the current action leaf. Notice how the “Investigate” branch fires when the player leaves detection range: the guard walks to the last known position before resuming patrol. This would require an extra state and two more transitions in an FSM.

The Node Protocol

Every node in a Behaviour Tree returns one of three values:

  • SUCCESS — the node completed its goal
  • FAILURE — the node could not complete its goal
  • RUNNING — the node is still working (multi-frame operation)

This shared protocol is what makes nodes composable. A parent node doesn’t need to know what its children do — it only needs to know what they return.

const SUCCESS = 'SUCCESS';
const FAILURE = 'FAILURE';
const RUNNING = 'RUNNING';

The Two Composite Nodes

Selector (?) — tries children left to right, stops and returns SUCCESS as soon as one succeeds. Returns FAILURE only if all children fail. Think of it as “try option A, or option B, or option C”:

function selector(...children) {
    return (ctx) => {
        for (const child of children) {
            const result = child(ctx);
            if (result !== FAILURE) return result;
        }
        return FAILURE;
    };
}

Sequence () — runs children left to right, stops and returns FAILURE as soon as one fails. Returns SUCCESS only if all children succeed. Think of it as “do A, then B, then C — but only if each step works”:

function sequence(...children) {
    return (ctx) => {
        for (const child of children) {
            const result = child(ctx);
            if (result !== SUCCESS) return result;
        }
        return SUCCESS;
    };
}

The guard’s full decision structure, expressed in these two nodes:

Selector (?)
├── Sequence (→)          "if player is close, chase"
│   ├── PlayerClose       condition: returns SUCCESS or FAILURE
│   └── ChasePlayer       action: returns RUNNING
├── Sequence (→)          "if last known position exists, investigate"
│   ├── HasLastKnown      condition
│   └── Investigate       action: returns RUNNING or SUCCESS when arrived
└── Patrol                action: returns RUNNING (always fallback)

The Selector works down the list. If PlayerClose returns FAILURE, the first Sequence fails immediately and the Selector tries the second branch. If HasLastKnown also fails, it falls through to Patrol. This is the priority system — earlier branches win.

Leaf Nodes

Leaf nodes are the actual work — checking conditions and performing actions.

Condition — tests a predicate, returns SUCCESS or FAILURE, never RUNNING:

function condition(test) {
    return (ctx) => test(ctx) ? SUCCESS : FAILURE;
}

// Usage
const playerClose = condition(ctx =>
    distance(ctx.guard, ctx.player) < ctx.guard.detectRange
);

Action — performs work, returns SUCCESS, FAILURE, or RUNNING:

function action(fn) {
    return (ctx) => fn(ctx);
}

// A multi-frame action that runs until it arrives
const chasePlayer = action(ctx => {
    moveToward(ctx.guard, ctx.player.x, ctx.player.y, ctx.guard.speed * 1.8);
    return RUNNING; // never "done" — the Selector re-evaluates from the top each tick
});

// A single-frame action that immediately succeeds
const facePlayer = action(ctx => {
    ctx.guard.angle = Math.atan2(
        ctx.player.y - ctx.guard.y,
        ctx.player.x - ctx.guard.x
    );
    return SUCCESS;
});

Building the Tree

Trees are built by composing functions. The result is a single callable that represents the entire AI:

const guardAI = selector(
    // Threat response: check proximity, then chase
    sequence(
        condition(ctx => distance(ctx.guard, ctx.player) < ctx.guard.detectRange),
        action(ctx => {
            ctx.guard.lastKnown = { x: ctx.player.x, y: ctx.player.y };
            moveToward(ctx.guard, ctx.player.x, ctx.player.y, ctx.guard.chaseSpeed);
            return RUNNING;
        })
    ),

    // Investigate last known position if one exists
    sequence(
        condition(ctx => ctx.guard.lastKnown !== null),
        action(ctx => {
            const lk = ctx.guard.lastKnown;
            const arrived = moveToward(ctx.guard, lk.x, lk.y, ctx.guard.speed);
            if (arrived) ctx.guard.lastKnown = null;
            return arrived ? SUCCESS : RUNNING;
        })
    ),

    // Default: patrol
    action(ctx => {
        const pt = ctx.guard.patrolPoints[ctx.guard.patrolIndex];
        if (moveToward(ctx.guard, pt.x, pt.y, ctx.guard.speed)) {
            ctx.guard.patrolIndex = (ctx.guard.patrolIndex + 1) % ctx.guard.patrolPoints.length;
        }
        return RUNNING;
    })
);

// Each frame:
app.ticker.add(() => {
    guardAI({ guard, player });
});

To add a new behaviour — say, the guard calls for backup when health is low — you add one branch to the Selector. No existing transitions change.

The Context Object

All shared data flows through a ctx object passed to every node. This keeps nodes stateless and reusable:

const ctx = {
    guard,      // the entity being ticked
    player,     // the target
    map,        // for pathfinding
    dt,         // delta time for frame-rate independent movement
};

guardAI(ctx);

Different guards can share the same tree with different context objects, or you can swap the tree entirely for a different enemy type.

FSM vs Behaviour Tree

FSM Behaviour Tree
Complexity growth O(n²) transitions O(n) new branches
Priority between behaviours Encoded in transition conditions Structural — left branches win
Debugging Trace state history Trace active node path
Best for Simple entities with few states Complex entities with layered priorities

FSMs are easier to visualise as diagrams and well-suited to entities with clear, distinct modes (a door: open, closed, locked). BTs shine when behaviours have a natural priority ordering and you expect to extend the AI over time without revisiting everything that already works.

Next up: Influence Maps — giving your AI spatial awareness without pathfinding to every candidate tile.