Standard SequenceNode and FallbackNode remember the child that returned RUNNING
and resume there on the next tick. This is efficient, but it means earlier conditions
are not re-evaluated while a later action is still running — a changed precondition
has no effect until the current action finishes.
Reactive variants fix this by restarting from child[0] on every tick where relevant state has changed.
| Node | Behavior |
|---|---|
ReactiveSequenceNode |
Restarts from child[0] each tick; earlier conditions can interrupt running actions |
ReactiveFallbackNode |
Same semantics with fallback (OR) logic; higher-priority branches can preempt work |
BTEng implements reactive re-evaluation via blackboard subscriptions, not naive polling. This is important for performance: conditions are only re-evaluated when relevant state has actually changed.
Step by step:
When an action child enters RUNNING, the reactive parent calls
_collect_blackboards(), which walks the subtree and collects every Blackboard
instance reachable from any node in that subtree.
The parent subscribes a _mark_dirty callback to each collected blackboard.
When any of those blackboards receives a write (bb.set(...)), _mark_dirty is
called and _dirty = True is set on the reactive parent.
_dirty is True → full re-evaluation from child[0]. _dirty is reset._dirty is False → fast path: skip all earlier children, tick only the running
action.RUNNING (succeeds, fails, or is halted), the
reactive parent unsubscribes all callbacks and clears _bb_subscriptions.Fallback for trees without a blackboard:
If _collect_blackboards() finds no blackboards in the subtree (e.g., the tree was
built without one), _bb_subscriptions is empty. The tick guard
not self._bb_subscriptions causes _dirty to be treated as always True — full
re-evaluation every tick. This preserves the original polling behaviour for backward
compatibility.
| Scenario | Behaviour |
|---|---|
| No blackboard write between ticks | Fast path: only the running action is ticked |
| One blackboard write between ticks | Full re-evaluation from child[0] |
| Multiple writes between ticks | Same as one write — _dirty is already True |
| Subtree has no blackboard | Full re-evaluation every tick (original polling) |
The reactive fast path is equivalent in cost to the standard non-reactive path. The overhead only occurs when a write actually happens.
from bteng import Blackboard, NodeStatus, TreeBuilder, TreeExecutor
bb = Blackboard.create("robot")
bb.set("path_clear", True)
bb.set("goal", (1.0, 2.0))
tree = (
TreeBuilder(blackboard=bb)
.reactive_sequence("navigate_safely")
.condition("PathClear", lambda: bb.get("path_clear", False))
.action("Navigate", lambda: NodeStatus.RUNNING) # simulates long navigation
.end()
.build()
)
executor = TreeExecutor()
executor.set_tree(tree)
print(executor.tick_once()) # PathClear=True → RUNNING
bb.set("path_clear", False) # write triggers dirty flag
print(executor.tick_once()) # PathClear=False → FAILURE, Navigate halted
Expected output:
NodeStatus.RUNNING
NodeStatus.FAILURE
ReactiveFallbackNode enables priority-based preemption: a higher-priority branch
interrupts lower-priority work when it becomes available.
bb = Blackboard.create("robot")
bb.set("emergency", False)
tree = (
TreeBuilder(blackboard=bb)
.reactive_fallback("priority_arbiter")
.sequence("emergency_stop")
.condition("Emergency", lambda: bb.get("emergency", False))
.action("Brake", lambda: NodeStatus.SUCCESS)
.end()
.action("NormalWork", lambda: NodeStatus.RUNNING)
.end()
.build()
)
executor = TreeExecutor()
executor.set_tree(tree)
print(executor.tick_once()) # Emergency=False → NormalWork RUNNING
bb.set("emergency", True) # triggers dirty flag
print(executor.tick_once()) # Emergency=True → Brake SUCCESS, NormalWork halted
Expected output:
NodeStatus.RUNNING
NodeStatus.SUCCESS
| Scenario | Recommended node |
|---|---|
| A condition change must halt a running action | ReactiveSequenceNode |
| A higher-priority alternative should preempt current work | ReactiveFallbackNode |
| The action should run to completion without interruption | Standard SequenceNode |
| Conditions are checked only at the start of each task | Standard SequenceNode |
| No blackboard is used | Either — reactive falls back to polling |
!!! warning “Keep guard conditions cheap” Guard conditions inside a reactive node are re-evaluated on every tick where a blackboard write occurred. They should read already-computed state from the blackboard — not perform blocking operations or expensive computation. Move any heavy computation into an action that writes its result to the blackboard, then check the result with a condition.
| Component | File |
|---|---|
ReactiveSequenceNode |
bteng/nodes/control/reactive_sequence.py |
ReactiveFallbackNode |
bteng/nodes/control/reactive_fallback.py |
Blackboard.subscribe() |
bteng/blackboard/blackboard.py |
_collect_blackboards() |
Defined on ControlNode in bteng/core/node.py |