johan helsing.studio

Extreme Bevy 4: Keeping score

In this part, we’ll be implementing respawning and adding a simple UI for keeping track of score.

This post is part of a series on making a p2p web game with rust and Bevy.

Our game in its current state quickly becomes... lonely. As soon as the other player is dead, there is nothing else to do.

In the original Extreme Violence, when a player dies, the game stops briefly, a sound effect plays, and then a new level is generated and the camera pans around it and then the players are respawned. We will be doing some of that as well.

Bevy states?

So, it’s obvious that a lot of things will happen at once when a round ends. Input is disabled, camera behaves differently etc. etc. This might seem like an ideal case for another state to be added to our app. Otherwise, we’d need to add ifs and buts to almost all our systems.

However, directly using bevy states is a trap! Remember, everything that is gameplay significant needs to be able to roll back, and for that to work all systems involved need to be part of the GgrsSchedule, and all components/resources need to be registered for rollback. Bevy states, at least when added through app.add_state::<S>(), violates both of these criteria.

Firstly, state transitions are added to special schedules (OnEnter(state)/OnExit(state)) that are run by the apply_state_transition system during the StateTransition schedule within the Main schedule. That's a long-winded way of saying it will follow your app update frequency instead of Ggrs ticks.

Secondly, the resources involved, State<S> and NextState<S>, are not registered for rollback.

Rollback safe states

So we can't use Bevy states, at least not directly. That said, Bevy states is otherwise a quite elegant solution.

And, in fact if we just don't use add_state, we can re-use most of it without changes.

To make this a tiny bit easier, I made tiny crate, bevy_roll_safe, which provides rollback safe alternatives for several Bevy abstractions, including add_state

So, let's add it to our project:

bevy_roll_safe = "0.1"

...and include its prelude:

use bevy_roll_safe::prelude::*;

This will allow a state to added to a specific schedule using app.add_roll_state(schedule).

But, we're getting a bit ahead of ourselves, let's first define the state we will be adding:

#[derive(States, Clone, Eq, PartialEq, Debug, Hash, Default, Reflect)]
enum RollbackState {
    /// When the characters running and gunning
    #[default]
    InRound,
    /// When one character is dead, and we're transitioning to the next round
    RoundEnd,
}

Now, we're ready to add the state to GgrsSchedule:

        .init_ggrs_state::<RollbackState>(GgrsSchedule)

Okay, now that we have a shiny new rollback safe state, let's put it to use :)

The first thing we will do, is make sure that when a player dies, regular gameplay stops (no more running and gunning). We can do this simply by using the regular built-in .in_state(state) run criteria on all of our running/gunning systems:

        .add_systems(
            GgrsSchedule,
            (
                move_players,
                reload_bullet,
                fire_bullets.after(move_players).after(reload_bullet),
                move_bullet.after(fire_bullets),
                kill_players.after(move_bullet).after(move_players),
            )
                .after(apply_state_transition::<RollbackState>) // NEW
                .run_if(in_state(RollbackState::InRound)), // NEW
        )

Note: we're also making sure the systems run after we've applied state transitions (don't worry, Bevy will tell you about system ambiguities like these if you forget).

...and we also need to transition to this state when someone dies:

fn kill_players(
    mut commands: Commands,
    players: Query<(Entity, &Transform), (With<Player>, Without<Bullet>)>,
    bullets: Query<&Transform, With<Bullet>>,
    mut next_state: ResMut<NextState<RollbackState>>, // new
) {
    for (player, player_transform) in &players {
        for bullet_transform in &bullets {
            let distance = Vec2::distance(
                player_transform.translation.xy(),
                bullet_transform.translation.xy(),
            );
            if distance < PLAYER_RADIUS + BULLET_RADIUS {
                commands.entity(player).despawn_recursive();
                next_state.set(RollbackState::RoundEnd); // new
            }
        }
    }
}

Phew, that was quite a lot of code for something pretty simple, but if you run the game now, it should finally stop whenever someone dies:

Respawning

Okay, so we're kind of halfway there. Now we just need to make sure to actually start a new round a short while after someone dies... When the losing player has had time to throw down the controller, and pick it up again... let's give them 1 second for all that; it's supposed to be an intense game after all.

So how do we know when 1 second has passed? We'll use a Timer. It's a very simple struct that keeps track of elapsed time, through a manual .tick(duration) call. It also has a .just_finished() method that returns true when the time is up; it's a perfect fit for what we want to do.

First, let's create a resource with a Timer in it:

#[derive(Resource, Clone, Deref, DerefMut)]
struct RoundEndTimer(Timer);

impl Default for RoundEndTimer {
    fn default() -> Self {
        RoundEndTimer(Timer::from_seconds(1.0, TimerMode::Once))
    }
}

/// fn main()
        .init_resource::<RoundEndTimer>()

And we also have to make sure it's rolled back together with the other gameplay-significant things:

        .init_ggrs_state::<RollbackState>()
        .rollback_resource_with_clone::<RoundEndTimer>() // NEW
        .rollback_component_with_clone::<Transform>()

And let's make sure to tick it when we're in the round end state and go back to the InRound state if it finished. We'll create a new system for that.

fn round_end_timeout(
    mut timer: ResMut<RoundEndTimer>,
    mut state: ResMut<NextState<RollbackState>>,
    time: Res<Time>,
) {
    timer.tick(time.delta());

    if timer.just_finished() {
        state.set(RollbackState::InRound);
    }
}

We'll add this new system to our ggrs schedule, and make sure it only runs in the RoundEnd state:

        .add_systems(
            GgrsSchedule,
            round_end_timeout
                .run_if(in_state(RollbackState::RoundEnd))
                .after(apply_state_transition::<RollbackState>),
        )

Okay, let's run the game...

2023-09-12T05:46:23.882329Z  WARN bevy_ecs::schedule::schedule: 1 pairs of systems with conflicting data access have indeterminate execution order. Consider adding `before`, `after`, or `ambiguous_with` relationships between these:
 -- round_end_timeout and kill_players
    conflict on: ["bevy_ecs::schedule::state::NextState<extreme_bevy::RollbackState>"]

thread 'main' panicked at 'Systems with conflicting access have indeterminate run order.'

So, what it's complaining about is that both kill_players and round_end_timeout might change state and that it's undefined whichever runs first. Turns out Bevy is not smart enough to know they're running in different states, so this can never happen, and is a false positive. We'll tell Bevy we know what we're doing by using ambiguous_with:

        .add_systems(
            GgrsSchedule,
            round_end_timeout
                .ambiguous_with(kill_players) // new
                .run_if(in_state(RollbackState::RoundEnd))
                .after(apply_state_transition::<RollbackState>),
        )

Now, the game should finally run again. What happens now, is that whenever a player is killed, the game just stops briefly (in the RoundEnd state), and then goes back to in game. Just like it's lagging for 1 second.

We kind of forgot to actually respawn players. Our current system for spawning players is in the OnEnter(InGame) state, which means it will only run once as we go from Matchmaking to InGame. Let's instead move it to OnEnter(RollbackState::InRound), and move it down with the other gameplay/rollback systems:

        .init_ggrs_state::<RollbackState>(GgrsSchedule)
        .add_systems(OnEnter(RollbackState::InRound), spawn_players) // new
        .add_systems(
            GgrsSchedule,

That fixes spawning, but we still need to despawn the players, or we'd be left with more than one player. And while at it, lets also get rid of the bullets from the last round.

fn spawn_players(
    mut commands: Commands,
    players: Query<Entity, With<Player>>,
    bullets: Query<Entity, With<Bullet>>,
) {
    info!("Spawning players");

    for player in &players {
        commands.entity(player).despawn_recursive();
    }

    for bullet in &bullets {
        commands.entity(bullet).despawn_recursive();
    }
    
    // ...
}

Now, we can play one round, and then another, but then the game freezes after the second round... This is because the round end timer is already finished, and doesn't finish again (.just_finished() is false), so we're stuck in the RoundEnd state. Since we're only ticking the timer in the round end state, we can fix this simply by changing the timer's mode to Repeating, and the timer will automatically reset after finishing:

impl Default for RoundEndTimer {
    fn default() -> Self {
        RoundEndTimer(Timer::from_seconds(1.0, TimerMode::Repeating)) // new
    }
}

Now, we finally have something somewhat functional again. We can keep playing round after round :)

Keeping score

Okay, so now that we have rounds, it would be cool to know who is best at this game. We will do that simply by giving the each player one point each time the other player dies.

We'll keep things simple and keep the score in a tuple struct resource:

#[derive(Resource, Default, Clone, Copy, Debug)]
struct Scores(u32, u32);

We'll also register it for rollback and initialize it:

        .rollback_resource_with_clone::<RoundEndTimer>()
        .rollback_resource_with_copy::<Scores>() // NEW

        // ...

        .init_resource::<RoundEndTimer>()
        .init_resource::<Scores>() // NEW

So let's give the players points whenever someone dies:

fn kill_players(
    mut commands: Commands,
    players: Query<(Entity, &Transform, &Player), Without<Bullet>>, // changed
    bullets: Query<&Transform, With<Bullet>>,
    mut next_state: ResMut<NextState<RollbackState>>,
    mut scores: ResMut<Scores>, // new
) {
    for (player_entity, player_transform, player) in &players { // changed
        for bullet_transform in &bullets {
            let distance = Vec2::distance(
                player_transform.translation.xy(),
                bullet_transform.translation.xy(),
            );
            if distance < PLAYER_RADIUS + BULLET_RADIUS {
                commands.entity(player_entity).despawn_recursive(); // changed
                next_state.set(RollbackState::RoundEnd);

                // new
                if player.handle == 0 {
                    scores.1 += 1;
                } else {
                    scores.0 += 1;
                }
                info!("player died: {scores:?}")
            }
        }
    }
}

Let's run again and confirm it's working. We'll see output like the following.

2023-09-12T06:57:10.095922Z  INFO extreme_bevy: All peers have joined, going in-game
2023-09-12T06:57:10.235360Z  INFO extreme_bevy: Spawning players
2023-09-12T06:57:12.817300Z  INFO extreme_bevy: player died: ResMut(Scores(1, 0))
2023-09-12T06:57:13.833975Z  INFO extreme_bevy: Spawning players
2023-09-12T06:57:18.917479Z  INFO extreme_bevy: player died: ResMut(Scores(2, 0))
2023-09-12T06:57:19.933890Z  INFO extreme_bevy: Spawning players
2023-09-12T06:57:21.217496Z  INFO extreme_bevy: player died: ResMut(Scores(3, 0))
2023-09-12T06:57:22.234724Z  INFO extreme_bevy: Spawning players
2023-09-12T06:57:23.000705Z  INFO extreme_bevy: player died: ResMut(Scores(3, 1))
2023-09-12T06:57:24.017288Z  INFO extreme_bevy: Spawning players

Okay, it's working, but not really user friendly to have to check the console to know who's winning...

Let's add some simple UI!

Adding UI

The built-in UI in Bevy, is for a game engine that prizes itself to be ergonomic, surprisingly un-ergonomic to use; it requires a lot of boiler-plate to get simple things done.

Cart has a very promising proposal for how to fix this, but in the meantime it's a lot easier to get things done using the bevy_egui community crate.

We'll add it to Cargo.toml:

bevy_egui = "0.25"

...and add the plugin:

            GgrsPlugin::<Config>::default(),
            EguiPlugin, // new

And we'll write a simple system that displays the score at the top of the screen:

fn update_score_ui(mut contexts: EguiContexts, scores: Res<Scores>) {
    let Scores(p1_score, p2_score) = *scores;

    egui::Area::new("score")
        .anchor(Align2::CENTER_TOP, (0., 25.))
        .show(contexts.ctx_mut(), |ui| {
            ui.label(
                RichText::new(format!("{p1_score} - {p2_score}"))
                    .color(Color32::BLACK)
                    .font(FontId::proportional(72.0)),
            );
        });
}

And add it to the Update schedule, in the InGame state:

        .add_systems(
            Update,
            (
                (
                    wait_for_players.run_if(p2p_mode),
                    start_synctest_session.run_if(synctest_mode),
                )
                    .run_if(in_state(GameState::Matchmaking)),
                (camera_follow, update_score_ui, handle_ggrs_events)
                    .run_if(in_state(GameState::InGame)), // changed
            ),
        )

And finally... we have a working score system :)

It's now possible for players to play the game as long as they want without refreshing the page, and easily keep tabs on who's winning.

You can try the game we've got so far by clicking the embeds below.

Player 1:

Player 2:

And that concludes this tutorial.

In the next part, we'll add randomized starting positions and procedurally generated levels.

Reference implementation

Diff for this part

Comments

Loading comments...