Behavior Tree

Behavior Tree module for building AI decision trees. Provides base node types (leaf, decorator, composite) that can be combined to create complex AI behaviors. Nodes return BehResult (Success, Fail, Running) and support lifecycle hooks (firstTick, tick, lastTick), reactions, and debugging.

To use this module:

require engine.ai.behtree

Creating custom leaf nodes

Subclass BehNode and override tick to implement game-specific conditions or actions:

class HasEnemy : BehNode {
    def override tick(nodeId : NodeId) : BehResult {
        var agent = nodeId?.AIAgent
        return BehResult(agent.enemy.valid())
    }
}

class ShootAtEnemy : BehNode {
    def override tick(nodeId : NodeId) : BehResult {
        var agent = nodeId?.AIAgent
        agent.fire_at(agent.enemy.worldPosition)
        return BehResult.Success
    }
}

Use firstTick and lastTick for setup and cleanup:

class WaitForTimer : BehNode {
    time : float = 1.0
    private timer : Timer

    def override firstTick(nodeId : NodeId) : void {
        timer = Timer(time = time, started = true)
    }

    def override tick(nodeId : NodeId) : BehResult {
        return timer.advance(get_delta_time()) || timer.is_done() ? BehResult.Success : BehResult.Running
    }
}

Building a behavior tree

Combine nodes into a tree using composites and decorators. Always call init() on the root after construction:

def create_enemy_ai() : BehNode? {
    var root = new SelectorBehNode(
        name = "root",
        children = array<BehNode?>(
            new SequenceBehNode(name = "attack", children = array<BehNode?>(
                new HasEnemy(name = "has enemy"),
                new ShootAtEnemy(name = "shoot")
            )),
            new SequenceBehNode(name = "wander", children = array<BehNode?>(
                new ChanceBehNode(name = "pause chance", chance = 0.5,
                    child = new WaitBehNode(name = "pause", waitDelay = float2(0.5, 1.5))),
                new ChooseRandomDirection(name = "pick direction"),
                new WalkForward(name = "walk")
            ))
        )
    )
    root.init()
    return root
}
  • SelectorBehNode tries children in order and succeeds on the first success (like boolean OR)

  • SequenceBehNode runs children in order and fails on the first failure (like boolean AND)

  • ParallelBehNode runs all children simultaneously

  • ChanceBehNode runs its child with a given probability

  • NotBehNode inverts Success/Fail

Ticking the tree from a component

Call update each frame, passing the entity’s nodeId:

class Enemy : Component {
    ai : BehNode?
    debugOutput : bool
    @collapse behTreeDebug : string

    def override on_update() {
        if (ai != null) {
            ai.update(nodeId)
            if (debugOutput) {
                behTreeDebug = dump_tree(ai)
            }
        }
    }
}

Using reactions

Reactions are special subtrees checked periodically via react(). When a reaction succeeds, the tree can be reset to re-evaluate priorities:

var root = new SelectorBehNode(
    name = "root",
    reaction = new SelectorBehNode(name = "reactions", children = array<BehNode?>(
        new SequenceBehNode(name = "enemy spotted", children = array<BehNode?>(
            new DetectEnemy(name = "detect"),
            new ResetTree(name = "reset")
        ))
    )),
    children = array<BehNode?>(...)
)

In the update loop, check reactions periodically:

var result = react(root, nodeId)
if (result.result == BehResult.Success) {
    print("reaction fired: {result.node.fullNodeName()}")
}

Note

Reactions must not return BehResult.Running. If they do, the reaction is forcibly reset and an error is logged.

Parallel execution

Use ParallelBehNode to run multiple behaviors simultaneously. It returns Fail if any child fails, Success if any succeeds, and Running if all are still running:

new ParallelBehNode(name = "shoot at range", children = array<BehNode?>(
    new StrafeAroundEnemy(name = "strafe"),
    new ShootAtEnemy(name = "shoot")
))

Debugging

Use dump_tree to visualize the tree state and debugState for the active node:

print(dump_tree(root, true))   // include reactions
print(root.debugState())       // e.g. "[sel] root/attack/shoot: <Success>"

The active node is marked with > in the dump output. Each node shows its debugSymbol and lastResult.

Resetting the tree

Call reset on the root to force all nodes back to initial state. This is typically done from a custom node inside a reaction:

class ResetTree : BehNode {
    def override tick(nodeId : NodeId) : BehResult {
        var agent = nodeId?.AIAgent
        agent.ai.reset(nodeId)
        return BehResult.Success
    }
}

You can also reset from external code, for example on collision:

nodeId.on_collision(@(collision) : void {
    enemy.ai.reset(nodeId)
})

Enumerations

BehResult
Values:
  • Fail = 0 - the node has failed

  • Success = 1 - the node has succeeded

  • Running = 2 - the node is still running and needs more ticks

Classes

BehNode
Fields:
  • parent : BehNode? - parent of the node

  • name : string - name of the node

  • debugSymbol : string - symbol displayed in debug output, e.g. “[seq]”, “[sel]”

  • lastResult : BehResult - last result of the node, useful for debugging

  • reaction : BehNode? - optional reaction subtree, evaluated bottom-up via react()

BehNode.init()

Initialize the node tree, setting up parent references and recursing into children. Must be called on the root node after constructing the tree

BehNode.firstTick(nodeId: NodeId)

Called before the first tick of the node. Override to initialize any state needed for the behavior

Arguments:
BehNode.lastTick(nodeId: NodeId)

Called after the last tick of the node or when the node is reset. Override to clean up any state that is no longer needed

Arguments:
BehNode.update(nodeId: NodeId): BehResult

Main entry point for ticking the node each frame. Calls firstTick on entry, tick every frame, and lastTick on completion

Arguments:
BehNode.reset(nodeId: NodeId)

Reset the node to its initial state. Override to add custom reset logic

Arguments:
BehNode.fullNodeName(): string

Return the full hierarchical path of this node. E.g. “root/attack/search target”

BehNode.activeChild(): BehNode?

Return the deepest active child of the node. Returns itself for leaf nodes

BehNode.debugState(): string

Return a formatted debug string with the active node path and last result

BehNode.iter(depth: int; cb: block<(var node:BehNode?;depth:int):void>)

Iterate the tree depth-first, calling the callback for each node

Arguments:
  • depth : int

  • cb : block<(node: BehNode?;depth:int):void>

BehNode(): BehNode

Default constructor

DecoratorBehNode : BehNode

Abstract base class for decorator nodes that wrap a single child

Fields:
  • child : BehNode? - the single child node wrapped by this decorator

DecoratorBehNode.init()

Set up the child’s parent reference and initialize it

DecoratorBehNode.firstTick(nodeId: NodeId)

Propagate firstTick to the child, then call the base implementation

Arguments:
DecoratorBehNode.lastTick(nodeId: NodeId)

Propagate lastTick to the child, then call the base implementation

Arguments:
DecoratorBehNode.reset(nodeId: NodeId)

Reset the child, then reset self

Arguments:
DecoratorBehNode.activeChild(): BehNode?

Delegate to the child’s active child

DecoratorBehNode.iter(depth: int; cb: block<(var node:BehNode?;depth:int):void>)

Iterate self and then the child subtree

Arguments:
  • depth : int

  • cb : block<(node: BehNode?;depth:int):void>

DecoratorBehNode(child: BehNode?): DecoratorBehNode

Construct with a child node

Arguments:
NotBehNode : DecoratorBehNode

Inverts the child’s result: Success becomes Fail and vice versa.

Fields:
  • debugSymbol : string = “{!}” - Running is passed through unchanged

NotBehNode.tick(nodeId: NodeId): BehResult

Tick the child and invert Success/Fail

Arguments:
TrueBehNode : BehNode
Fields:
  • debugSymbol : string = “[Yes]” - Leaf node that always returns Success

TrueBehNode.tick(nodeId: NodeId): BehResult

Always return Success

Arguments:
FalseBehNode : BehNode
Fields:
  • debugSymbol : string = “[No]” - Leaf node that always returns Fail

FalseBehNode.tick(nodeId: NodeId): BehResult

Always return Fail

Arguments:
SuccessBehNode : DecoratorBehNode

Forces Success regardless of the child’s result.

Fields:
  • debugSymbol : string = “{Success}” - Running is passed through unchanged

SuccessBehNode.tick(nodeId: NodeId): BehResult

Tick the child and return Success unless it is Running

Arguments:
FailureBehNode : DecoratorBehNode

Forces Fail regardless of the child’s result.

Fields:
  • debugSymbol : string = “{Failure}” - Running is passed through unchanged

FailureBehNode.tick(nodeId: NodeId): BehResult

Tick the child and return Fail unless it is Running

Arguments:
RunningBehNode : DecoratorBehNode

Always returns Running regardless of the child’s result.

Fields:
  • debugSymbol : string = “{Running}” - Use exitOnFailure or exitOnSuccess to allow specific results through

  • exitOnFailure : bool = false - when true, passes through Fail instead of returning Running

  • exitOnSuccess : bool = false - when true, passes through Success instead of returning Running

RunningBehNode.tick(nodeId: NodeId): BehResult

Tick the child and return Running, unless an exit condition is met

Arguments:
WaitBehNode : BehNode

Leaf node that returns Running for a random duration, then succeeds. The delay is randomly chosen between waitDelay.x and waitDelay.y on each entry

Fields:
  • debugSymbol : string = “[Wait]” - min and max wait duration in seconds (x = min, y = max)

  • waitDelay : float2 = float2(0.5f,1f) - timestamp when the wait expires

WaitBehNode.firstTick(nodeId: NodeId)

Pick a random delay and record the expiry time

Arguments:
WaitBehNode.tick(nodeId: NodeId): BehResult

Return Running until the delay has elapsed, then return Success

Arguments:
CompositeBehNode : BehNode

Abstract base class for composite nodes with multiple children

Fields:
  • children : array< BehNode?> - list of child nodes

  • currentChild : int = 0 - index of the currently executing child

CompositeBehNode.init()

Set up parent references for all children and initialize them

CompositeBehNode.firstTick(nodeId: NodeId)

Reset currentChild to 0 so execution starts from the first child

Arguments:
CompositeBehNode.reset(nodeId: NodeId)

Reset all children and the currentChild index

Arguments:
CompositeBehNode.activeChild(): BehNode?

Return the active child of the currently executing child node

CompositeBehNode.iter(depth: int; cb: block<(var node:BehNode?;depth:int):void>)

Iterate self and then each child subtree at depth + 1

Arguments:
  • depth : int

  • cb : block<(node: BehNode?;depth:int):void>

SequenceBehNode : CompositeBehNode

Runs children in order until one fails or returns Running.

Fields:
  • debugSymbol : string = “[seq]” - Succeeds only if all children succeed. Equivalent to boolean AND

SequenceBehNode.tick(nodeId: NodeId): BehResult

Advance through children; stop on Fail or Running, succeed if all pass

Arguments:
SelectorBehNode : CompositeBehNode

Runs children in order until one succeeds or returns Running.

Fields:
  • debugSymbol : string = “[sel]” - Fails only if all children fail. Equivalent to boolean OR

SelectorBehNode.tick(nodeId: NodeId): BehResult

Advance through children; stop on Success or Running, fail if all fail

Arguments:
ParallelBehNode : CompositeBehNode

Runs all children every tick.

Fields:
  • debugSymbol : string = “[parallel]” - Returns Fail if any child fails, Success if any succeeds, Running if all are still running

ParallelBehNode.tick(nodeId: NodeId): BehResult

Tick all children and aggregate results

Arguments:
ChanceBehNode : DecoratorBehNode

Runs the child with a given probability; otherwise returns Fail. The roll is performed once on entry (in firstTick) and reused until the node completes

Fields:
  • debugSymbol : string = “{Chance}” - chance to run the child, between 0 and 1

  • chance : float = 0.5f - result of the chance roll for the current tick cycle

ChanceBehNode.firstTick(nodeId: NodeId)

Roll the dice once on entry

Arguments:
ChanceBehNode.tick(nodeId: NodeId): BehResult

Tick the child if the roll succeeded, otherwise return Fail

Arguments:

Functions

BehResult(val: bool): BehResult

Explicit conversion from bool: true becomes Success, false becomes Fail

Arguments:
  • val : bool

dump_tree(node: BehNode?; include_reactions: bool = false): string

Return a formatted string representation of the behavior tree. The active node is marked with “>”; each node shows its debugSymbol and lastResult

Arguments:
  • node : BehNode?

  • include_reactions : bool

react(node: BehNode?; nodeId: NodeId): tuple<node:BehNode?;result:BehResult>

Evaluate reactions bottom-up from the active child to the root. Returns the first reaction that succeeds, or Fail if none trigger

Arguments: