-
Notifications
You must be signed in to change notification settings - Fork 0
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).
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);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.
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)
);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.
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);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.
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.
-
GOAP Planning, how
ACTIVE_GOAL_TYPEgets set in the first place. - Built-in Actions, the actions you'll actually wrap in these nodes.