A hierarchical state machine framework for Rust, built around a runtime-constructed state tree.
The tree describes topology. Behaviors describe reactions. The runner is a pure function. None of the three owns the others.
A compile-time state machine is a program. A runtime state tree is data.
Declarative state machine libraries — whether type-based like statig or DSL-based like banish — fix topology at compile time. When topology is data, it can be loaded from a file, assembled from user input, or shared across a thousand instances without copying. The cost is that errors surface at runtime rather than compile time, and performance is strictly worse than a compile-time solution. If a declarative library solves your problem, use that instead.
Runner holds only a reference to the tree. The caller owns the current state and behaviors.
One tree and one runner can drive any number of independent instances simultaneously — each with its own State and its
own Behaviors. No coordination required.
Runner::dispatch bubbles an event through behaviors until one handles it. Runner::transition runs the exit/enter sequence between two states. The two are independent: routing an event does not change the state, and changing state does not depend on any event.
Neither is re-entrant. Calling them from within on_event, on_exit, or on_enter is undefined behavior. This is a usage contract, not a type-level guarantee.
Behavior<E> returns an EventReply: Handled (stop bubbling), Ignored (bubble to parent), or Transition(target) (stop bubbling and declare transition intent). Transition(target) is intent only: Runner::dispatch reports it verbatim, and the caller decides whether to execute Runner::transition directly or stage it for arbitration.
A behavior can be tested without a runner and written without knowing the tree's shape.
© 2026 tarnishablec — Licensed under the Mozilla Public License 2.0