Skip to content

Behavior Trees

AzureDoom edited this page Aug 29, 2026 · 1 revision

Behavior Trees

A behavior tree is built out of BehaviorNode<E, G> implementations, evaluated every tick by CortexRuntime. Nodes are composable, leaves wrap an Action, interior nodes (composites/decorators) delegate to children and combine their results.

public interface BehaviorNode<E, G> {
    BehaviorResult<E, G> tick(E agent, Blackboard blackboard, CooldownTracker cooldowns);
}

A BehaviorResult<E, G> carries: the selected Action (or null), its priority, whether the node produced a valid result, and an optional interrupt-category override. Use BehaviorResult.none() for "nothing selected" and BehaviorResult.run(action, priority) for a normal candidate; BehaviorResult.runEmergency(action, priority) forces EMERGENCY handling for an action instance that's ordinarily non-emergency (e.g. the tree detected critical health but the action itself is shared with non-emergency branches).

Leaf: ActionNode

The simplest node: offer a single action at a fixed priority, gated by an optional precondition.

// Always offered
new ActionNode<>(idleAction, 0);

// Gated by blackboard/cooldown-aware precondition
new ActionNode<>(wanderAction, 5, (agent, blackboard, cooldowns) ->
    blackboard.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE) == MyGoal.WANDER);

// Gated by a simple agent-only predicate
ActionNode.when(agent -> agent.isBaby(), fleeAction, 20);

Composite: PrioritySelector

Evaluates every child every tick and returns the single highest-priority result among the ones that succeeded. Order only breaks ties (first-listed wins). This is what lets an EMERGENCY branch declared anywhere in the list win regardless of its position, every branch is always consulted.

PrioritySelector.of(
    new ActionNode<>(idle, 0),
    new Condition<>(isWandering, new ActionNode<>(wander, 5)),
    new Condition<>(isInvestigating, new ActionNode<>(investigate, 8)),
    huntTargetNode   // a custom BehaviorNode, see below
);

This is the composite you'll use to arbitrate between an agent's top-level goal branches, see Full Example Walkthrough.

Composite: Sequence

Tries children in order and returns the first one that succeeds, no priority comparison across the whole list. Use this for a fixed fallback chain ("try the specific approach, then the general one") where you don't need every branch scored every tick.

Sequence.of(
    new ActionNode<>(specificApproach, 10),
    new ActionNode<>(generalFallback, 5)
);

Decorator: Condition

Gates a whole subtree behind a predicate; returns BehaviorResult.none() when the predicate fails.

new Condition<>(
    (agent, blackboard, cooldowns) -> blackboard.has(CommonBlackboardKeys.TARGET),
    huntSubtree
);

Use Condition when several nodes should share one gate; use ActionNode's own precondition parameter when the condition is local to a single leaf.

Decorator: CooldownGate

Gates a subtree behind a cooldown key instead of a predicate, keeps an expensive or noisy branch (a periodic scan, a rarely-eligible special attack) from being re-evaluated every single tick.

new CooldownGate<>("special_attack_scan", specialAttackNode);

Custom nodes: when the built-ins aren't enough

BehaviorNode is a @FunctionalInterface, nothing stops you from writing your own for logic a static tree shape can't express. The bundled HuntTargetNode example is the canonical case: deciding between closing the distance to a target and firing off an attack has to happen fresh every tick against the target's live distance, which isn't something PrioritySelector/Condition/ActionNode alone can express (it depends on data, not just a fixed precondition):

public final class HuntTargetNode<E extends Mob, G> implements BehaviorNode<E, G> {

    private final G huntGoalType;
    private final Action<E, G> chaseAction;
    private final List<AttackProfile<E, G>> attackProfiles;

    // ... constructor ...

    @Override
    public BehaviorResult<E, G> tick(E agent, Blackboard blackboard, CooldownTracker cooldowns) {
        var activeGoalType = blackboard.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE);
        if (!huntGoalType.equals(activeGoalType))
            return BehaviorResult.none();

        var target = blackboard.get(CommonBlackboardKeys.TARGET);
        if (target == null || !target.isAlive())
            return BehaviorResult.none();

        var attack = AttackSelector.select(agent, target, cooldowns, false, attackProfiles);
        if (attack != null) {
            return BehaviorResult.run(attack.action(), attack.action().priority());
        }
        return BehaviorResult.run(chaseAction, chaseAction.priority());
    }
}

Note the priority discipline here: the node passes each candidate's own Action.priority() straight through rather than inventing a separate number. This matters because CortexRuntime re-checks a running action's continued eligibility using that same priority() method, so a candidate's selection priority and its priority while running must be the same value, or preemption behavior will be inconsistent across ticks.

Priority numbers are yours to define

AzureCortex doesn't reserve or standardize priority values, they're just integers your tree compares. A common convention (seen in the bundled examples) is:

Priority Meaning
0 Idle fallback (always eligible, lowest priority)
5–10 Passive behaviors (wander, investigate)
15–20 Combat/reactive behaviors (melee, flee)
90+ Emergency-tier behaviors (eating to heal under GoalUrgency.EMERGENCY)

Keep the numbers consistent within one entity's tree; they don't need to mean anything across different entities.

Next

Clone this wiki locally