johan helsing.studio

Extreme Bevy 5: Procedural generation

In this part, we'll explore how to seed a random number generator and use it to generate a map.

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

Currently, the game is rather predictable. It's essentially a game about who can hit the fire button first. Let's fix that!

Randomized starting positions

The first thing we'll want to do is to make sure the players are actually starting in different positions each round. So they'll actually have to aim before they fire.

To get a random position, we just need two random numbers between (-MAP_SIZE/2 and MAP_SIZE/2). If you google how to do this, you'll find the rust cookbook, which would lead to the following code:

    let mut rng = rand::thread_rng();
    let half = MAP_SIZE as f32 / 2.;
    let p1_pos = Vec2::new(rng.gen_range(-half..half), rng.gen_range(-half..half));
    let p2_pos = Vec2::new(rng.gen_range(-half..half), rng.gen_range(-half..half));

    // Player 1
    // ...
                transform: Transform::from_translation(p1_pos.extend(100.)),
    // ...
    // Player 2
    // ...
                transform: Transform::from_translation(p2_pos.extend(100.)),
}

In order for this to work, we also need to add rand to our dependencies section in Cargo.toml:

rand = "0.8"

However, when you run this, the game will immediately desync. If you did section 3.5, you'll be notified about this in the console:

2024-01-20T09:14:07.775280Z  INFO extreme_bevy: Spawning players
2024-01-20T09:14:07.775746Z  INFO extreme_bevy: GGRS event: Synchronized { addr: PeerId(12a14097-b14b-4408-9bc5-8941c70155b0) }
2024-01-20T09:14:07.807427Z ERROR extreme_bevy: Desync on frame 1. Local checksum: 92E3AC433A0C3F55, remote checksum: A6E59D963C78C52D

The problem is that the starting positions are different for each peer. They generate different starting positions.

Seeding

In order for the starting positions to be deterministic across peers, they need to seed the random generator using the same seed. When seeded, the generator will output the exact same sequence of numbers each time it's run.

The random number generator we're currently using, rand::thread_rng does not let us access or set its seed, so we need to switch it out with another, explicitly seeded rng instance.

We need to choose another random number generator that is:

  1. Seedable
  2. Portable/cross-platform deterministic

There are a number of rngs you could use, but we'll use one called Xoshiro256PlusPlus. I wrote about this topic in detail in a previous blog post.

So first, we'll need to yet add another dependency to Cargo.toml:

rand_xoshiro = "0.6"

And instead of thread_rng, we'll simply use Xoshiro256PlusPlus with a deterministic seed:

    let mut rng = Xoshiro256PlusPlus::seed_from_u64(1337); // <-- changed
    let half = MAP_SIZE as f32 / 2.;
    let p1_pos = Vec2::new(rng.gen_range(-half..half), rng.gen_range(-half..half));
    let p2_pos = Vec2::new(rng.gen_range(-half..half), rng.gen_range(-half..half));

Since it implements the RngCore trait, there is no need to change the rest of the code. gen_range still works.

So now, if you run the game, the desyncs are gone... but our "random" positions are not really random anymore, since we use the exact same seed each round...

The first challenge is to choose a seed that will be different each round, we can do this easily by basing the seed on something that changes each round. One easy way to do this is to use the existing Scores resource:

fn spawn_players(
    mut commands: Commands,
    players: Query<Entity, With<Player>>,
    bullets: Query<Entity, With<Bullet>>,
    scores: Res<Scores>, // <-- new
) {
    // ...
    let mut rng = Xoshiro256PlusPlus::seed_from_u64((scores.0 + scores.1) as u64);

Now the seed is incremented by 1 for each round we play, so we'll have a different starting position on each round.

We still have one problem though, round 0 will always have the same starting positions in a new session, so determined players could simply memorize the starting positions and gain an unfair advantage... we don't want that.

We need the seed to also be different for each new session. So how do we do that?

A simple hack we can do, is to take advantage of the fact that Matchbox peer ids are randomly generated Uuids.

Our seeding function takes a u64, though so how do we deterministically convert two Uuids into something we can use?

Uuid has a handy method as_u64_pair, so we'll simply use that to generate one pair for each player, and xor (^) all four values together. Xor is a commutative operation, meaning it doesn't matter which order we do it in, so we can simply xor our own to values and then loop over the others, and finally put it in a resource.

// fn wait_for_players(
    // ...
    info!("All peers have joined, going in-game");

    // determine the seed
    let id = socket.id().expect("no peer id assigned").0.as_u64_pair();
    let mut seed = id.0 ^ id.1;
    for peer in socket.connected_peers() {
        let peer_id = peer.0.as_u64_pair();
        seed ^= peer_id.0 ^ peer_id.1;
    }
    commands.insert_resource(SessionSeed(seed));

And we just need to declare our new SessionSeed resource:

#[derive(Resource, Default, Clone, Copy, Debug, Deref, DerefMut)]
struct SessionSeed(u64);

Now, all we need to do is simply combine our SessionSeed with the score based seed we already have... so how do we do that? Just xor again:

fn spawn_players(
    mut commands: Commands,
    players: Query<Entity, With<Player>>,
    bullets: Query<Entity, With<Bullet>>,
    scores: Res<Scores>,
    session_seed: Res<SessionSeed>, // <-- new
) {
    // ...
    let mut rng = Xoshiro256PlusPlus::seed_from_u64((scores.0 + scores.1) as u64 ^ **session_seed);
    // ...
}

And finally we have randomized starting positions. That are unique for each match and round :)

Fixing synctest sessions

One more thing before we're done with seeding: In 3.5, we added synctest sessions, since they're started in a different path, we need to make sure the SessionSeed is also inserted start_synctest_session:

fn start_synctest_session(mut commands: Commands, mut next_state: ResMut<NextState<GameState>>) {
    // ...
    commands.insert_resource(bevy_ggrs::Session::SyncTest(ggrs_session));
    commands.insert_resource(SessionSeed(thread_rng().next_u64())); // <-- new
    next_state.set(GameState::InGame);

Here we simply use thread_rng to generate our seed, since there is only one "peer".

Map generation

Ok, so we've gone from a reaction-time game, to a search-and-find-the-other-player game, but it's still missing an important element from the original game: obstacles to hide behind.

The original game solves in a very simple, yet effective way. The maps consist of randomly sized and placed rectangles that can overlap each other.

Let's start by simply generating and showing some rectangles, and then we'll worry about collision detection afterwards.

First we create a new marker component for our walls in components.rs

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

Then we register a new system for generating walls:

// main.rs

// fn main() {
        .add_systems(
            OnEnter(RollbackState::InRound),
            (generate_map, spawn_players.after(generate_map)), // <-- add a new system
        )

We add it to the InRound state so we generate a new map each round. Finally, let's add the system itself:

fn generate_map(
    mut commands: Commands,
    walls: Query<Entity, With<Wall>>,
    scores: Res<Scores>,
    session_seed: Res<SessionSeed>,
) {
    // despawn walls from previous round (if any)
    for wall in &walls {
        commands.entity(wall).despawn_recursive();
    }

    let mut rng = Xoshiro256PlusPlus::seed_from_u64((scores.0 + scores.1) as u64 ^ **session_seed);

    for _ in 0..20 {
        let max_box_size = MAP_SIZE / 4;
        let width = rng.gen_range(1..max_box_size);
        let height = rng.gen_range(1..max_box_size);

        let cell_x = rng.gen_range(0..=(MAP_SIZE - width));
        let cell_y = rng.gen_range(0..=(MAP_SIZE - height));

        let size = Vec2::new(width as f32, height as f32);

        commands.spawn((
            Wall,
            SpriteBundle {
                sprite: Sprite {
                    color: Color::rgb(0.27, 0.27, 0.27),
                    custom_size: Some(size),
                    ..default()
                },
                transform: Transform::from_translation(Vec3::new(
                    cell_x as f32 + size.x / 2. - MAP_SIZE as f32 / 2.,
                    cell_y as f32 + size.y / 2. - MAP_SIZE as f32 / 2.,
                    10.,
                )),
                ..default()
            },
        ));
    }
}

First, we despawn any existing walls from previous rounds, then we generate a random size, and place it somewhere within our map bounds.

If we run the game now, we'll see something map-like:

Collisions

Okay, so we have our map... but how do we make sure the players can't walk through the walls?

One way would be to use a physics engine. While this could in theory work, at the time of writing, neither of the major existing bevy physics engines (bevy_xpbd and bevy_rapier) are sufficiently deterministic or ergonomic for use with rollback networking.

Fortunately, our game doesn't require especially complicated physics, so rolling our own physics implementation really isn't that complicated.

The gist of what we need, is to make sure that if a player is overlapping a wall, they are a pushed away before we render the next frame. Essentially, it's exactly the same thing we're doing with the outer map bounds.

fn resolve_wall_collisions(
    mut players: Query<&mut Transform, With<Player>>,
    walls: Query<(&Transform, &Sprite), (With<Wall>, Without<Player>)>,
) {
    for mut player_transform in &mut players {
        for (wall_transform, wall_sprite) in &walls {
            let wall_size = wall_sprite.custom_size.expect("wall doesn't have a size");
            let wall_pos = wall_transform.translation.xy();
            let player_pos = player_transform.translation.xy();

            let wall_to_player = player_pos - wall_pos;
            // exploit the symmetry of the problem,
            // treat things as if they are in the first quadrant
            let wall_to_player_abs = wall_to_player.abs();
            let wall_corner_to_player_center = wall_to_player_abs - wall_size / 2.;

            let corner_to_corner = wall_corner_to_player_center - Vec2::splat(PLAYER_RADIUS);

            if corner_to_corner.x > 0. || corner_to_corner.y > 0. {
                // no collision
                continue;
            }

            if corner_to_corner.x > corner_to_corner.y {
                // least overlap on x axis
                player_transform.translation.x -= wall_to_player.x.signum() * corner_to_corner.x;
            } else {
                // least overlap on y axis
                player_transform.translation.y -= wall_to_player.y.signum() * corner_to_corner.y;
            }
        }
    }
}

We add it to the schedule after move_players.

                move_players,
                resolve_wall_collisions.after(move_players),

If you run it now, you get a system order ambiguity with fire_bullets. It makes sense that the bullets should be fired from the final player positions, so we also make sure fire_bullets run after resolve_wall_collisions.

                fire_bullets
                    .after(move_players)
                    .after(reload_bullet)
                    .after(resolve_wall_collisions),

And with that we can no longer walk through walls!

Bullet collisions

So while our players can no longer walk through walls, bullets still can.

We can do basically the same thing here:

// fn main() {
                bullet_wall_collisions.after(move_bullet),
// ...

fn bullet_wall_collisions(
    mut commands: Commands,
    bullets: Query<(Entity, &Transform), With<Bullet>>,
    walls: Query<(&Transform, &Sprite), (With<Wall>, Without<Bullet>)>,
) {
    for (bullet_entity, bullet_transform) in &bullets {
        for (wall_transform, wall_sprite) in &walls {
            let wall_size = wall_sprite.custom_size.expect("wall doesn't have a size");
            let wall_pos = wall_transform.translation.xy();
            let bullet_pos = bullet_transform.translation.xy();
            let center_to_center = wall_pos - bullet_pos;
            // exploit symmetry
            let center_to_center = center_to_center.abs();
            let corner_to_center = center_to_center - wall_size / 2.;
            if corner_to_center.x < 0. && corner_to_center.y < 0. {
                // we're inside a wall
                commands.entity(bullet_entity).despawn_recursive();
                break;
            }
        }
    }
}

And now we finally have a functional map:

Despawn bullets outside map

One last thing before we're done. Bullets that are fired outside the map just go on forever, we should despawn them as well.

We can do this by updating the bullet_wall_collisions system to also check if we're outside the map area:

fn bullet_wall_collisions(
    mut commands: Commands,
    bullets: Query<(Entity, &Transform), With<Bullet>>,
    walls: Query<(&Transform, &Sprite), (With<Wall>, Without<Bullet>)>,
) {
    let map_limit = MAP_SIZE as f32 / 2.;

    for (bullet_entity, bullet_transform) in &bullets {
        let bullet_pos = bullet_transform.translation.xy(); // moved

        // despawn bullets outside the map
        if bullet_pos.x.abs() > map_limit || bullet_pos.y.abs() > map_limit {
            commands.entity(bullet_entity).despawn_recursive();
            continue;
        }

        for (wall_transform, wall_sprite) in &walls {
            let wall_size = wall_sprite.custom_size.expect("wall doesn't have a size");
            let wall_pos = wall_transform.translation.xy();
            let center_to_center = wall_pos - bullet_pos;

Conclusion

We've done some very simple procedural generation, but it's had quite an effect on gameplay. We now have something that could actually be fun to play for a little while. Each round is somewhat unique and unpredictable.

And it's somewhat fun to play even with very dull and boring graphics and no sound. I often like to work this way, do most of the core gameplay before adding any polish. It's a good way to verify the game is actually working, and worth polishing.

The next part (which is not out yet) will be all cosmetics. We'll replace our boxes with animated character sprites.

If you've come this far, thanks for reading the whole series! Maintaining and updating it for each Bevy release is quite a lot of work... so if you appreciate my work, I'd really like to hear from you, it's what's encouraging me to keep writing. Leave a comment below, or reach out to me on my discord server.

Reference implementation

Diff for this part

Comments

Loading comments...