Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Systems and Stages

Systems are plain functions

A system is any function whose parameters are all SystemParams. Pebble inspects the signature, fetches each parameter, and calls the function — no registration macro, no trait to implement by hand:

fn move_system(
    time: Res<Time>,              // immutable resource borrow
    mut rb: ResMut<RigidBodies>,  // mutable resource borrow
    mut q: Query<&mut Transform>, // ECS query
    mut cmd: Commands,            // deferred world mutations
) {
    // ...
}

app.add_system(SystemStage::Update, move_system);

Query and Commands are covered in Queries, Commands, and Entities; Res/ResMut in Resources. This page is about the other axis: when a system runs at all.

Stages: when a system runs

Every system is registered against a SystemStage, which determines its place in the fixed per-tick order:

StagePurpose
StartupOne-time initialization before the first tick (resources, GPU objects)
PreUpdateBefore main logic (input, time, draining channels)
UpdateMain game logic
PostUpdateAfter main logic
PreRenderPrepare render data, poll the backend
AssetSyncUpload CPU assets to the GPU backend
AssetSyncDepsUpload assets that depend on other GPU assets
RenderIssue draw calls
PostRenderPresent the frame

AssetSync/AssetSyncDeps are special: they run to convergence (repeated until a full pass produces nothing new) at the very front of every tick, and again after every other stage — so newly queued asset work is drained immediately instead of waiting for next tick’s front pass. See The Asset Pipeline and Handles for why they’re split into two.

Registering several systems at once

app.add_systems(SystemStage::Update, (system_a, system_b, system_c));

Ordering within a stage

Systems in the same stage otherwise run in registration order — .before()/.after() impose an explicit constraint instead:

app.add_systems(SystemStage::Update, (
    apply_input,
    move_player.after(apply_input),
));

Run once

A function that returns Option<()> is automatically a once-system — no wrapper needed:

fn spawn_scene(mut commands: Commands, config: Option<Res<MyConfig>>) -> Option<()> {
    let config = config?; // not ready yet — try again next tick
    commands.spawn(/* ... */);
    Some(()) // done — never runs again
}

app.add_system(SystemStage::PreUpdate, spawn_scene);

Return None to mean “call me again next tick”; return Some(()) to mean “done” — the system is retired permanently, no matter how many ticks that took. It composes with the hard-requirement check described in Resources: a bare Res<T> parameter is still checked (wait if declared, panic if not) before the function body ever runs.

SystemStage::Startup is the natural home for one-time initialization — resources that need the GPU device, initial entity spawning, etc. Like any other stage, a Startup system that returns Option<()> retries every startup pass until it succeeds. One-off GPU resources follow exactly this pattern — see Custom GPU Resources for the full walkthrough.

Run conditions

.run_if::<C>() gates a system (or a whole tuple passed to add_systems) behind a RunCondition, re-checked every tick — its SystemParams are only fetched, and its body only runs, when the condition holds:

app.add_systems(
    SystemStage::Update,
    expensive_diagnostic.run_if::<ResourceExists<DebugOverlay>>(),
);

Built-in conditions: ResourceExists<T>, plus And<A, B>/Or<A, B> for combining conditions; implement RunCondition yourself for anything else. A system wrapped in .run_if is fully exempt from the hard-requirement panic described in Resources — the condition is trusted to gate correctly, so a bare Res<T> inside it is never checked independently.