TreeBuilder is the recommended way to build BTEng trees in Python. It provides a
fluent, indentation-based API that mirrors the visual structure of the tree without
requiring you to manually construct NodeConfig objects or manage parent-child
relationships.
Every control node opens a scope. Close each scope with .end(). Leaf nodes (actions
and conditions) do not need .end().
from bteng import Blackboard, NodeStatus, TreeBuilder, TreeExecutor
bb = Blackboard.create("tutorial")
bb.set("battery_ok", True)
bb.set("path_clear", True)
tree = (
TreeBuilder(blackboard=bb)
.tree_id("Mission")
.sequence("mission")
.condition("BatteryOK", lambda: bb.get("battery_ok", False))
.fallback("move_or_stop")
.sequence("move")
.condition("PathClear", lambda: bb.get("path_clear", False))
.action("Navigate", lambda: NodeStatus.SUCCESS)
.end()
.action("Stop", lambda: NodeStatus.SUCCESS)
.end()
.end()
.build()
)
executor = TreeExecutor()
executor.set_tree(tree)
print(executor.tick_until_result(max_ticks=10))
Expected output:
NodeStatus.SUCCESS
The Python indentation in your code visually represents the tree hierarchy. Each
.end() closes the nearest open scope.
| Rule | Detail |
|---|---|
| Close every control scope | .sequence(...), .fallback(...), .parallel(...) all need .end() |
| Close every decorator scope | .retry(...), .inverter(), .timeout(...) need .end() |
| A decorator scope has exactly one child | Zero or more than one child → RuntimeError at build() |
Call .build() last |
Raises RuntimeError if unclosed scopes remain |
.tree_id() is optional |
Defaults to "tree" if omitted |
Pass a node class (not an instance) as the second argument to .node():
from bteng import ActionNode, NodeStatus
class Work(ActionNode):
def tick(self):
return NodeStatus.SUCCESS
tree = (
TreeBuilder(blackboard=bb)
.sequence("root")
.node("DoWork", Work)
.end()
.build()
)
Connect a node’s declared ports to blackboard keys immediately after .node():
from bteng import ActionNode, InputPort, NodeStatus, OutputPort
class Navigate(ActionNode):
@classmethod
def provided_ports(cls):
return [
InputPort("goal"),
OutputPort("result"),
]
def tick(self):
goal = self.get_input("goal")
self.set_output("result", f"arrived:{goal}")
return NodeStatus.SUCCESS
tree = (
TreeBuilder(blackboard=bb)
.node("Nav", Navigate)
.map("goal", "current_goal") # input port "goal" reads bb["current_goal"]
.map_output("result", "nav_result") # output port "result" writes bb["nav_result"]
.build()
)
For static values that do not come from the blackboard:
.node("Nav", Navigate)
.literal("goal", (0.0, 0.0)) # hardcoded value
The builder validates that .map() and .map_output() reference ports that are
actually declared in provided_ports(). If not, TreeExecutor.set_tree() raises
TreeValidationError.
Wrap a single child with a decorator scope:
tree = (
TreeBuilder(blackboard=bb)
.sequence("root")
.retry(max_attempts=3)
.action("TryConnect", lambda: NodeStatus.FAILURE)
.end()
.inverter()
.condition("NotBusy", lambda: False)
.end()
.timeout(seconds=5.0)
.node("SlowTask", SlowAction)
.end()
.end()
.build()
)
Available decorator methods:
| Method | Parameters | Effect |
|---|---|---|
.inverter() |
— | Flips SUCCESS ↔ FAILURE |
.retry(max_attempts) |
max_attempts: int |
Re-ticks on FAILURE up to N times |
.timeout(seconds) |
seconds: float |
Returns FAILURE after timeout |
.rate_controller(hz) |
hz: float |
Limits ticking to N times per second |
.force_success() |
— | Always returns SUCCESS |
.force_failure() |
— | Always returns FAILURE |
Use .reactive_sequence() and .reactive_fallback() in place of the standard
variants:
tree = (
TreeBuilder(blackboard=bb)
.reactive_sequence("guarded_navigate")
.condition("PathClear", lambda: bb.get("path_clear", False))
.action("Navigate", NavigateAction)
.end()
.build()
)
Any blackboard write to a key watched by a condition in this subtree will trigger
re-evaluation from PathClear on the next tick, even if Navigate is still
RUNNING.
from bteng import Blackboard, NodeStatus, StatefulActionNode, TreeBuilder, TreeExecutor
bb = Blackboard.create("mission")
bb.set("battery_ok", True)
bb.set("goal_reached", False)
bb.set("path_clear", True)
bb.set("recovery_ok", True)
class NavigateToGoal(StatefulActionNode):
def on_start(self):
self._ticks = 0
return NodeStatus.RUNNING
def on_running(self):
self._ticks += 1
if self._ticks >= 4:
bb.set("goal_reached", True)
return NodeStatus.SUCCESS
return NodeStatus.RUNNING
tree = (
TreeBuilder(blackboard=bb)
.tree_id("FullMission")
.fallback("mission_or_recover")
.sequence("main_mission")
.condition("BatteryOK", lambda: bb.get("battery_ok", False))
.reactive_sequence("guarded_nav")
.condition("PathClear", lambda: bb.get("path_clear", False))
.node("Navigate", NavigateToGoal)
.end()
.condition("GoalReached", lambda: bb.get("goal_reached", False))
.end()
.sequence("recovery")
.condition("RecoveryOK", lambda: bb.get("recovery_ok", False))
.action("ReturnHome", lambda: NodeStatus.SUCCESS)
.end()
.end()
.build()
)
executor = TreeExecutor()
executor.set_tree(tree)
status = executor.tick_until_result(max_ticks=20)
print(status)
print("Goal reached:", bb.get("goal_reached"))
Expected output:
NodeStatus.SUCCESS
Goal reached: True
| Mistake | Fix |
|---|---|
Forgetting .end() |
Close every control and decorator scope; build() raises RuntimeError if left open |
Calling .build() before all .end() |
Same as above — finish all scopes first |
Passing a node instance to .node() |
Pass the class, not an instance: .node("Nav", Navigate) not .node("Nav", Navigate(...)) |
Using .map() on a node with no provided_ports() |
TreeExecutor will raise TreeValidationError for unknown ports |
Mixing TreeBuilder and manual node construction in one tree |
Possible but fragile; prefer one or the other |
Next: Testing your first tree.