Bevy Scheduling Guide

Complete reference for Bevy's scheduling system — schedules, ordering, sets, run conditions, and states.

Built-in Schedules

Bevy runs these schedules in a fixed order each frame:

Main Schedules (Run Every Frame)

ScheduleWhen It RunsTypical Use
FirstVery start of each frameInternal engine bookkeeping, time updates
PreUpdateBefore UpdateEngine-level preprocessing (input collection, UI focus)
UpdateMain frame updateYour game logic goes here
PostUpdateAfter UpdateEngine-level postprocessing (transform propagation, rendering sync)
LastVery end of each frameCleanup, diagnostics

Fixed-Timestep Schedules (Run at Fixed Intervals)

These run at a fixed rate (default 64 Hz / every ~15.6ms), independent of frame rate. Multiple ticks can run per frame if the frame was slow, or zero ticks if the frame was fast.

ScheduleWhen It RunsTypical Use
FixedFirstStart of each fixed tickFixed-timestep bookkeeping
FixedPreUpdateBefore FixedUpdatePhysics preprocessing
FixedUpdateMain fixed tickPhysics, deterministic gameplay
FixedPostUpdateAfter FixedUpdatePhysics postprocessing, collision detection
FixedLastEnd of each fixed tickFixed-timestep cleanup

One-Time Schedules

ScheduleWhen It RunsTypical Use
StartupOnce, before the first UpdateSpawning initial entities, loading resources

State-Transition Schedules

ScheduleWhen It RunsTypical Use
OnEnter(state)Once, when entering a stateSetup for that state (spawn UI, load level)
OnExit(state)Once, when leaving a stateCleanup (despawn UI, save progress)
OnTransition { exited, entered }Once, during a state transitionLogic that depends on both the old and new state

Frame Order

Within a single frame, the execution order is:

Startup (first frame only)
  |
  v
First -> PreUpdate -> [FixedFirst -> FixedPreUpdate -> FixedUpdate -> FixedPostUpdate -> FixedLast]* -> Update -> PostUpdate -> Last
                       ^--- may run 0, 1, or many times per frame

System Ordering

Default: Parallel and Unordered

Systems in the same schedule run in parallel with no guaranteed order, as long as their data access does not conflict. This is Bevy's core performance advantage.

If two systems access the same data mutably, Bevy detects the conflict and runs them sequentially (in arbitrary order).

Explicit Ordering

#![allow(unused)]
fn main() {
App::new()
    .add_systems(Update, (
        // A runs before B
        system_a.before(system_b),

        // B runs after A (equivalent to above)
        system_b.after(system_a),

        // Chain: runs in order A -> B -> C
        (system_a, system_b, system_c).chain(),

        system_b,
        system_c,
    ))
}

.before() and .after() accept system names or system sets. .chain() is syntactic sugar for chaining .before()/.after() across a tuple of systems.

Ambiguity Detection

In debug builds, Bevy warns about "system order ambiguity" when two systems access the same data and have no explicit ordering. Fix by adding .before(), .after(), .chain(), or putting them in ordered sets.

System Sets

System sets group systems for shared ordering and run conditions.

Defining Sets

#![allow(unused)]
fn main() {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum GameSet {
    Input,
    Movement,
    Combat,
    UI,
}
}

Configuring Set Order

#![allow(unused)]
fn main() {
App::new()
    .configure_sets(Update, (
        GameSet::Input,
        GameSet::Movement.after(GameSet::Input),
        GameSet::Combat.after(GameSet::Movement),
        GameSet::UI.after(GameSet::Combat),
    ))
    // Or equivalently with chain:
    .configure_sets(Update, (
        GameSet::Input,
        GameSet::Movement,
        GameSet::Combat,
        GameSet::UI,
    ).chain())
}

Assigning Systems to Sets

#![allow(unused)]
fn main() {
App::new()
    .add_systems(Update, (
        read_keyboard.in_set(GameSet::Input),
        read_gamepad.in_set(GameSet::Input),
        move_player.in_set(GameSet::Movement),
        move_enemies.in_set(GameSet::Movement),
        deal_damage.in_set(GameSet::Combat),
        apply_damage.in_set(GameSet::Combat),
        update_hud.in_set(GameSet::UI),
    ))
}

Run Conditions on Sets

Apply a run condition to an entire set — all systems in the set are skipped if the condition is false:

#![allow(unused)]
fn main() {
App::new()
    .configure_sets(Update,
        GameSet::Combat.run_if(in_state(AppState::InGame)),
    )
}

Run Conditions

Run conditions are functions that return bool. If they return false, the system (or set) is skipped for that tick.

Built-in Run Conditions

#![allow(unused)]
fn main() {
use bevy::prelude::*;

// State-based
system.run_if(in_state(AppState::InGame))

// Resource-based
system.run_if(resource_exists::<Score>)
system.run_if(resource_equals(Paused(true)))
system.run_if(resource_changed::<Score>)
system.run_if(resource_added::<Score>)

// Event-based
system.run_if(on_event::<DamageEvent>)

// Time-based (from bevy::time)
system.run_if(on_timer(Duration::from_secs(2)))
system.run_if(on_real_timer(Duration::from_millis(500)))

// Logic combinators
system.run_if(in_state(AppState::InGame).and(resource_exists::<Player>))
system.run_if(in_state(AppState::Paused).or(in_state(AppState::MainMenu)))
system.run_if(not(in_state(AppState::Loading)))
}

Custom Run Conditions

A run condition is any system that returns bool:

#![allow(unused)]
fn main() {
fn has_living_enemies(query: Query<(), With<Enemy>>) -> bool {
    !query.is_empty()
}

fn player_is_alive(query: Query<&Health, With<Player>>) -> bool {
    query.iter().any(|h| h.0 > 0)
}

App::new()
    .add_systems(Update, (
        enemy_ai.run_if(has_living_enemies),
        game_over_check.run_if(not(player_is_alive)),
    ))
}

Combining Conditions

#![allow(unused)]
fn main() {
App::new()
    .add_systems(Update,
        combat_system
            .run_if(in_state(AppState::InGame))
            .run_if(has_living_enemies)
            // Multiple .run_if() = AND logic (all must be true)
    )
}

States

States control large-scale game flow: menus, loading, gameplay, pausing.

Defining States

#![allow(unused)]
fn main() {
#[derive(States, Debug, Clone, PartialEq, Eq, Hash, Default)]
enum AppState {
    #[default]
    MainMenu,
    Loading,
    InGame,
    Paused,
    GameOver,
}
}

Registering and Using States

#![allow(unused)]
fn main() {
App::new()
    .init_state::<AppState>()  // Starts at Default value (MainMenu)
    // OR:
    .insert_state(AppState::Loading)  // Start at a specific value

    // Systems that run once on state entry/exit
    .add_systems(OnEnter(AppState::InGame), setup_game_world)
    .add_systems(OnExit(AppState::InGame), despawn_game_world)

    // Systems that run every frame while in a state
    .add_systems(Update, (
        menu_ui.run_if(in_state(AppState::MainMenu)),
        gameplay.run_if(in_state(AppState::InGame)),
        pause_overlay.run_if(in_state(AppState::Paused)),
    ))
}

Transitioning Between States

#![allow(unused)]
fn main() {
fn handle_start_button(
    mut next_state: ResMut<NextState<AppState>>,
    interaction: Query<&Interaction, With<StartButton>>,
) {
    for interaction in &interaction {
        if *interaction == Interaction::Pressed {
            next_state.set(AppState::InGame);
        }
    }
}

fn handle_pause(
    mut next_state: ResMut<NextState<AppState>>,
    input: Res<ButtonInput<KeyCode>>,
    state: Res<State<AppState>>,
) {
    if input.just_pressed(KeyCode::Escape) {
        match state.get() {
            AppState::InGame => next_state.set(AppState::Paused),
            AppState::Paused => next_state.set(AppState::InGame),
            _ => {}
        }
    }
}
}

State transitions are applied during StateTransition (which runs between PreUpdate and Update). OnExit runs first, then OnTransition, then OnEnter.

Sub-States (Bevy 0.15+)

Sub-states only exist when their parent state has a specific value. When the parent leaves that value, the sub-state is removed entirely.

#![allow(unused)]
fn main() {
#[derive(SubStates, Debug, Clone, PartialEq, Eq, Hash, Default)]
#[source(AppState = AppState::InGame)]
enum GamePhase {
    #[default]
    Exploration,
    Combat,
    Cutscene,
}

App::new()
    .init_state::<AppState>()
    .add_sub_state::<GamePhase>()
    .add_systems(OnEnter(GamePhase::Combat), setup_combat_ui)
    .add_systems(OnExit(GamePhase::Combat), cleanup_combat_ui)
    .add_systems(Update, combat_tick.run_if(in_state(GamePhase::Combat)))
}

When AppState leaves InGame, GamePhase is automatically removed. When AppState re-enters InGame, GamePhase is re-initialized to its Default value.

Computed States (Bevy 0.15+)

Computed states derive their value from one or more other states. You cannot set them manually — they update automatically.

#![allow(unused)]
fn main() {
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
enum InCombat {
    Yes,
    No,
}

impl ComputedStates for InCombat {
    type SourceStates = (AppState, Option<GamePhase>);

    fn compute(sources: (AppState, Option<GamePhase>)) -> Option<Self> {
        match sources {
            (AppState::InGame, Some(GamePhase::Combat)) => Some(InCombat::Yes),
            (AppState::InGame, _) => Some(InCombat::No),
            _ => None, // State does not exist outside InGame
        }
    }
}

App::new()
    .init_state::<AppState>()
    .add_sub_state::<GamePhase>()
    .add_computed_state::<InCombat>()
    .add_systems(Update, show_combat_hud.run_if(in_state(InCombat::Yes)))
}