Tags, Events, and Commands
The three patterns in this chapter all solve the same problem from different angles. One system needs to tell another system something, or one system needs to make a structural change that cannot happen while the iteration that prompted it still holds the world.
The event bus is for cross-system messages. World commands are for deferred mutations that need GPU access or have to wait for a specific point in the frame. Tags are not a separate subsystem in Nightshade today, they are markers expressed as zero-size components and queried through the normal flag-mask machinery.
The event bus
world.resources.event_bus is a VecDeque<Message> that systems push into and the game pops out of. The message variants cover input events and arbitrary app-defined payloads.
#![allow(unused)] fn main() { pub struct EventBus { pub messages: VecDeque<Message>, } pub enum Message { Input { event: InputEvent }, App { type_name: &'static str, payload: Box<dyn Any + Send + Sync>, }, } }
The trade-off is the boxed Any. Each app event allocates and pays a downcast at the consumer. The win is that the bus does not need to know about the app's event types at compile time. For games where event volume is low and latency is high (death notifications, door opens, inventory pickups), this cost is irrelevant. For high-frequency telemetry, the better fit is a dedicated typed queue inside Resources.
Publishing
Define a plain struct for each event type. Send it through publish_app_event.
#![allow(unused)] fn main() { pub struct EnemyDied { pub entity: Entity, pub position: Vec3, } pub struct ItemPickedUp { pub item_type: ItemType, pub quantity: u32, } fn combat_system(world: &mut World) { for enemy in world.core.query_entities(ENEMY | HEALTH) { let health = world.core.get_health(enemy).unwrap(); if health.current <= 0.0 { let position = world.core.get_global_transform(enemy) .map(|t| t.0.column(3).xyz()) .unwrap_or(Vec3::zeros()); publish_app_event(world, EnemyDied { entity: enemy, position, }); world.despawn_entities(&[enemy]); } } } }
Consuming
The game drains the bus during its frame loop and dispatches each message by type.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { while let Some(msg) = world.resources.event_bus.messages.pop_front() { match msg { Message::App { payload, .. } => { if let Some(died) = payload.downcast_ref::<EnemyDied>() { self.handle_enemy_death(world, died); } if let Some(pickup) = payload.downcast_ref::<ItemPickedUp>() { self.update_inventory(pickup); } } Message::Input { event } => { self.handle_input_event(event); } } } } }
The downcast pattern means several handlers can react to the same event without coupling. One system reads a DoorOpened to trigger a cutscene, another reads it to play a sound, neither knows the other exists.
#![allow(unused)] fn main() { publish_app_event(world, DoorOpened { door_id: 42 }); if let Some(door) = payload.downcast_ref::<DoorOpened>() { trigger_cutscene(world, door.door_id); } if let Some(door) = payload.downcast_ref::<DoorOpened>() { play_door_sound(world, door.door_id); } }
World commands
Commands are deferred operations. They go into world.resources.command_queue and are drained by the right subsystem at the right point in the frame. The two main reasons to defer are GPU access (texture uploads, HDR skybox loads, screenshot capture) and iteration safety (despawning entities mid-loop).
#![allow(unused)] fn main() { world.queue_command(WorldCommand::LoadTexture { name: "brick".to_string(), rgba_data: texture_bytes, width: 512, height: 512, }); world.queue_command(WorldCommand::DespawnRecursive { entity }); world.queue_command(WorldCommand::LoadHdrSkybox { hdr_data }); world.queue_command(WorldCommand::CaptureScreenshot { path: None }); }
Available commands
| Command | Description |
|---|---|
LoadTexture | Upload RGBA texture data to the GPU |
DespawnRecursive | Remove an entity and every descendant in its hierarchy |
LoadHdrSkybox | Load an HDR environment map |
CaptureScreenshot | Save the next frame to a PNG |
Immediate vs deferred
Not every structural change needs to be deferred. Operations that do not touch the GPU and are not running inside a borrowing iteration can run inline.
#![allow(unused)] fn main() { // inline, happens immediately world.despawn_entities(&[entity]); despawn_recursive_immediate(world, entity); // deferred, happens at the next command drain point world.queue_command(WorldCommand::DespawnRecursive { entity }); }
The rule is the simple one. If the call would invalidate an iteration the current code is inside, queue it. If it would touch the GPU from a non-render system, queue it. Otherwise call directly.
The State trait event hook
The State trait has a dedicated handle_event method called once per message after run_systems. It is the cleanest place to handle events that drive state transitions.
#![allow(unused)] fn main() { fn handle_event(&mut self, world: &mut World, message: &Message) { match message { Message::App { payload, .. } => { if let Some(event) = payload.downcast_ref::<MyEvent>() { self.process_event(world, event); } } _ => {} } } }
The engine dispatches each message exactly once, in order, then drains the bus.
Input events
Input events travel on the same bus.
#![allow(unused)] fn main() { pub enum InputEvent { KeyboardInput { key_code: u32, state: KeyState }, GamepadConnected { gamepad_id: usize }, GamepadDisconnected { gamepad_id: usize }, } pub enum KeyState { Pressed, Released, } }
For polling-style input (held keys, mouse position) the snapshot in world.resources.input is the right shape. The event variants are for transitions that matter exactly once. Gamepad connect and disconnect, key repeat, IME input.
Practical notes
- Keep event payloads small. The
Box<dyn Any>allocation cost is per-message. - Drain the bus every frame. The queue is unbounded and will accumulate stale messages otherwise.
- Avoid circular events. System A reacting to event B by emitting event B will not terminate.
- Prefer immediate calls for pure ECS work that does not need to be deferred. The command queue exists for GPU access and iteration safety, not as the default.