Making Games in Rust - Part 12 - Better Code with Events
Sébastien Belzile
Posted on January 31, 2022
Up until now, we have been killing monsters and players by despawning their entity. This works fine, but we might want more than that: adding an explosion, render blood, play a death sound, etc.
This can easily be done by adding more code to our current "death" systems, but this would also mean more dependencies to our system's function, and a bigger code block that will keep growing as we add more "reactions".
Furthermore, we might also want to add more "causes of death": our player can die from touching a monster, but monsters could shoot bullets too. Those bullets could kill our player as well. Implementing this would mean that we should include all the death reactions dependencies to a system that detects bullet collisions with our player.
I hope you see where this is going: the way we coded the death of our player and of our monsters do not scale very well... Our code would be better if we could separate an event occurrence (bullet hitting a monster or a player) from an event reaction (despawning the monster or the player).
In Bevy, events are there for this reason. This article explains the basis of events and refactors part of our code to include them in the design.
Events in Bevy
You can trigger an event in a system with an EventWriter
:
mut my_event_writter: EventWriter<MyEvent>,
// ...
my_event_writter.send(event);
You can react to events in your systems with an EventReader
:
fn on_my_event(
mut my_event_reader: EventReader<MyEvent>
) {
for event in my_event_reader.iter() {
// do something
}
}
An event in Bevy is a plain old rust struct:
pub struct MyEvent {
entity: Entity,
some_other_prop: f32,
}
It is important to note that events must be registered on your AppBuilder
:
.add_event::<MyEvent>()
Now, let's see them in action.
Monsters Should Shoot Bullets Too
If monsters were to shoot bullet as well, it would be a good idea to isolate bullet creation to it's own system. One way to do this, would be to add a "bullet fired" event:
pub struct BulletFiredEvent {
pub position: Vec2,
pub direction: GameDirection,
}
A bullet fired event needs to have a position attribute (where the bullet is shot from), and a direction (left or right).
To shoot our bullet, we could rewrite our fire controller to trigger the event:
pub fn fire_controller(
keyboard_input: Res<Input<KeyCode>>,
mut send_fire_event: EventWriter<BulletFiredEvent>,
players: Query<(&Player, &RigidBodyPosition), With<Player>>,
) {
if keyboard_input.just_pressed(KeyCode::Space) {
for (player, position) in players.iter() {
let event = BulletFiredEvent {
position: Vec2::new(position.position.translation.x, position.position.translation.y),
direction: player.facing_direction,
};
send_fire_event.send(event);
}
}
}
And react to it via an on_bullet_fired
system:
pub fn on_bullet_fired(
mut commands: Commands,
materials: Res<Materials>,
mut bullet_fired_events: EventReader<BulletFiredEvent>,
) {
for event in bullet_fired_events.iter() {
insert_bullet_at(&mut commands, &materials, event)
}
}
Don't forget to register the event before trying it:
.add_event::<BulletFiredEvent>()
A Player could have Multiple Lives
Players and monsters could have multiple lives.
We could easily add this behaviour with new events:
pub struct PlayerHitEvent {
entity: Entity,
}
pub struct PlayerDeathEvent {
entity: Entity,
}
We may then rewrite our death_by_height
system to trigger a death event:
fn death_by_height(
mut send_player_death: EventWriter<PlayerDeathEvent>,
players: Query<(Entity, &RigidBodyPosition), With<Player>>,
) {
for (entity, position) in players.iter() {
if position.position.translation.y < -1. {
send_player_death.send(PlayerDeathEvent { entity })
}
}
}
And the death_by_enemy
system to trigger a player hit event:
pub fn death_by_enemy(
mut send_player_hit: EventWriter<PlayerHitEvent>,
players: Query<Entity, With<Player>>,
enemies: Query<Entity, With<Enemy>>,
mut contact_events: EventReader<ContactEvent>,
) {
for contact_event in contact_events.iter() {
if let ContactEvent::Started(h1, h2) = contact_event {
for player in players.iter() {
for enemy in enemies.iter() {
if (h1.entity() == player && h2.entity() == enemy)
|| (h1.entity() == enemy && h2.entity() == player)
{
send_player_hit.send(PlayerHitEvent { entity: player })
}
}
}
}
}
}
Finally, we can implement the reactions:
fn on_player_hit(
mut player_hit_events: EventReader<PlayerHitEvent>,
mut send_player_death: EventWriter<PlayerDeathEvent>,
) {
for event in player_hit_events.iter() {
send_player_death.send(PlayerDeathEvent {
entity: event.entity,
})
}
}
fn on_player_dead(mut player_death_events: EventReader<PlayerDeathEvent>, mut commands: Commands) {
for event in player_death_events.iter() {
commands.entity(event.entity).despawn_recursive()
}
}
Here, we added a on_player_hit
event, which triggers a player death event (multiple lives would be handled in there), and a on_player_dead
which contain our reaction code to despawn our player.
Don't forget to register the events:
.add_event::<PlayerHitEvent>()
.add_event::<PlayerDeathEvent>()
The same can be done for monsters:
pub struct MonsterHitEvent {
pub entity: Entity,
}
pub struct MonsterDeathEvent {
entity: Entity,
}
Living Being Component
A monster loosing a life is no different from a player loosing a life. A monster dying is no different from a player dying. A bullet could kill a monster or a player. Falling into a hole could kill a monster just like it kills a player.
We could simplify our code even more if we could extract what is common to both.
Let's create a living_being
module, and move our Player events to it, along with the systems that trigger or react to these events. Then, let's replace the word player
by the word living_being
:
use bevy::prelude::*;
use bevy_rapier2d::prelude::RigidBodyPosition;
pub struct LivingBeing;
pub struct LivingBeingHitEvent {
pub entity: Entity,
}
pub struct LivingBeingDeathEvent {
pub entity: Entity,
}
pub fn on_living_being_hit(
mut living_being_hit_events: EventReader<LivingBeingHitEvent>,
mut send_living_being_death: EventWriter<LivingBeingDeathEvent>,
) {
for event in living_being_hit_events.iter() {
send_living_being_death.send(LivingBeingDeathEvent {
entity: event.entity,
})
}
}
pub fn on_living_being_dead(
mut living_being_death_events: EventReader<LivingBeingDeathEvent>,
mut commands: Commands,
) {
for event in living_being_death_events.iter() {
commands.entity(event.entity).despawn_recursive()
}
}
pub fn death_by_height(
mut send_death_event: EventWriter<LivingBeingDeathEvent>,
living_being: Query<(Entity, &RigidBodyPosition), With<LivingBeing>>,
) {
for (entity, position) in living_being.iter() {
if position.position.translation.y < -1. {
send_death_event.send(LivingBeingDeathEvent { entity })
}
}
}
Then, where a MonsterHitEvent
or a PlayerHitEvent
is triggered, replace the event by a LivingBeingHitEvent
. Do the same with reactions, and with Death events.
Finally, remove any duplicated code, and remove the specific event definitions (MonsterHitEvent
, PlayerHitEvent
, etc).
Do not forget to register the new events:
.add_event::<LivingBeingHitEvent>()
.add_event::<LivingBeingDeathEvent>()
and to add the LivingBeing
component to your player and monsters:
// player.rs
.insert(LivingBeing)
.insert(Player {
// monsters.rs
.insert(LivingBeing)
.insert(Enemy)
.insert(Monster);
Running the game should confirm that everything still works the same.
The final code is available here.
Posted on January 31, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.