Event System
The event bus is a FIFO queue of messages shared across systems. One system publishes, another consumes, and neither has to know about the other. Events are the alternative to direct function calls when the producer and consumer should not be coupled, when one event should trigger several handlers, or when the action needs to be deferred to a later point in the frame.
EventBus
The bus lives on world.resources.event_bus. It is a VecDeque<Message> plus the Message enum, which has two variants. Input is reserved for engine-generated input messages. App is a type-erased payload for application events.
#![allow(unused)] fn main() { pub struct EventBus { pub messages: VecDeque<Message>, } pub enum Message { Input(InputMessage), App(Box<dyn Any + Send + Sync>), } }
Defining Events
An event is any struct that implements Send + Sync. There is no trait to derive and no registration step. The struct holds the data the consumer will need.
#![allow(unused)] fn main() { pub struct EnemyDied { pub entity: Entity, pub position: Vec3, pub killer: Option<Entity>, } pub struct PlayerLeveledUp { pub new_level: u32, pub skills_unlocked: Vec<String>, } pub struct ItemPickedUp { pub item_type: ItemType, pub quantity: u32, } }
Publishing
publish_app_event boxes the event and pushes it onto the queue. The example below detects death in a combat system, publishes EnemyDied with the relevant context, then despawns the entity.
#![allow(unused)] fn main() { fn combat_system(world: &mut World, game: &mut GameState) { for enemy in world.core.query_entities(ENEMY | HEALTH) { let health = world.core.get_health(enemy).unwrap(); if health.current <= 0 { let position = world.core.get_global_transform(enemy) .map(|t| t.matrix.column(3).xyz()) .unwrap_or(Vec3::zeros()); publish_app_event(world, EnemyDied { entity: enemy, position, killer: game.last_attacker, }); world.despawn_entities(&[enemy]); } } } }
Consuming
Consumers pop messages off the queue and match on the variant. For Message::App, downcast against each event type that the consumer cares about. The order is FIFO. The first event published is the first one popped.
#![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(event) => { if let Some(died) = event.downcast_ref::<EnemyDied>() { self.handle_enemy_death(world, died); } if let Some(levelup) = event.downcast_ref::<PlayerLeveledUp>() { self.show_levelup_ui(levelup); } if let Some(pickup) = event.downcast_ref::<ItemPickedUp>() { self.update_inventory(pickup); } } Message::Input(input_msg) => { self.handle_input_message(input_msg); } } } } fn handle_enemy_death(&mut self, world: &mut World, event: &EnemyDied) { spawn_explosion_effect(world, event.position); self.score += 100; self.enemies_killed += 1; } }
Patterns
One Publisher, Many Consumers
A single published event can be matched against by any number of consumers, each on its own pass through the queue. The publisher does not know how many listeners exist.
#![allow(unused)] fn main() { publish_app_event(world, DoorOpened { door_id: 42 }); }
#![allow(unused)] fn main() { if let Some(door) = event.downcast_ref::<DoorOpened>() { trigger_cutscene(world, door.door_id); } if let Some(door) = event.downcast_ref::<DoorOpened>() { play_door_sound(world, door.door_id); } if let Some(door) = event.downcast_ref::<DoorOpened>() { update_minimap(world, door.door_id); } }
The downside is that the queue is drained once per frame, so consumers that share a drain loop see each event exactly once. For one-to-many fan-out, the consumers need to live inside the same drain or the event has to be republished.
Deferred Actions
Events delay work to a later frame. The struct stores its own countdown, and the system retains pending events until the countdown reaches zero, at which point it spawns the actual thing.
#![allow(unused)] fn main() { pub struct SpawnEnemy { pub enemy_type: EnemyType, pub position: Vec3, pub delay_frames: u32, } fn wave_spawner_system(world: &mut World, pending: &mut Vec<SpawnEnemy>) { pending.retain_mut(|spawn| { if spawn.delay_frames == 0 { spawn_enemy(world, spawn.enemy_type, spawn.position); false } else { spawn.delay_frames -= 1; true } }); } }
Request-Response
The event bus is fire-and-forget. For request-response, where the caller needs the result of the work, use a pair of structs and pass them through a shared buffer rather than the event bus.
#![allow(unused)] fn main() { pub struct DamageRequest { pub target: Entity, pub amount: f32, pub damage_type: DamageType, } pub struct DamageResult { pub target: Entity, pub actual_damage: f32, pub killed: bool, } fn damage_system(world: &mut World, requests: &[DamageRequest]) -> Vec<DamageResult> { requests.iter().map(|req| { let actual = apply_damage(world, req.target, req.amount, req.damage_type); let killed = is_dead(world, req.target); DamageResult { target: req.target, actual_damage: actual, killed, } }).collect() } }
Input Messages
The bus also carries engine-generated input messages. These are the same input transitions exposed through the polling API, available as events for code that prefers the event style.
#![allow(unused)] fn main() { pub enum InputMessage { KeyPressed(KeyCode), KeyReleased(KeyCode), MouseMoved { x: f32, y: f32 }, MouseButton { button: MouseButton, pressed: bool }, GamepadButton { button: GamepadButton, pressed: bool }, } }
Constraints
Keep events small. The payload is boxed and downcast, so a large struct costs more to publish and more to read. Process events every frame, since the queue grows unbounded if drained slowly. Watch for cycles. An event A whose handler publishes event B whose handler publishes event A is an infinite loop that fills memory and stalls the frame.