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

Events: messages between systems

A system often needs to tell other systems that something happened: a unit died, a building finished, the player scored. Send an event and let whoever cares read it, instead of having systems call each other or poll flags.

#![allow(unused)]
fn main() {
struct Health(i32);
struct Died {
    who: Entity,
}

/// Sends one `Died` per entity out of health, from inside a query.
fn deaths(ctx: &mut Ctx) {
    ctx.world.query::<(Entity, &Health)>().for_each(|(e, h)| {
        if h.0 <= 0 {
            ctx.events.send(Died { who: e });
        }
    });
}

/// Any later system (or this one next tick) reads them, once each.
fn funerals(ctx: &mut Ctx) {
    for d in ctx.events.read::<Died>() {
        ctx.world.despawn(d.who);
    }
}
}
struct Hp { int value; };
struct Died { crabby::entity who; };

// Sends one Died per entity out of health, from inside a loop.
static void deaths(crabby::ctx& ctx) {
    ctx.world().each([&](crabby::entity e, const Hp& hp) {
        if (hp.value <= 0) ctx.send(Died{e});
    });
}

// Any later system (or this one next tick) reads them, once each.
static void funerals(crabby::ctx& ctx) {
    for (const Died& d : ctx.read<Died>()) ctx.despawn_later(d.who);
}

Any type can be an event: a Rust type, or a plain C++ struct. In C, register the event struct like a component and pass its id. Events can be at most 16-byte aligned.

Who sees what

Every system has its own read position. read returns the events it hasn’t seen yet, oldest first, and the next read returns only newer ones. So:

  • A system after the sender, in the same tick, sees the events right away.
  • A system before the sender sees them on its next run, the next tick.
  • A system that runs every tick (or every frame) sees every event exactly once. With sim_system_every(n, ..), it sees everything sent since its last run.
  • Events from setup reach the first tick.

Within simulation systems this is deterministic: the same sends in the same order give the same reads, whatever the frame rate.

Events are dropped once every system has had its chance: when they’re at least one frame old and older than the slowest sim_system_every. You never clear them yourself. peek shows everything still kept without moving the read position.

Sending from queries

ctx.events is a separate field from ctx.world, so you can send inside a query and read while changing the world (as above). C++ ctx.send works inside world().each but not each_par, which runs on many threads: collect there, then send.

Tools and agents see events

Reflect an event type and the control endpoint and the MCP server can collect it: an agent subscribes, runs ticks, and gets every event with the tick it was sent in, as JSON. In C and C++, events whose type is reflected (described with its fields) show up the same way.

#![allow(unused)]
fn main() {
#[derive(serde::Serialize)]
struct Scored {
    points: u32,
}

fn setup_tools(ctx: &mut Ctx) {
    // Tools (the control endpoint, MCP) can now collect Scored events as JSON.
    ctx.events.reflect::<Scored>();
}
}
            // Described with its fields: tools can collect Died events as JSON.
            ctx.world().reflect<Died>({crabby::field("who", &Died::who)});

Collecting costs nothing until a tool subscribes.

Cost

Sending is a push onto a vector, and reading returns a slice of it: about 4 ns per event sent, read and dropped (100 000 events per tick cost 0.43 ms, cargo run --release -p ponko_bench -- events). Steady state allocates nothing.

Events aren’t saved with the world (world.save); a save between ticks drops the ones in flight.