johan helsing.studio

Extreme Bevy 3: Adding violence

In this part, we’ll turn our pacifistic grid walking game into a violent killing ground. We’ll add bullets and hit detection.

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

Managing assets and states

For drawing the bullets, we’ll use a very simple image consisting of three pixels.

Go ahead and create an assets folder in your project root and save the image above in it as bullet.png.

While assets can quite easily be loaded in bevy using AssetServer::load, it leads to quite a bit of boiler-plate, especially since we want to make sure the images are not released when there are no bullets on the screen.

We will be using bevy_asset_loader to help us with this.

Go ahead and add it in Cargo.toml:

bevy_asset_loader = "0.21"

Next, we add its prelude to our usings:

use bevy_asset_loader::prelude::*;

We can now go ahead and create an asset collection for our images, currently only with the bullet in it:

#[derive(AssetCollection, Resource)]
struct ImageAssets {
    #[asset(path = "bullet.png")]
    bullet: Handle<Image>,
}

With bevy_asset_loader all assets are loaded in a special loading state, and then it continues to the actual game state. So let’s create some states:

#[derive(States, Clone, Eq, PartialEq, Debug, Hash, Default)]
enum GameState {
    #[default]
    AssetLoading,
    InGame,
}

And initialize it in main after adding the plugins:

fn main() {
    App::new()
        .add_plugins((
            // ... (same as before)
        ))
        .init_state::<GameState>()
        .add_loading_state(
            LoadingState::new(GameState::AssetLoading)
                .load_collection::<ImageAssets>()
                .continue_to_state(GameState::Matchmaking),
        )
        // ... (same as before)
}

Now, as the game launches, it will load all the image assets first, and then transition to the InGame state.

We can take advantage of this, and make sure some systems are only run after loading has finished, that way we don’t have to deal with the possibility that some assets may not yet be loaded. In fact, if we look at our systems, it makes sense to add another stage as well, so we can differentiate between before and after the GGRS session has started.

Let’s go ahead and create another state. We’ll call it Matchmaking:

#[derive(Clone, Eq, PartialEq, Debug, Hash)]
enum GameState {
    AssetLoading,
    Matchmaking, // <-- NEW
    InGame,
}

Now, let’s update our main function to make sure we continue to this state after loading:

        .add_loading_state(
            LoadingState::new(GameState::AssetLoading)
                .with_collection::<ImageAssets>()
                .continue_to_state(GameState::Matchmaking), // <-- CHANGED
        )

And also make sure our systems only run in the appropriate states:

        .add_systems(
            OnEnter(GameState::Matchmaking),
            (setup, start_matchbox_socket),
        )
        .add_systems(OnEnter(GameState::InGame), spawn_players)
        .add_systems(
            Update,
            (
                wait_for_players.run_if(in_state(GameState::Matchmaking)),
                camera_follow.run_if(in_state(GameState::InGame)),
            ),
        )
        .add_systems(ReadInputs, read_local_inputs)
        .run();

Finally, we have to make sure that we actually enter the InGame state in wait_for_players when we start the GGRS session.

fn wait_for_players(
    mut commands: Commands,
    mut socket: ResMut<Option<WebRtcSocket>>,
    mut next_state: ResMut<NextState<GameState>>, // <-- NEW
) {
    // ...

    next_state.set(GameState::InGame); // <-- NEW
}

And with that, the player sprites won’t spawn before we have enough players. Nice!

Ok, let’s get back to the bullet sprite. Let’s start simple. When a player presses a button, we want to spawn a sprite with the bullet image. Let’s make a new system for this and add it in the rollback schedule (if a bullet fired by the other player was mis-predicted, this is obviously something we’d want to correct!)

        .add_systems(ReadInputs, read_local_inputs)
        .add_systems(
            GgrsSchedule,
            (move_players, fire_bullets.after(move_players)),
        )
        .run();

// ...

fn fire_bullets(
    mut commands: Commands,
    inputs: Res<PlayerInputs<Config>>,
    images: Res<ImageAssets>,
    players: Query<(&Transform, &Player)>,
) {
    for (transform, player) in &players {
        // TODO: Check if player pressed fire button
        // Spawn bullet
    }
}

Now, let’s add a method to our input module to check for the fire button:

pub fn fire(input: u8) -> bool {
    input & INPUT_FIRE != 0
}

And let’s use it to spawn our bullet sprite:

        let (input, _) = inputs[player.handle];
        if fire(input) {
            commands.spawn(SpriteBundle {
                transform: Transform::from_translation(transform.translation),
                texture: images.bullet.clone(),
                sprite: Sprite {
                    custom_size: Some(Vec2::new(0.3, 0.1)),
                    ..default()
                },
                ..default()
            });
        }

That should be enough to see some bullets on screen, right?

System order ambiguities

Well, not quite, if you run the game now, you'll be met with the following panic:

2023-08-10T08:41:52.893816Z  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:
 -- fire_bullets and move_players
    conflict on: ["bevy_transform::components::transform::Transform"]

thread 'main' panicked at 'Systems with conflicting access have indeterminate run order.', C:\Users\Johan\.cargo\registry\src\index.crates.io-6f17d22bba15001f\bevy_ecs-0.11.0\src\schedule\schedule.rs:234:51
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Encountered a panic in exclusive system `bevy_ggrs::ggrs_stage::GgrsStage<extreme_bevy::GgrsConfig>::run`!
Encountered a panic in system `bevy_app::main_schedule::Main::run_main`!

This is a safety mechanism in enabled in bevy_ggrs; it will not allow a non-deterministic system order.

It's complaining because we now have two systems in our rollback schedule, and they both operate on the Transform component. So it matters a lot in what order the systems are executed.

It is very important whether we move the player first, then fire the bullet, or the other way around, otherwise different players might see bullets spawned at slightly different positions which could be the difference between a hit and a miss.

As hinted to in the panic message, this issue is fixed by adding an explicit ordering to our rollback systems:

        .add_systems(
            GgrsSchedule,
            (move_players, fire_bullets.after(move_players)),
        )

Now, finally, we have some bullets on screen.

However, it feels weird that bullets are spawned continuously while fire is held. Let’s make it so it only happens when the button transitions from being not pressed to pressed:

We’ll add a new component that keeps track of whether a bullet is ready to be fired. This way we can extend it to also handle other kinds of cooldowns if necessary:

#[derive(Component, Clone, Copy)]
pub struct BulletReady(pub bool);

We derive Copy so this component can be cheaply and easily rolled back. We also need to tell ggrs that it should roll the component back using the Copy trait.

        .rollback_component_with_clone::<Transform>()
        .rollback_component_with_copy::<BulletReady>() // <-- new

And add it to the players when we spawn them:

            Player { handle: 0 },
            BulletReady(true),
    // ...
            Player { handle: 1 },
            BulletReady(true),

Now let's add a reload_bullet system and add it to the rollback schedule:

        .add_systems(
            GgrsSchedule,
            (
                move_players,
                reload_bullet,
                fire_bullets.after(move_players),
            ),
        )
// ...

fn reload_bullet(
    inputs: Res<PlayerInputs<Config>>,
    mut players: Query<(&mut BulletReady, &Player)>,
) {
    for (mut can_fire, player) in players.iter_mut() {
        let (input, _) = inputs[player.handle];
        if !fire(input) {
            can_fire.0 = true;
        }
    }
}

Now, we can simply check whether a bullet is ready (and also remember to set bullet ready to false when we actually fire):

fn fire_bullets(
    mut commands: Commands,
    inputs: Res<PlayerInputs<Config>>,
    images: Res<ImageAssets>,
    mut players: Query<(&Transform, &Player, &mut BulletReady, &MoveDir)>,
) {
    for (transform, player, mut bullet_ready, move_dir) in &mut players {
        let (input, _) = inputs[player.handle];
        if fire(input) && bullet_ready.0 {
            commands.spawn((
                Bullet,
                *move_dir,
                SpriteBundle {
                    transform: Transform::from_translation(transform.translation),
                    texture: images.bullet.clone(),
                    sprite: Sprite {
                        custom_size: Some(Vec2::new(0.3, 0.1)),
                        ..default()
                    },
                    ..default()
                },
            ));
            bullet_ready.0 = false; // <-- new
        }
    }
}

Okay, so running the game at this point, we'll get a similar panic as before:

2023-08-10T09:07:10.687926Z  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:
 -- reload_bullet and fire_bullets
    conflict on: ["extreme_bevy::components::BulletReady"]

thread 'main' panicked at 'Systems with conflicting access have indeterminate run order.', C:\Users\Johan\.cargo\registry\src\index.crates.io-6f17d22bba15001f\bevy_ecs-0.11.0\src\schedule\schedule.rs:234:51
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Encountered a panic in exclusive system `bevy_ggrs::ggrs_stage::GgrsStage<extreme_bevy::GgrsConfig>::run`!
Encountered a panic in system `bevy_app::main_schedule::Main::run_main`!

Again, we've been saved from another hard-to-debug non-determinism issue. This time, it's telling us that we need to decide whether we want to reload or fire first. Let's fire after we reload:

        .add_systems(
            GgrsSchedule,
            (
                move_players,
                reload_bullet,
                fire_bullets.after(move_players).after(reload_bullet),
            ),
        )

Now our game shouldn’t desync anymore, right?

...well, another problem with our bullets, is that they aren’t currently rolled back because we forgot to add the .add_rollback() to the bullet entities we spawned...

This means that if a bullet is spawned, but then we have to rollback to before the bullet was spawned, bevy_ggrs won't know it's supposed to remove the entity, and we'll be left with two versions of the same bullet.

So let's add the .add_rollback() call now:

fn fire_bullets(
    mut commands: Commands,
    inputs: Res<PlayerInputs<Config>>,
    images: Res<ImageAssets>,
    mut players: Query<(&Transform, &Player, &mut BulletReady, &MoveDir)>,
) {
    for (transform, player, mut bullet_ready, move_dir) in &mut players {
        let (input, _) = inputs[player.handle];
        if fire(input) && bullet_ready.0 {
            commands
                .spawn((
                    Bullet,
                    *move_dir,
                    SpriteBundle {
                        transform: Transform::from_translation(transform.translation),
                        texture: images.bullet.clone(),
                        sprite: Sprite {
                            custom_size: Some(Vec2::new(0.3, 0.1)),
                            ..default()
                        },
                        ..default()
                    },
                ))
                .add_rollback(); // <-- NEW
            bullet_ready.0 = false;
        }
    }
}

That should be enough to make it deterministic again!

And now, we only fire one bullet each time we press the fire button.

Chaining systems for non-ambiguity

There is another, simpler way to deal with system ordering: system chaining.

        .add_systems(
            GgrsSchedule,
            (move_players, reload_bullet, fire_bullets).chain(),
        )

This will make the systems run in the exact order they appear in the tuple. This means we can simply insert systems in the "position" we want them to run, and we will never see system order ambiguity errors from bevy_ggrs again.

While this is simpler to reason about, results in fewer lines of code, and will give you fewer errors, it will add unnecessary dependencies between some systems. For instance reload_bullet is now always run after move_players, even though they could have run at the same time without causing any issues.

On the other hand, WASM is not actually multi-threaded, so the systems won't run in parallel anyway. Furthermore, the number of entities we operate on is so low, that any parallelism gains would be completely insignificant even on native. It's a perfect example of premature optimization.

However, one really good argument in favor of explicitly ordering with .after/.before, is that it helps spot dependencies we might not otherwise have thought about. Like in this case, it might be a good thing to think about whether it makes sense to move first or fire first. Firing first and then moving would perhaps be perceived by the player as higher latency, so in this case it helped us be aware of the choice and make the better decision.

For this reason, the rest of the tutorial will continue with explicit ordering.

Back to the bullets

Ok, time to make the bullets move!

To start with, we’ll just move the bullets to the right every frame. And add it to the rollback stage:

(
    move_players,
    reload_bullet,
    fire_bullets.after(move_players).after(reload_bullet),
    move_bullet, // <-- new
)

Now, before we can write the system, we need to know which components are actually bullets... We’ll add another marker component in components.rs:

#[derive(Component)]
pub struct Bullet;

And add it in fire_bullets:

            commands
                .spawn((
                    Bullet, // <-- NEW
                    SpriteBundle {
                        transform: Transform::from_translation(transform.translation),
                        texture: images.bullet.clone(),
                        sprite: Sprite {
                            custom_size: Some(Vec2::new(0.3, 0.1)),
                            ..default()
                        },
                        ..default()
                    },
                ))
                .add_rollback();

Now we can move on to write the system:

fn move_bullet(mut bullets: Query<&mut Transform, With<Bullet>>, time: Res<Time>) {
    for mut transform in &mut bullets {
        let speed = 1.;
        transform.translation.x += speed * time.delta_seconds();
    }
}

Running the game now will give us a new system ambiguity error:

023-03-23T08:10:15.974988Z  WARN bevy_ecs::schedule::schedule: 2 pairs of systems with conflicting data access have indeterminate execution order. Consider adding `before`, `after`, or `ambiguous_with` relationships between these:
 -- move_bullet and move_players
    conflict on: ["bevy_transform::components::transform::Transform"]
 -- move_bullet and fire_bullets
    conflict on: ["bevy_transform::components::transform::Transform"]

We need to decide whether we want bullets to move during the first frame or not. At this point, it might not matter a great deal in which order we choose, but we need to choose one in order to stay deterministic. Let's go with moving after firing:

move_bullet.after(fire_bullets),

And now we have moving bullets!

Now we just need to make sure they fly in the right direction.

We’ll start by spawning them with the right orientation, we will do that by setting their rotation to the direction the player is currently facing. Since we will use sprites for representing player direction, we won’t actually rotate the players themselves, but what we’ll do is instead keep track of the player direction in a special MoveDir component. It will be a newtype of Vec2. So, we do the usual dance that should be somewhat familiar by now:

// components.rs
#[derive(Component, Clone, Copy)]
pub struct MoveDir(pub Vec2);

// main.rs
//fn main()
            .register_rollback_component::<MoveDir>()

// fn spawn_players()
    // Player 1
            Player { handle: 0 },
            BulletReady(true),
            MoveDir(-Vec2::X),
    // Player 2
            Player { handle: 1 },
            BulletReady(true),
            MoveDir(Vec2::X),

Now we simply need to set this in move_player, after we’ve checked it for being zero:

fn move_players(
    inputs: Res<PlayerInputs<Config>>,
    mut players: Query<(&mut Transform, &mut MoveDir, &Player)>
) {
    for (mut transform, mut move_dir, player) in &mut players {
        let (input, _) = inputs[player.handle];
        let direction = direction(input);

        if direction == Vec2::ZERO {
            continue;
        }

        move_dir.0 = direction;

And while we’re at it, we should probably also make sure that the direction is normalized, so we don’t move faster diagonally than up/down. At the end of input::direction():

    direction.normalize_or_zero()

Ok, now that we have a player direction, we can copy it to our bullets in fire_bullets, and also set their transforms’ rotations based on it.

fn fire_bullets(
    mut commands: Commands,
    inputs: Res<PlayerInputs<Config>>,
    images: Res<ImageAssets>,
    mut players: Query<(&Transform, &Player, &mut BulletReady, &MoveDir)>,
) {
    for (transform, player, mut bullet_ready, move_dir) in &mut players {
        let (input, _) = inputs[player.handle];
        if fire(input) && bullet_ready.0 {
            commands
                .spawn((
                    Bullet,
                    *move_dir, // <-- NEW
                    SpriteBundle {
                        transform: Transform::from_translation(transform.translation)
                            .with_rotation(Quat::from_rotation_arc_2d(Vec2::X, move_dir.0)), // <-- NEW
                        texture: images.bullet.clone(),
                        sprite: Sprite {
                            custom_size: Some(Vec2::new(0.3, 0.1)),
                            ..default()
                        },
                        ..default()
                    },
                ))
                .add_rollback();
            bullet_ready.0 = false;
        }
    }
}

We use Quat::from_rotation_arc_2d(Vec2::X, move_dir.0) to find the new rotation. This means that we use the old convention that all our rotatable sprites face right (Vec2::X) in the original image.

And let’s update move_bullet to make use of our shiny new directions, and also increase their speed a little:

fn move_bullet(mut bullets: Query<(&mut Transform, &MoveDir), With<Bullet>>, time: Res<Time>) {
    for (mut transform, dir) in &mut bullets {
        let speed = 20.;
        let delta = dir.0 * speed * time.delta_seconds();
        transform.translation += delta.extend(0.);
    }
}

Now we just need to make the characters die when they are hit

Detecting collisions

Detecting collisions can be very complicated, but again, let’s start naively and fix corner cases as required. In fact, let’s remove all our corners, and just use circles for collision.

Let’s start with the basics: A player should die if the bullet overlaps the player. In other words, if the distance between their centers are smaller than the sum of their radii.

const PLAYER_RADIUS: f32 = 0.5;
const BULLET_RADIUS: f32 = 0.025;

fn kill_players(
    mut commands: Commands,
    players: Query<(Entity, &Transform), (With<Player>, Without<Bullet>)>,
    bullets: Query<&Transform, With<Bullet>>,
) {
    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();
            }
        }
    }
}

WARNING: At this point we've introduced a very subtle and rare desync bug. It's introducing a desync if there is a rollback exactly when someone dies. I've left it in on purpose, we'll get back to debugging and fixing it in the next part.

So before we add it, let’s think about system order. We probably want to kill players at the very end. That ensures the player is destroyed as close as possible to the detection (commands are executed at the end of the stage). That way they’re destroyed before they get the chance to take more actions, otherwise we’d have to deal with scenarios like a player firing a bullet after they’ve been hit etc. Our system only depends on output from move_bullet and move_players, so all we have to do is make sure it runs after both systems:

(
    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), // <-- new
)

And now, let’s try it.

So if we fire, we seem to just immediately kill ourselves. So that means dying works... kinda. Let’s solve it by moving the bullet a little bit away from the player when spawning it:

// fn fire_bullets
            let player_pos = transform.translation.xy(); // <-- new
            let pos = player_pos + move_dir.0 * PLAYER_RADIUS + BULLET_RADIUS; // <-- new
            commands
                .spawn((
                    Bullet,
                    *move_dir,
                    SpriteBundle {
                        transform: Transform::from_translation(pos.extend(200.)) // <-- new
                            .with_rotation(Quat::from_rotation_arc_2d(Vec2::X, move_dir.0)),

That’s more like it!

Player 1:

Player 2:

In the next part we'll debug and fix the subtle bug mentioned above.

Reference implementation

Diff for this part

Comments

Loading comments...