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:
| Stage | Purpose |
|---|---|
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. 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
LazyResourceplugin, 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 it —
Apppanics immediately, naming both the system and the missing resource, with a hint pointing at the fix (usually a missingapp.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.