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, Stages, and Resources

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 the next chapter. This chapter is about the other two: resources, and 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
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. The Asset Pipeline and Handles covers why they’re split into two.

Within one stage, systems run in the order they were registered, unless you impose an explicit ordering constraint — not covered in this book; see the System trait’s docs for before/after.

Resources: singleton state

A resource is any hecs::Component type with exactly one instance, stored in the ECS world rather than on an entity:

app.add_resource(MyConfig { volume: 0.8 });

fn my_system(config: Res<MyConfig>) {
    println!("{}", config.volume);
}

Res<T>/ResMut<T> borrow it immutably/mutably for the duration of the system call — the same borrow-checking rules as RefCell apply across the whole tick, so two systems in the same stage both wanting ResMut<T> is fine (they run sequentially), but you can’t stash a Res<T> guard somewhere and read it later.

What happens when a resource isn’t there yet

A bare Res<T>/ResMut<T> is a hard requirement: before a system with one runs, Pebble checks that T actually exists. What happens if it doesn’t depends on whether anything has declared it will eventually provide T:

  • Something declared it (a LazyResource plugin, an async graphics backend) — the system is silently skipped this pass and retried next tick. No error; this is the expected shape of “constructed asynchronously.”
  • Nothing declared itApp panics immediately, naming both the system and the missing resource, with a hint pointing at the fix (usually a missing app.add_resource(...) or a missing plugin).

This is why build() runs its own pre-flight pass (see previous chapter): it applies exactly this check to every system in every stage before run() starts, so a missing-resource mistake becomes one clear panic at startup instead of a surprise several ticks in.

When a resource is legitimately optional — not “not ready yet,” but “may never exist, and that’s fine” — use Option<Res<T>> instead. It never panics or waits; the system just receives None and can skip its own work:

fn maybe_render(backend: Option<Res<WGPUBackend>>) {
    let Some(backend) = backend else { return }; // backend not ready yet, try again next tick
    // ...
}

Run once

There’s no dedicated “Startup” stage (see the end of the previous chapter) — instead, .once() turns “have I already done this” into the system’s own return value:

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.once());

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 above: a bare Res<T> parameter inside a .once() system is still checked (wait if declared, panic if not) before the function body ever runs.

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 above — the condition is trusted to gate correctly, so a bare Res<T> inside it is never checked independently.