| Crates.io | bevy_fsm |
| lib.rs | bevy_fsm |
| version | 0.2.0 |
| created_at | 2025-10-25 15:08:02.914644+00 |
| updated_at | 2026-01-20 17:10:45.834259+00 |
| description | Observer-driven finite state machine framework for Bevy ECS with variant-specific events and flexible validation |
| homepage | https://github.com/ffmulks/bevy_fsm |
| repository | https://github.com/ffmulks/bevy_fsm |
| max_upload_size | |
| id | 1900326 |
| size | 152,118 |
Observer-driven finite state machine framework for Bevy ECS.
| Bevy | bevy_fsm |
|---|---|
| 0.17 | 0.2 |
| 0.16 | 0.1 |
use bevy::prelude::*;
use bevy_fsm::{FSMState, FSMTransition, FSMPlugin, StateChangeRequest, Enter, Exit, Transition, fsm_observer};
use bevy_enum_event::EnumEvent;
fn plugin(app: &mut App) {
// FSMPlugin automatically sets up the observer hierarchy on first use
app.add_plugins(FSMPlugin::<LifeFSM>::default());
// Use fsm_observer! macro for variant-specific observers
// This is functionally identical to a typed global observer but gets automatically parented
// into a custom FSMObservers/LifeFSM/on_enter_dying hierarchy that keeps observers nicely
// sorted by their respective FSM.
fsm_observer!(app, LifeFSM, on_enter_dying);
fsm_observer!(app, LifeFSM, on_exit_alive);
fsm_observer!(app, LifeFSM, on_transition_dying_dead);
}
#[derive(Component, EnumEvent, FSMState, Reflect, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[reflect(Component)]
enum LifeFSM {
Alive,
Dying,
Dead,
}
impl FSMTransition for LifeFSM {
// This is used as baseline filter to allow and forbid transitions
fn can_transition(from: Self, to: Self) -> bool {
matches!((from, to),
(LifeFSM::Alive, LifeFSM::Dying) |
(LifeFSM::Dying, LifeFSM::Alive) |
(LifeFSM::Dying, LifeFSM::Dead)) || from == to
}
}
#[derive(Component)]
struct DyingAnimation;
fn on_enter_dying(trigger: On<Enter<life_fsm::Dying>>, mut commands: Commands) {
let entity = trigger.event().entity;
commands.entity(entity).insert(DyingAnimation);
}
fn on_exit_alive(trigger: On<Exit<life_fsm::Alive>>) {
let entity = trigger.event().entity;
println!("Entity {} was unalived.", entity);
}
fn on_transition_dying_dead(
trigger: On<Transition<life_fsm::Dying, life_fsm::Alive>>,
mut commands: Commands
) {
let entity = trigger.event().entity;
println!("Entity {} was saved from the brink of death.", entity);
}
Implement this trait to define which state transitions are valid:
impl FSMTransition for MyFSM {
fn can_transition(from: Self, to: Self) -> bool {
// Define your transition rules
matches!((from, to),
(MyFSM::StateA, MyFSM::StateB) |
(MyFSM::StateB, MyFSM::StateC)) || from == to
}
// Optional: context-aware validation with world access
fn can_transition_ctx(world: &World, entity: Entity, from: Self, to: Self) -> bool {
if !Self::can_transition(from, to) {
return false;
}
// Additional validation using world state
world.get::<SomeComponent>(entity).is_some()
}
}
bevy_fsm uses two derive macros from bevy_enum_event:
#[derive(EnumEvent)] - Generates variant-specific event types in a modulename::Variant hierarchy#[derive(FSMState)] - Implements FSM-specific trigger methods for Enter/Exit/Transition eventsTogether they enable:
use bevy_enum_event::{EnumEvent, FSMState};
#[derive(Component, EnumEvent, FSMState, Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum BlockFSM {
Tile, // Generates blockfsm::Tile event type
Loose, // Generates blockfsm::Loose event type
Disabled // Generates blockfsm::Disabled event type
}
impl FSMState for BlockFSM {}
// Use with Enter/Exit wrappers:
fn on_tile_enter(enter: On<Enter<blockfsm::Tile>>, ...) { }
fn on_tile_exit(exit: On<Exit<blockfsm::Tile>>, ...) { }
The easiest way to register an FSM is with FSMPlugin:
use bevy_fsm::FSMPlugin;
fn plugin(app: &mut App) {
// Automatically registers apply_state_request and on_fsm_added observers
app.add_plugins(FSMPlugin::<MyFSM>::default());
// Optional: Skip automatic on_fsm_added observer
app.add_plugins(FSMPlugin::<MyFSM>::new().ignore_fsm_addition());
}
Use the fsm_observer! macro to register variant-specific observers with automatic hierarchy organization:
use bevy_fsm::{fsm_observer, Enter};
fn on_enter_loose(trigger: On<Enter<blockfsm::Loose>>, mut commands: Commands) {
let entity = trigger.event().entity;
commands.entity(entity).insert(RigidBody::Dynamic);
}
fn plugin(app: &mut App) {
app.add_plugins(FSMPlugin::<BlockFSM>::default());
// Registers and organizes observers in entity hierarchy
fsm_observer!(app, BlockFSM, on_enter_loose);
fsm_observer!(app, BlockFSM, on_exit_loose);
}
If you prefer manual control, you can register observers directly:
use bevy_fsm::{apply_state_request, on_fsm_added};
// Handles state transition requests
app.world_mut().add_observer(apply_state_request::<MyFSM>);
// Triggers enter events when FSM is first added to entity
app.world_mut().add_observer(on_fsm_added::<MyFSM>);
// Variant-specific observers
app.world_mut().add_observer(on_enter_loose);
You can also observe generic events if you need runtime state checking:
fn on_any_enter(
trigger: On<Enter<BlockFSM>>,
mut commands: Commands,
) {
let state = trigger.event().state;
match state {
BlockFSM::Tile => { /* handle tile */ },
BlockFSM::Loose => { /* handle loose */ },
_ => {}
}
}
FSMOverride allows per-entity transition control with a priority-based system: config takes precedence over FSMTransition rules.
with_rules() is used, else deniedwith_rules() is used, else accepteduse bevy_fsm::FSMOverride;
// Example 1: Force allow specific transition (override FSMTransition)
commands.entity(special_npc).insert((
AnimationState::Idling,
FSMOverride::whitelist([
(AnimationState::Idling, AnimationState::Flying), // Normally forbidden
]),
));
// Idling->Flying: ACCEPT (whitelisted, config wins)
// Idling->Walking: DENY (not whitelisted)
// Example 2: Whitelist + fallback to FSMTransition for others
commands.entity(npc).insert((
AnimationState::Idling,
FSMOverride::whitelist([
(AnimationState::Idling, AnimationState::Flying), // Force allow
]).with_rules(),
));
// Idling->Flying: ACCEPT (whitelisted, config wins)
// Idling->Walking: Check FSMTransition (not whitelisted, rules fill gap)
// Example 3: Force deny specific transition
commands.entity(injured_npc).insert((
AnimationState::Idling,
FSMOverride::blacklist([
(AnimationState::Idling, AnimationState::Running), // Prevent running
]),
));
// Idling->Running: DENY (blacklisted, config wins)
// Idling->Walking: ACCEPT (not blacklisted)
// Example 4: Blacklist + fallback to FSMTransition for others
commands.entity(npc).insert((
AnimationState::Idling,
FSMOverride::blacklist([
(AnimationState::Idling, AnimationState::Running),
]).with_rules(),
));
// Idling->Running: DENY (blacklisted, config wins)
// Idling->Walking: Check FSMTransition (not blacklisted, rules fill gap)
whitelist([...]): Only listed transitions pass immediately. Others denied unless with_rules() is used.blacklist([...]): Listed transitions denied immediately. Others allowed unless with_rules() is used.allow_all(): All transitions pass (bypass FSMTransition unless with_rules() is used).deny_all(): All transitions denied (immutable state).The with_rules() method enables FSMTransition validation for transitions NOT decided by the config:
// Without with_rules: whitelist is sole authority
FSMOverride::whitelist([(State::A, State::C)])
// A->C: ACCEPT (whitelisted)
// A->B: DENY (not whitelisted)
// With with_rules: whitelist wins, FSMTransition fills gaps
FSMOverride::whitelist([(State::A, State::C)]).with_rules()
// A->C: ACCEPT (whitelisted, FSMTransition NOT checked)
// A->B: Check FSMTransition (not whitelisted, rules enabled)
Use world state in transition validation:
impl FSMTransition for AnimationState {
fn can_transition_ctx(world: &World, entity: Entity, from: Self, to: Self) -> bool {
if !Self::can_transition(from, to) {
return false;
}
// Verify animation exists for target state
if let Some(animation) = world.get::<SpriteAnimation>(entity) {
animation.has_state(to)
} else {
false
}
}
}
Each FSM generates several event types. All transition events implement EntityEvent and contain an entity field to identify the target entity:
StateChangeRequest<S>: Request to change an entity's state (contains entity and next fields)Enter<S>: Generic enter event (contains entity and state fields)Exit<S>: Generic exit event (contains entity and state fields)Transition<S, S>: Generic transition event (contains entity, from, and to fields)The states themselves generate standard events. They are usually unit events without data.
modulename::Variant: Type-safe variant event types (used with Enter<T> and Exit<T> wrappers)In observer functions, access the entity via trigger.event().entity.
When a state change is requested:
apply_state_request observer validates the transitionExit<S> (generic) and Exit<modulename::Variant> (type-safe)Transition<S, S> with from and to fieldsEnter<S> (generic) and Enter<modulename::Variant> (type-safe)When an FSM component is first added:
on_fsm_added observer detects the new componentcan_transitioncan_transition_ctx) for world-dependent rulesEnter<lifefsm::Dying>)bevy_fsm when using variant-specific observersObserver parameter type: Change Trigger<Event> to On<Event>
// Old (Bevy 0.16):
fn my_observer(trigger: Trigger<Enter<MyState>>) { }
// New (Bevy 0.17):
fn my_observer(trigger: On<Enter<MyState>>) { }
Accessing the target entity: Change trigger.target() to trigger.event().entity
// Old (Bevy 0.16):
let entity = trigger.target();
// New (Bevy 0.17):
let entity = trigger.event().entity;
Triggering events: Use trigger() instead of trigger_targets(), and include the entity in the event struct
// Old (Bevy 0.16):
commands.trigger_targets(
StateChangeRequest { next: MyState::NewState },
entity
);
// New (Bevy 0.17):
commands.trigger(
StateChangeRequest { entity, next: MyState::NewState }
);
WARNING: When an FSM component is added during entity spawn, the initial Enter event fires in the same frame, before the entity is fully initialized.
let entity = commands.spawn((
LifeFSM::Alive, // Enter event fires immediately!
Health::new(100),
// Other components...
)).id();
When this spawn occurs:
on_fsm_added observer fires immediatelyEnter<life_fsm::Alive> event is triggeredConsider using ignore_fsm_addition() if you don't need initial Enter events:
app.add_plugins(FSMPlugin::<LifeFSM>::new().ignore_fsm_addition());
use bevy_fsm::{FSMPlugin, fsm_observer};
#[test]
fn test_state_transition() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
// Register FSM using FSMPlugin
app.add_plugins(FSMPlugin::<LifeFSM>::default());
fsm_observer!(app, LifeFSM, on_dying);
// Spawn entity with initial state
let entity = app.world_mut().spawn(LifeFSM::Alive).id();
app.update(); // Triggers on_fsm_added
// Request transition
app.world_mut().commands().trigger(
StateChangeRequest::<LifeFSM> { entity, next: LifeFSM::Dying },
);
app.update();
// Verify transition occurred
assert_eq!(*app.world().get::<LifeFSM>(entity).unwrap(), LifeFSM::Dying);
}
bevy_fsm/
├── src/lib.rs # Core traits and observer functions
├── Cargo.toml
└── README.md
bevy_enum_event/ # Separate crate (dependency)
├── src/lib.rs # EnumEvent and FSMState derive macros
├── Cargo.toml
└── README.md
Note: bevy_fsm depends on bevy_enum_event with the fsm feature enabled.
Licensed under either of:
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.