BehaviorTree.CPP vs State Machines vs SMACC2: Robot Task Logic Compared (2026)

BehaviorTree.CPP vs State Machines vs SMACC2: Robot Task Logic Compared (2026)

BehaviorTree.CPP vs State Machines vs SMACC2: Robot Task Logic Compared (2026)

A robot that works in a demo and a robot that survives a warehouse shift differ mostly in one layer: the task logic that decides what to do when the plan stops matching reality. Teams reach for a finite state machine because it is the first formalism every engineer learns, then spend a year adding edges until nobody dares touch the diagram. Others adopt behavior trees because Nav2 ships with them, then discover that a tree has no native idea of “mode” and rebuild a state machine out of blackboard flags. Both failures come from choosing a formalism by fashion rather than by the shape of the problem.

This post is the decision piece. The BehaviorTree.CPP vs state machine question is really three questions: how each model handles reactivity (reacting to a change while an action is running), how each scales when you add behaviors, and how well you can debug and test the result. We compare BehaviorTree.CPP, flat and hierarchical state machines, and SMACC2, and we end with a migration pattern and a decision matrix.

What this covers: tick semantics and the control nodes that matter, the state-explosion arithmetic, blackboards and ports, async actions and halting, how Nav2 uses trees, where SMACC2, FlexBE, py_trees and YASMIN fit, debugging with Groot2, a runnable XML plus C++ example, and an FSM-to-tree migration path.

Context and Background

Finite state machines (FSMs) are the oldest tool. A state machine has a set of states, an event alphabet, and a transition function; the robot is always in exactly one state, and events move it. Harel’s statecharts (1987) added hierarchy, orthogonal regions and history, which is what people mean by a hierarchical state machine (HSM). For robotics, SMACH was the classic ROS 1 library, and in ROS 2 the options include SMACC2, FlexBE and YASMIN.

Behavior trees (BTs) came from game AI, where developers found that FSMs became hard to scale, extend and reuse as agent complexity grew. A 2020 survey by Iovino, Scukins, Styrud, Ögren and Smith makes this exact argument: BTs arrange state-transition logic hierarchically with the behaviors as leaves, which improves modularity and makes systems easier for both people and algorithms to design and analyze (arXiv:2005.05842). The foundational textbook is Colledanchise and Ögren, Behavior Trees in Robotics and AI: An Introduction (arXiv:1709.00084), which also shows how BTs relate to, and often generalize, earlier task-switching structures, and gives tools for analyzing safety, robustness and efficiency formally.

In ROS 2, the practical incumbent for trees is BehaviorTree.CPP, the C++ library maintained by Davide Faconti and contributors, and used inside Nav2. At the time of writing, the project’s releases page lists 4.9.0 as the latest release (it adds a TryCatch node and wraps exceptions thrown in tick() in a NodeExecutionError), preceded by 4.8.x releases that added event-based tree ticking and stricter XML validation. Pin your own version and read the changelog; the 4.8.4 notes warn that stricter name validation may break existing trees.

If you want the foundations first, our earlier piece on behavior trees for robot task planning architecture covers what a tree is and how to structure one. This post assumes that background and asks the harder question: when is a tree the wrong tool, and what do you gain or lose by choosing a statechart library such as SMACC2 instead.

The thesis, stated up front: behavior trees and state machines are not rivals on one axis. A tree is optimized for prioritized, reactive task decomposition, where “what should I do now” is re-derived from conditions on every tick. A state machine is optimized for explicit modes with guarded lifecycle, where “what phase am I in” is itself the primary fact. Most production robots need both, and the real engineering skill is drawing the boundary between them.

Reference Architecture: Where Task Logic Sits and What Each Model Optimizes

The three candidates differ in what the runtime remembers. A state machine remembers the current state. A behavior tree remembers almost nothing between ticks except the status of running nodes and the contents of the blackboard. SMACC2, built on Boost Statechart, remembers a set of active states, one per orthogonal region, plus per-state objects.

A behavior tree re-evaluates its conditions from the root on every tick, so reactive priorities are free; a state machine evaluates only the transitions leaving the current state, so modes are explicit but reactivity must be wired edge by edge. SMACC2 sits between: statechart hierarchy plus orthogonal regions, with C++ compile-time validation.

BehaviorTree.CPP vs state machine reactive tree structure with ReactiveFallback, battery and e-stop conditions and a running navigation action

Figure 1: A reactive mission tree. A ReactiveFallback re-checks BatteryOk and EStopClear on every tick while NavigateToPose is RUNNING; a failed condition halts the action and falls through to SafeStop.

Figure 1 shows the structural reason trees feel reactive. The root ticks at a fixed rate (the executor, not the tree, owns the rate). Each tick walks from the root downward, and any condition placed to the left of a running action is evaluated again before that action is ticked. If the condition flips, the parent returns FAILURE, the running child is halted, and the fallback branch takes over. No transition was drawn; the priority order of the children encoded the behavior.

The same behavior in a flat FSM requires an edge from every state in which the robot might be navigating, charging, docking, or manipulating to a “safe stop” state, for each of the guard conditions. Add a fifth condition and you add edges from every state. HSMs reduce this by letting a superstate own the edge: put all task states inside a “Operating” superstate and draw one estop transition out of it. That is a genuine win and it closes much of the gap, which is why the honest comparison is BT versus HSM, not BT versus flat FSM.

What a tick really is

A tick is the invocation of a node’s tick() callback. According to the BehaviorTree.CPP documentation, a tick propagates through the tree until it reaches a leaf, each parent decides if, when and how many times to tick its children, and every ticked node returns SUCCESS, FAILURE or RUNNING. RUNNING means the action needs more time. Condition nodes are documented as not returning RUNNING.

That three-valued return is the whole contract. SUCCESS and FAILURE are terminal for that activation; RUNNING says “tick me again”. The tree has no event queue, no transition table, no current-state variable. Everything the robot “is doing” is the set of nodes currently returning RUNNING. Debugging a tree is therefore the act of reading which path of RUNNING nodes the last tick produced, which is precisely what Groot2 visualizes.

The control nodes that decide your behavior

Four control-flow families cover almost every real tree, and their differences are where bugs hide.

Sequence ticks children left to right and returns SUCCESS only if all succeed; on the first FAILURE it stops and returns FAILURE. The BehaviorTree.CPP basics page illustrates the danger with its “find the bug” example: if the grab-beer step fails, the close-fridge step never runs, so the fridge stays open. Sequences do not clean up after themselves. The 4.9.0 TryCatch node exists partly for this reason, offering a cleanup branch that runs on failure or halt.

Fallback (also called Selector) ticks children in order until one succeeds, moving on at each FAILURE and returning FAILURE only when all fail. It is the encoding of “try the preferred approach, then the next”.

ReactiveSequence and ReactiveFallback differ from their plain siblings in one respect: they restart from the first child on every tick, even when a later child returned RUNNING. That is what makes the guard in Figure 1 work. The cost is that every child to the left of the running one must be safe to tick repeatedly, which means conditions and fast synchronous nodes, not long-running actions. The official node-library pages for these were not reachable when I checked, so treat the one-line summary above as the widely documented behavior and verify it against the docs for your pinned version.

SequenceWithMemory is the opposite: it remembers which child last returned RUNNING and resumes there, never re-ticking earlier children. Use it for a strict procedure (open gripper, move, close gripper) where re-checking earlier steps would be wrong or wasteful.

Parallel ticks all children each pass. Per the documentation, it has two ports: success_count (default -1) is the number of children that must succeed for the node to return SUCCESS, and failure_count (default 1) is the number that must fail for it to return FAILURE. Negative values count from the total, so -1 means all. It completes as soon as either threshold is reached and halts any children still RUNNING. With defaults, one failing child fails the whole node. With success_count=2 failure_count=2 and three children, two successes before two failures yield SUCCESS.

Nav2 adds control nodes of its own. Per its documentation, PipelineSequence re-ticks earlier children whenever a later child returns RUNNING, halts everyone if any child fails, and halts and succeeds when the last child succeeds. It is the Nav2 idiom for “keep replanning while the controller follows”. RecoveryNode has two children: the main task and a recovery action. If the first fails, the second runs and then the first is retried, until the first succeeds, the recovery fails, or number_of_retries is exceeded. RoundRobin cycles through children across ticks, remembering position, and is used to rotate recovery behaviors.

Blackboards and ports: where data lives

In a state machine, data usually lives in the state object or in a shared context passed to every callback. In BehaviorTree.CPP, data flows through ports on nodes, bound to entries on a blackboard, a shared key-value store. The XML refers to entries with {key} syntax; nodes that use the same key share the same entry. A node declares InputPort<T> and OutputPort<T> in a static providedPorts() function, reads with getInput<T>() (which returns a result you must check) and writes with setOutput<T>(). Custom types are loaded from strings in the XML by specializing BT::convertFromString<T>.

Ports are the main reason trees stay modular. A node that computes a goal does not know who consumes it; the XML wires them. The cost is that the data flow is invisible in the tree’s shape. Two sibling subtrees that both write {target} will silently overwrite each other, and the tree structure alone will not tell you. Subtrees have their own blackboard scope with explicit port remapping, which is the tool for containing this, and the Pro edition of Groot2 adds blackboard visualization to help you see it (the free edition lacks it).

SMACC2 and other statechart libraries solve data differently. In SMACC2, per its project description, orthogonal regions are central: the library follows Harel’s orthogonality and inherits compile-time validation from Boost Statechart. Data typically lives in orthogonal objects (clients, components) attached to the state machine, not in a string-keyed store, so type errors surface at compile time rather than at tree-load time. That is a real advantage for safety-conscious code, and a real constraint on dynamic reconfiguration.

Deeper Analysis: Reactivity, Explosion Math, Async Semantics

The decisive comparison is not feature lists. It is what happens when something changes mid-action, and how the model count grows when requirements change.

The reactivity race: ticks versus transitions

Walk through Figure 2. A ReactiveSequence guards an asynchronous NavigateToPose action behind an IsClear condition. On tick 1, the condition succeeds and the action starts and returns RUNNING. On tick 2, the sequence restarts from the first child, so the condition is evaluated again; it still succeeds, and the action is ticked again and returns RUNNING. On tick 3 the condition fails. The sequence returns FAILURE and, crucially, halts the running action, which in a well-written node cancels the underlying goal.

BehaviorTree.CPP vs state machine reactivity sequence diagram showing a ReactiveSequence halting an async NavigateToPose action when a condition fails

Figure 2: Tick-by-tick execution of a ReactiveSequence. The guard is re-evaluated on every tick, and a FAILURE halts the asynchronous child, which must cancel its action goal.

The latency from “condition becomes false” to “action halted” is at most one tick period plus the cost of evaluating the guard. If the tree ticks at 100 Hz, that is under 10 ms of tree overhead; the goal cancellation round trip to the action server then dominates. Contrast an FSM in state Navigating: the guard is only noticed if an event is delivered and an edge for it exists out of that state. Event delivery is equally fast, but the mechanism is different. A tree polls conditions; an FSM waits for events. Polling costs CPU per tick but cannot miss a level-triggered condition; an event-driven machine is cheaper at idle but can miss a state change that never produced an event, which is a classic source of stuck robots.

Notice the trade: polling gives level-triggered semantics (the condition is true now) and events give edge-triggered semantics (something just happened). BehaviorTree.CPP 4.8.2 added event-based tree ticking, which lets a tree sleep until something wakes it, a hybrid that reduces idle polling; I have not benchmarked it, so consult the release notes before relying on it for latency claims.

Async actions and halting: the part everyone gets wrong

A synchronous action, in the project’s words, executes atomically and blocks the tree until it returns SUCCESS or FAILURE. An asynchronous action may return RUNNING, and the parent must tick it again until it finishes. Anything that talks to a ROS 2 action server should be asynchronous: send the goal on the first tick, poll the result on later ticks, return RUNNING in between.

The sharp edge is halt(). When a parent abandons a RUNNING child, because a ReactiveSequence condition failed or a Parallel threshold was reached or a Fallback pre-empted it, the child is halted. If your node does not override the halt hook to cancel the goal, the robot keeps executing the abandoned goal while the tree believes it moved on. This is the single most common serious bug in BT-based ROS 2 systems, and it is invisible in the tree diagram. Every async leaf needs a reviewed halt path: cancel the goal, stop the motion, release resources. State machines have the same issue on state exit, but the exit callback is a visible, named concept, whereas halt is an implicit consequence of structure.

State explosion: the arithmetic

Suppose a mobile manipulator has three loosely coupled concerns: a drive subsystem with 4 modes (idle, navigating, docking, stuck), an arm with 3 modes (stowed, reaching, holding), and a payload or safety supervisor with 5 modes (nominal, low battery, degraded sensing, paused, e-stopped). A flat FSM that models the robot as one machine needs a state for every combination of modes that can coexist: 4 x 3 x 5 = 60 states, before you prune the impossible ones. Add one more mode to the supervisor and you pay 4 x 3 = 12 new states and every edge that touches them. These numbers are illustrative, not measured, but the multiplication is exact: independent concerns multiply in a flat machine.

An HSM with orthogonal regions, the Harel mechanism SMACC2 is built around, runs the three concerns as concurrent regions. Its state count is the sum, 4 + 3 + 5 = 12, and adding a supervisor mode costs one state. This is the key point people miss when they declare FSMs obsolete: the state-explosion argument is an argument against flat FSMs, not against statecharts.

BehaviorTree.CPP vs state machine scaling comparison of flat FSM state product, HSM orthogonal regions and behavior tree subtrees

Figure 3: Growth under one added mode. The flat FSM multiplies (60 to 72 states), the orthogonal HSM adds one state, and the tree adds one subtree beneath a Fallback.

Where does the tree sit? A tree does not enumerate combinations at all, because it never stores “the combination”. Each tick it derives behavior from conditions, so the 60 combinations are never reified. The price is the opposite failure: combinations you did not think about are handled by whatever the priority ordering happens to do, which can be wrong without being visible. The FSM makes you enumerate the cases and thus think about them; the tree lets you forget them. Neither is free.

Colledanchise and Ögren’s analysis is useful here. Their book argues that BTs generalize earlier structures including finite state machines, decision trees and the subsumption architecture, and that BTs are modular because a subtree’s interface is only its return value, while transitions in an FSM reference other states directly. That “return value only” interface is why subtree reuse is cheap in a tree and expensive in a state machine, where reusing a state cluster means rewiring every external edge. They also derive formal conditions for safety and robustness (for example, that a BT is safe if the region of attraction of a safe subtree contains the states reachable under the rest). I am summarizing from the abstract and my own reading, so cite the original for the exact theorems.

Modularity, side by side

Reuse is the strongest practical argument for trees. A PickObject subtree has a contract: it takes inputs through ports and returns SUCCESS, FAILURE or RUNNING. You can drop it into any tree. An FSM cluster for picking must expose entry states, exit transitions and shared variables; reusing it means wiring outcomes explicitly. FlexBE’s states do this with named outcomes, and SMACC2 does it with transitions between states that name event types, both of which are clean but more coupled than a bare status code.

The flip side: a tree’s “contract” is three values. If a subtree can end in five meaningfully different ways (succeeded, no object found, grasp slipped, arm fault, operator abort), a tree must squeeze these into FAILURE plus blackboard side-channel data, whereas an FSM or statechart gives each outcome a name and a destination. When the outcome taxonomy is rich, the state-machine form is more honest.

Deeper Analysis: Nav2, SMACC2, FlexBE, py_trees and YASMIN in Practice

Nav2 is the largest production use of BehaviorTree.CPP in ROS 2, and the official docs frame it as a customization point: the BT navigator loads an XML tree, and planners, controllers and recoveries are exposed to the tree as action nodes. The Nav2 documentation lists action plugins such as Wait, Spin, BackUp, ComputePathToPose, FollowPath and NavigateToPose; conditions such as GoalReached, GoalUpdated, IsStuck, TimeExpired and IsBatteryLow; controls such as PipelineSequence, RecoveryNode, RoundRobin and ReactiveFallback; and decorators such as RateController and DistanceController.

The documented minimal tree illustrates the idiom: a PipelineSequence named NavigateWithReplanning wraps a ComputePathToPose behind a DistanceController (distance 1.0, so replanning is triggered every meter of travel) followed by FollowPath, passing goal in and path out via the blackboard. The docs note that this basic tree has no recovery methods and no retries. Production trees wrap each stage in a RecoveryNode, whose recovery child typically clears a costmap, and wrap the whole pipeline in an outer RecoveryNode with several retries. The documentation example uses number_of_retries="6" for the outer node.

Why did Nav2 choose a tree rather than a state machine? The answer is recovery. Navigation failures are plentiful and heterogeneous: planner failure, controller failure, oscillation, blocked goal, a goal update mid-flight. In a flat machine each would need an edge from Planning and from Following to a recovery state and back, doubled by the retry counter. In the tree, a RecoveryNode around each stage and a RoundRobin of recoveries encode the same policy in a few lines of XML, and users swap trees without recompiling the navigator. See our deep dives on Nav2 Lyrical versus Kilted and the MPPI trajectory validator and the ROS 2 Kilted to Lyrical migration for how the surrounding stack has moved. Which BehaviorTree.CPP major version your Nav2 distro links is distro-specific; check the package dependencies for your release rather than assuming.

SMACC2: statecharts done in C++ templates

SMACC2’s project description calls it an event-driven, asynchronous, behavioral state machine library for real-time ROS 2 applications in C++, aimed at robots with multiple components. Its status table lists Humble and Jazzy, it is Apache-2.0 licensed, and its GitHub page shows no formal releases, so treat versioning as source-based. Because it inherits from Boost Statechart, a malformed machine (a transition to a state that does not exist, for instance) fails at compile time. That is the headline trade against BehaviorTree.CPP’s XML: SMACC2 gives compile-time structural guarantees, while trees give runtime-swappable structure and a graphical editor.

SMACC2’s orthogonals are its distinctive feature: concurrent regions that each hold a component such as a navigation client or a sensor, running in parallel with independent state. This is the HSM answer to the explosion arithmetic above. It also demands comfort with C++ templates and Boost.MPL-style declarations; the learning curve is steeper than writing a tree in XML, and I found no independent benchmark of its latency, so I make no performance claims.

FlexBE, py_trees and YASMIN

FlexBE (the Flexible Behavior Engine) is a hierarchical-state-machine framework with an operator-in-the-loop GUI, designed for collaborative autonomy where a human can approve or alter behavior at runtime. The ROS index lists the metapackage for Humble, Jazzy, Kilted, Lyrical and Rolling under a BSD license. Zutell, Conner and Schillinger’s paper, “Flexible Behavior Trees: In search of the mythical HFSMBTH,” proposes a hybrid: an open-source ROS 2 package that lets BT-based behaviors run inside a FlexBE hierarchical state machine (arXiv:2203.05389). It is the clearest published statement of the position this post argues: use both.

py_trees and py_trees_ros bring behavior trees to Python, with a tick-based design closely related to BehaviorTree.CPP’s. YASMIN is a Python/C++ state-machine library for ROS 2. I was unable to load their documentation pages during research, so I am positioning them from general knowledge only: py_trees for Python-first teams prototyping trees, YASMIN for lightweight FSMs in the style of SMACH. Verify features and current ROS 2 distro support on their repositories before adopting either.

A runnable example: reactive guard plus async action

The following tree and nodes demonstrate the pattern from Figure 2 with BehaviorTree.CPP 4.x APIs (BTCPP_format="4", StatefulActionNode, NodeConfig). It simulates navigation as a countdown; replace the body with an action-client call in a real system. Treat it as a teaching skeleton: I verified the port and XML conventions against the official tutorial, but compile it against your pinned version before relying on it.

<root BTCPP_format="4">
  <BehaviorTree ID="MainTree">
    <ReactiveFallback name="mission_root">
      <ReactiveSequence name="mission">
        <IsClear/>
        <Navigate goal="{target}" />
      </ReactiveSequence>
      <SafeStop/>
    </ReactiveFallback>
  </BehaviorTree>
</root>
#include <behaviortree_cpp/bt_factory.h>
#include <chrono>
#include <iostream>

using namespace BT;

struct Pose2D { double x, y; };

namespace BT {
template <> inline Pose2D convertFromString(StringView str) {
  auto p = splitString(str, ';');
  if (p.size() != 2) throw RuntimeError("expected x;y");
  return {convertFromString<double>(p[0]), convertFromString<double>(p[1])};
}
}  // namespace BT

class Navigate : public StatefulActionNode {
public:
  Navigate(const std::string& name, const NodeConfig& cfg)
      : StatefulActionNode(name, cfg) {}
  static PortsList providedPorts() { return {InputPort<Pose2D>("goal")}; }

  NodeStatus onStart() override {
    auto g = getInput<Pose2D>("goal");
    if (!g) throw RuntimeError("missing goal: ", g.error());
    ticks_left_ = 5;  // stand-in for send_goal() to an action server
    std::cout << "send goal\n";
    return NodeStatus::RUNNING;
  }
  NodeStatus onRunning() override {
    return (--ticks_left_ <= 0) ? NodeStatus::SUCCESS : NodeStatus::RUNNING;
  }
  void onHalted() override {
    std::cout << "cancel goal\n";  // MUST cancel the real goal here
  }
private:
  int ticks_left_ = 0;
};

int main() {
  static int calls = 0;
  BehaviorTreeFactory f;
  f.registerNodeType<Navigate>("Navigate");
  f.registerSimpleCondition("IsClear", [](TreeNode&) {
    return (++calls < 3) ? NodeStatus::SUCCESS : NodeStatus::FAILURE; });
  f.registerSimpleAction("SafeStop", [](TreeNode&) {
    std::cout << "safe stop\n"; return NodeStatus::SUCCESS; });
  auto tree = f.createTreeFromText(R"(<root BTCPP_format="4">
    <BehaviorTree ID="MainTree"><ReactiveFallback>
      <ReactiveSequence><IsClear/><Navigate goal="1;2"/></ReactiveSequence>
      <SafeStop/></ReactiveFallback></BehaviorTree></root>)");
  tree.tickWhileRunning();
}

Expected behavior: the first two ticks run the condition and the navigation (printing “send goal” once), the third tick’s condition fails, onHalted() prints “cancel goal”, and the fallback runs SafeStop. If you comment out the cancel logic in a real node, the tree will still report the right status while the robot keeps driving, which is precisely the failure mode described earlier.

Debugging and Testing: Groot2 and Beyond

Observability decides whether a task-logic formalism survives its second year. Trees have a purpose-built tool: Groot2, an IDE for creating and debugging behavior trees from the BehaviorTree.CPP project. Per the official page, its editor offers drag-and-drop editing, multi-file projects, compatibility with BT.CPP 3 and 4, and a real-time XML preview. Its real-time monitor connects to a running executor and shows tree state live, and can record every transition to a log file. Its log visualization replays execution at different speeds, shows how long a node stayed RUNNING and how often it changed status, and filters transitions by name or time range.

Licensing matters for planning. The page lists a free tier with the full editor and a monitor and log visualizer limited to up to 20 nodes, and a PRO tier at 590 euros per year (floating license) with unlimited nodes, node search, blackboard visualization, interactive breakpoints, fault injection and run-time substitution of nodes with dummy nodes. A real Nav2 tree easily exceeds 20 nodes once recovery subtrees are included, so assume the free tier is for learning and PRO for fleet debugging. Prices change; confirm on the vendor page. Release 4.8.4 added a command-line tool that generates the XML model file Groot2 needs, which helps CI pipelines keep node palettes in sync.

What Groot2 gives you that an FSM tool rarely does: a timeline of which branch was RUNNING and when it flipped. Post-mortems of “why did the robot reverse into the shelf” become a replay of the exact tick where a ReactiveFallback pre-empted the controller. For statecharts, SMACC2 ships its own visualization and logging tooling in the ecosystem, and FlexBE provides a graphical behavior editor and runtime monitor with operator interaction; I did not verify their feature lists in this run, so evaluate them directly.

Testing strategies that work for both models

Tree testing is mostly unit testing of leaves plus scenario testing of structure. Leaves are plain C++ classes: instantiate with a hand-built NodeConfig and blackboard, call executeTick(), and assert on status and output ports. Structure tests load the XML, register mock leaves (BehaviorTree.CPP supports simple lambdas for conditions and actions, as in the example above), script their returns tick by tick, and assert on the sequence of halts. The mock-leaf approach tests exactly the reactive properties that cause field bugs: that a failing guard halts the running action, and that the fallback then runs.

State machines are tested by driving event sequences and asserting on state and entry/exit side effects. A statechart is easier to test exhaustively in the model-checking sense, since its states and transitions are enumerable, and several statechart tools can export to formal verifiers. Trees can be analyzed formally too (the Colledanchise and Ögren work does), but in practice teams rely on scenario tests and simulation replay. Whatever the formalism, record the tick log in simulation and diff it between releases; behavior regressions show up as changed status timelines long before they show up as crashes.

Migrating from a State Machine to a Tree (Without a Rewrite)

Big-bang rewrites of task logic fail because the old machine encodes years of field fixes in its odd edges. A staged migration keeps those fixes alive while moving reactive logic into a tree. Figure 4 shows the path.

BehaviorTree.CPP vs state machine migration path from legacy FSM to a hybrid of hierarchical state machine modes and behavior tree tasks

Figure 4: A six-step FSM-to-tree migration. The legacy machine is first wrapped as a single action node, then dismantled piece by piece, with the mode-level HSM retained.

Step 1, extract leaves. List every action the old states perform and every guard on every edge. Actions become action nodes, guards become condition nodes. Resist the urge to redesign; one-to-one extraction gives you a behavioral baseline.

Step 2, move data to ports. Shared mutable context in the FSM becomes blackboard entries with named ports. Do this early, because untangling data flow is the hardest part and it forces you to find hidden coupling.

Step 3, wrap the old machine as one node. Expose the entire legacy FSM as a single stateful action node returning RUNNING until the machine reaches a terminal state. Now the tree can pre-empt it with a ReactiveFallback, which delivers immediate value: global guards such as e-stop and battery live in the tree while the legacy internals stay untouched. This step is where the Zutell et al. hybrid argument becomes concrete.

Step 4, replace guards with structure. Peel states out of the wrapped machine one at a time. A chain of linear states becomes a Sequence; a state with ordered alternatives becomes a Fallback; a retry loop with a counter becomes a RecoveryNode-style decorator. Each peel is testable against the baseline tick log.

Step 5, keep an HSM for modes. Operating modes such as manual, autonomous, charging and maintenance are genuine states with entry and exit semantics and mutual exclusion. Leave them as a small HSM (SMACC2, FlexBE or a hand-rolled statechart), and let each autonomous-mode state host a tree.

Step 6, test with log replay. Record Groot2 or equivalent logs from both versions across the same simulated missions, and compare status timelines. Differences are either bugs or undocumented behavior you now know about.

Trade-offs, Gotchas, and What Goes Wrong

Honest limits apply to every option here, and most project failures trace to a handful of recurring mistakes.

Trees hide state. Because a tree is stateless between ticks apart from RUNNING nodes and the blackboard, teams smuggle modes in as blackboard flags (is_charging, phase). Once you have three such flags guarding branches you have built an implicit state machine with no transition table and no tooling. The fix is to promote real modes to an explicit HSM above the tree.

Reactivity can thrash. A ReactiveSequence re-evaluates its guard every tick. A condition near a threshold (battery at 20 percent, an obstacle at 0.5 meters) can flip each tick, halting and restarting the action repeatedly. Add hysteresis in the condition node, a minimum dwell time, or a decorator that latches the decision. State machines face the same flapping on a guarded edge, but their explicit transitions make it more visible.

Halt bugs are silent. Covered above, and worth repeating: an async node without a correct halt is a latent safety defect. Review every async leaf for goal cancellation, and write a test that halts mid-run.

Condition nodes that block or have side effects. Conditions are re-ticked constantly, so they must be fast and idempotent. A condition that calls a slow service at the tick rate will stall the whole tree, since the tick is single-threaded unless you build otherwise.

XML drift. Runtime-loaded XML means a typo in a port name fails at load time, not compile time. Version 4.8.4 tightened name validation and duplicate-path detection, a sign the project is closing these gaps, but you still need a CI step that loads every tree and validates it against the registered node models.

Statechart costs. SMACC2-style libraries bring compile-time safety but also template-heavy code, a smaller community than the tree ecosystem, and no runtime swap of structure. Its distro table lists only Humble and Jazzy and no formal releases, which is a dependency-risk factor for long-lived products; evaluate your own support horizon.

Tick-rate coupling. Everything in a tree is paced by the executor’s tick rate. Slowing the rate to save CPU raises the worst-case reaction latency by the same factor. Choose the rate from your safety reaction requirement, not from habit.

Not a safety system. Neither a tree nor a statechart is a certified safety function. E-stop and speed limiting belong in the safety-rated layer below ROS 2, with the task logic reacting to those signals rather than implementing them.

Decision Matrix: Which Formalism for Which Job

Criterion BehaviorTree.CPP Flat FSM HSM (FlexBE, YASMIN-style) SMACC2 (statechart, orthogonals)
Reactive pre-emption Native (Reactive nodes) Edge per state Superstate edge Event transitions on parent
Scaling with new behaviors Add subtree Multiplicative Additive in regions Additive in orthogonals
Rich named outcomes Three statuses plus blackboard Strong Strong (named outcomes) Strong (typed events)
Structural validation Load-time XML Compile-time if coded Varies by tool Compile-time (Boost Statechart)
Runtime reconfiguration Swap XML file Rebuild Tool dependent Rebuild
Visual editor/monitor Groot2 (free limited, PRO paid) Rarely FlexBE GUI Ecosystem tools
Human in the loop Custom Custom Strong in FlexBE Custom
Nav2 compatibility Native BT navigator Needs wrapper Needs wrapper Needs wrapper

Read the matrix by job. For navigation-style reactive missions with many recoveries, choose a tree. For robot lifecycle and operating modes (boot, calibrate, run, charge, fault), choose an HSM. For multi-component robots where concurrent subsystems each have modes and you want compile-time guarantees, SMACC2 is the strongest fit if your team accepts C++ template code. For operator-supervised missions (inspection, teleop handoff), FlexBE’s human-in-the-loop design is purpose-built. A flat FSM is still right for a single-purpose device with fewer than about eight states and no concurrency, where a diagram on a whiteboard is the spec.

Practical Recommendations

Default to a hybrid: a small HSM for modes, trees for the task logic within each mode. This follows both the published hybrid argument and the practice visible in Nav2, where the navigator is a mode-like server and the logic inside it is a tree. Resist both extremes: a single giant tree with mode flags, and a single giant statechart with every recovery drawn as edges.

Size the decision by the number of independent concerns and failure types. If you can list fewer than eight states and no concurrency, draw an FSM. If recoveries and priorities dominate, use BehaviorTree.CPP. If concurrent subsystems each have modes, use orthogonal regions. If you adopt trees, budget for the debugging tool from the first sprint, because a tree you cannot replay is harder to trust than a diagram you can read.

A short checklist before you commit:

  • Write down the three worst runtime surprises your robot has met; check each is expressible as a guard, a fallback or an explicit transition.
  • List every async action and confirm a tested halt or cancel path.
  • Choose the tick rate from your reaction-latency requirement.
  • Put operating modes in an explicit state machine, not blackboard flags.
  • Pin library versions (BehaviorTree.CPP, your statechart library) and check release notes for breaking validation changes.
  • Record tick or transition logs in simulation and diff them between releases.
  • Keep safety functions outside the task logic.

For simulation and asset context, see how robot descriptions feed this loop in our URDF vs SDF vs MJCF vs OpenUSD guide, and for manipulation-side task logic see the MoveIt Pro 10.0 versus 9.4 migration notes.

Frequently Asked Questions

Are behavior trees better than state machines for robots?

Neither is better in general. Behavior trees excel at prioritized, reactive task decomposition with recoveries, because conditions are re-evaluated every tick and subtrees reuse cleanly. State machines excel at explicit operating modes with entry and exit semantics and richly named outcomes. Flat FSMs suffer state explosion, but hierarchical statecharts with orthogonal regions largely avoid it. Most production robots combine a small HSM for modes with trees for tasks.

What is the difference between a Sequence and a ReactiveSequence in BehaviorTree.CPP?

A Sequence runs children left to right and, once a child is RUNNING, resumes there on the next tick. A ReactiveSequence restarts from its first child on every tick, so earlier conditions are re-checked while a later action runs, and a failure halts that action. Use ReactiveSequence for guards over long-running actions, and keep every child before the running one fast and safe to re-tick.

Does Nav2 use BehaviorTree.CPP?

Yes. Nav2’s BT navigator loads a behavior tree XML and exposes planning, control and recovery as tree nodes: actions like ComputePathToPose and FollowPath, conditions like GoalUpdated and IsStuck, and controls like PipelineSequence, RecoveryNode and RoundRobin. You customize navigation by editing the XML or writing plugins. The exact BehaviorTree.CPP major version depends on your ROS 2 distribution, so check the package dependencies for your release.

What is SMACC2 and how is it different from BehaviorTree.CPP?

SMACC2 is an event-driven, asynchronous state machine library for ROS 2 in C++, built on Boost Statechart with Harel-style orthogonal regions. It validates structure at compile time and models concurrent subsystems as parallel regions. BehaviorTree.CPP defines logic in runtime-loaded XML as a tree of status-returning nodes. SMACC2 fits mode-heavy multi-component robots; BehaviorTree.CPP fits reactive task logic and rapid iteration with a visual editor.

Is Groot2 free?

Groot2 has a free tier and a paid PRO tier. According to the official page, the free tier includes the full editor and multi-file projects, with the monitor and log visualizer limited to trees of up to 20 nodes. PRO, listed at 590 euros per year as a floating license, removes the limit and adds blackboard visualization, breakpoints, fault injection and node search. Check the vendor page for current terms.

Can I use behavior trees and state machines together?

Yes, and it is often the best design. The FlexBE team published an open-source ROS 2 package that runs behavior trees inside a hierarchical state machine. A common pattern is an HSM for operating modes such as manual, autonomous and charging, with a behavior tree running inside the autonomous mode. This keeps mode logic explicit while preserving reactivity and reuse for tasks.

Further Reading

Internal:

External primary sources:

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *