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

Observers

Observers are pebble’s other communication primitive, alongside Events — subscription-based instead of polled, and dispatched the same tick they’re triggered, not next tick.

Register one on the app:

struct Ping(u32);

fn on_ping(trigger: Trigger<Ping>, mut score: Write<Score>) {
    score.0 += trigger.0;
}

app.add_observer(on_ping)

An observer function’s first parameter is always Trigger<E> — it Derefs straight to E, so trigger.0/trigger.some_field reads through directly. Every other parameter is an ordinary SystemParam, same as a ordinary system.

Fire one via Commands::trigger:

fn fire(mut commands: Commands) {
    commands.trigger(Ping(3));
}

Triggers are queued the same way entity spawns are, and dispatched at the same point in the tick: once the current stage finishes running and commands sync — so an observer registered for Ping runs before the end of the stage that triggered it, not one tick later. Multiple observers can be registered for the same event type; all of them run.

Why multiple observers on one trigger is the actual point

The useful case isn’t one observer — it’s several unrelated ones reacting to the same moment without knowing about each other. Say an enemy dies: something should add score, something should roll loot, something should kick off a screen shake. Those three concerns have no reason to live in the same system, or even know the others exist:

struct EnemyDied { position: glam::Vec3, value: u32 }

fn award_score(trigger: Trigger<EnemyDied>, mut score: Write<Score>) {
    score.0 += trigger.value;
}

fn spawn_loot(trigger: Trigger<EnemyDied>, mut commands: Commands) {
    commands.spawn((Position(trigger.position), Loot));
}

fn trigger_screen_shake(trigger: Trigger<EnemyDied>, mut shake: Write<ScreenShake>) {
    shake.0 = 0.3;
}

app.add_observer(award_score)
    .add_observer(spawn_loot)
    .add_observer(trigger_screen_shake)

The system that actually detects the death doesn’t call any of these directly — it just triggers the fact that happened:

use pebble::ecs::Entity;

fn check_deaths(mut q: Query<(Entity, &Health, &Value, &Position)>, mut commands: Commands) {
    for (entity, health, value, pos) in q.iter() {
        if health.0 <= 0 {
            commands.trigger(EnemyDied { position: pos.0, value: value.0 });
            commands.despawn(entity);
        }
    }
}

(See Queries, Commands, and Entities for why Entity has to be named explicitly in the query to get it back out.)

Add a fourth reaction later (an achievement check, a kill-streak counter) by registering one more observer — check_deaths never changes. This is the same shape Events gives you for many-to-many communication, but with a guarantee events don’t make: every one of these three observers has already run, and their effects (like award_score’s mutation) are visible, before the stage that triggered the death finishes — so a UI system later in that same stage already sees the updated score, not one tick stale.

Events vs. Observers

Use Events when several systems might poll for something whenever convenient, and it’s fine if a couple ticks pass before someone reads it. Use Observers when you need a guaranteed, same-tick reaction — the trigger and its handling should feel like one atomic step, as in the death example above.