3 releases (breaking)
| 0.3.0 | Feb 18, 2026 |
|---|---|
| 0.2.0 | Jan 20, 2026 |
| 0.1.0 | Oct 25, 2025 |
#188 in Game dev
34 downloads per month
78KB
1K
SLoC
bevy_fsm
Observer-driven finite state machine framework for Bevy ECS.
Bevy Compatibility
| Bevy | bevy_fsm |
|---|---|
| 0.18 | 0.3 |
| 0.17 | 0.2 |
| 0.16 | 0.1 |
Features
- Enum-based states: Keep your states as simple enum variants
- Observer-driven: React to state changes via Bevy observers
- Variant-specific events: No runtime state checks needed in observers
- Flexible validation: Per-entity and per-type transition rules
- Clean API: FSMPlugin for automatic setup
- Initial state support: Automatic enter events when FSM components are added
- Organized hierarchy: Observers automatically organized in entity hierarchy
Quick Start
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) {
app.add_plugins(FSMPlugin::<LifeFSM>::default());
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 {
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) {
commands.entity(trigger.entity).insert(DyingAnimation);
}
fn on_exit_alive(trigger: On<Exit<life_fsm::Alive>>) {
println!("Entity {} was unalived.", trigger.entity);
}
fn on_transition_dying_dead(
trigger: On<Transition<life_fsm::Dying, life_fsm::Alive>>,
mut commands: Commands
) {
println!("Entity {} was saved from the brink of death.", trigger.entity);
}
Core Concepts
FSMTransition Trait
Implement this trait to define which state transitions are valid:
impl FSMTransition for MyFSM {
fn can_transition(from: Self, to: Self) -> bool {
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;
}
world.get::<SomeComponent>(entity).is_some()
}
}
EnumEvent and FSMState Derives
Use these derive macros to generate variant-specific events:
#[derive(EnumEvent)]- Generates variant-specific event types#[derive(FSMState)]- Implements FSM-specific trigger methods
use bevy::prelude::*;
use bevy_fsm::{EnumEvent, FSMState, FSMTransition, Enter, Exit};
#[derive(Component, EnumEvent, FSMTransition, FSMState, Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum BlockFSM {
Tile,
Loose,
Disabled
}
// FSMTransition derive provides "allow all" behavior
// For custom rules, skip the derive and implement manually
fn on_tile_enter(enter: On<Enter<block_fsm::Tile>>, /* ... */) { }
fn on_tile_exit(exit: On<Exit<block_fsm::Tile>>, /* ... */) { }
FSMPlugin - Automatic Setup
use bevy_fsm::FSMPlugin;
fn plugin(app: &mut App) {
app.add_plugins(FSMPlugin::<MyFSM>::default());
// Optional: Skip automatic on_fsm_added observer
app.add_plugins(FSMPlugin::<MyFSM>::new().ignore_fsm_addition());
}
fsm_observer! Macro
Register variant-specific observers with automatic hierarchy organization:
use bevy_fsm::{fsm_observer, Enter};
fn on_enter_loose(trigger: On<Enter<block_fsm::Loose>>, mut commands: Commands) {
commands.entity(trigger.entity).insert(RigidBody::Dynamic);
}
fn plugin(app: &mut App) {
app.add_plugins(FSMPlugin::<BlockFSM>::default());
fsm_observer!(app, BlockFSM, on_enter_loose);
fsm_observer!(app, BlockFSM, on_exit_loose);
}
Manual Observer Registration
use bevy_fsm::{apply_state_request, on_fsm_added};
app.world_mut().add_observer(apply_state_request::<MyFSM>);
app.world_mut().add_observer(on_fsm_added::<MyFSM>);
app.world_mut().add_observer(on_enter_loose);
Generic Event Observers
Observe generic events for 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 */ },
_ => {}
}
}
Advanced Features
Per-Entity Configuration with Priority Model
FSMOverride allows per-entity transition control with a priority-based system.
Priority: Config Wins, Rules Fill Gaps
- Whitelist: Transitions ON the list are immediately accepted
- Blacklist: Transitions ON the list are immediately denied
- Transitions NOT decided by config use
FSMTransitionrules (ifwith_rules())
use bevy_fsm::FSMOverride;
// Force allow specific transition
commands.entity(special_npc).insert((
AnimationState::Idling,
FSMOverride::whitelist([
(AnimationState::Idling, AnimationState::Flying),
]),
));
// Whitelist + fallback to FSMTransition
commands.entity(npc).insert((
AnimationState::Idling,
FSMOverride::whitelist([
(AnimationState::Idling, AnimationState::Flying),
]).with_rules(),
));
// Force deny specific transition
commands.entity(injured_npc).insert((
AnimationState::Idling,
FSMOverride::blacklist([
(AnimationState::Idling, AnimationState::Running),
]),
));
FSMOverride Modes
whitelist([...]): Only listed transitions pass immediatelyblacklist([...]): Listed transitions denied immediatelyallow_all(): All transitions pass (bypass FSMTransition unlesswith_rules())deny_all(): All transitions denied (immutable state)
Context-Aware Validation
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;
}
if let Some(animation) = world.get::<SpriteAnimation>(entity) {
animation.has_state(to)
} else {
false
}
}
}
Event Types
All transition events implement EntityEvent and contain an entity field:
StateChangeRequest<S>: Request to change state (entity,next)Enter<S>: Enter event (entity,state)Exit<S>: Exit event (entity,state)Transition<S, S>: Transition event (entity,from,to)
Access the entity via trigger.entity (using Deref).
How It Works
When a state change is requested:
apply_state_requestobserver validates the transition- Exit events are triggered
- Transition event is triggered
- State component is updated
- Enter events are triggered
When an FSM component is first added:
on_fsm_addedobserver detects the new component- Enter events are triggered for the initial state
Important: Timing of Initial Enter Events
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),
)).id();
Consider using ignore_fsm_addition() if you don't need initial Enter events:
app.add_plugins(FSMPlugin::<LifeFSM>::new().ignore_fsm_addition());
Testing
use bevy_fsm::{FSMPlugin, fsm_observer};
#[test]
fn test_state_transition() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(FSMPlugin::<LifeFSM>::default());
fsm_observer!(app, LifeFSM, on_dying);
let entity = app.world_mut().spawn(LifeFSM::Alive).id();
app.update();
app.world_mut().commands().trigger(
StateChangeRequest::<LifeFSM> { entity, next: LifeFSM::Dying },
);
app.update();
assert_eq!(*app.world().get::<LifeFSM>(entity).unwrap(), LifeFSM::Dying);
}
License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT License (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Contribution
Contributions are welcome! This crate is part of the MolecularSadism game development libraries.
Dependencies
~24MB
~513K SLoC