DEV Community

Sébastien Belzile
Sébastien Belzile

Posted on

Making Games in Rust - Part 12 - Better Code with Events

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);
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

An event in Bevy is a plain old rust struct:

pub struct MyEvent {
    entity: Entity,
    some_other_prop: f32,
}
Enter fullscreen mode Exit fullscreen mode

It is important to note that events must be registered on your AppBuilder:

.add_event::<MyEvent>()
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Don't forget to register the event before trying it:

.add_event::<BulletFiredEvent>()
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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 })
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 })
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode

The same can be done for monsters:

pub struct MonsterHitEvent {
    pub entity: Entity,
}

pub struct MonsterDeathEvent {
    entity: Entity,
}
Enter fullscreen mode Exit fullscreen mode

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 })
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Running the game should confirm that everything still works the same.

The final code is available here.

Discussion (0)