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:
| Stage | Purpose |
|---|---|
Startup | One-time initialization before the first tick (resources, GPU objects) |
PreUpdate | Before main logic (input, time, draining channels) |
Update | Main game logic |
PostUpdate | After main logic |
PreRender | Prepare render data, poll the backend |
AssetSync | Upload CPU assets to the GPU backend |
AssetSyncDeps | Upload assets that depend on other GPU assets |
Render | Issue draw calls |
PostRender | Present 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.