johan helsing.studio

Extreme Bevy: Making a p2p web game with rust and rollback netcode

In my previous post, Introducing Matchbox, I explained how Matchbox solves the problem of setting up peer-to-peer connections in rust web assembly for implementing low-latency multiplayer web games. I said I’d start making games using it and I figured it’s about time I make good on that promise, as well as write a tutorial while at it. I’ll explain step-by-step how to use Bevy, GGRS, and Matchbox to recreate the old classic Extreme Violence by Simon Green with online p2p multiplayer using rollback netcode.

Extreme Violence

Extreme Violence is one of my early childhood memories. It’s the Amiga game that had it all: great graphics, cool sound effects and intense multiplayer action.

In the author, Simon Green’s, own words, the game is built around “idiot-proof run-around-and-shoot-the-other-guy gameplay”. Apart from the opportunity to go on about the good ol’ days, I chose it as it is a test project because it’s simple, but compared to other simple games, it’s still fun to play (yes, looking at you, Pong!).

You can probably tell what the gameplay is about just by looking at the screenshot above.

Starting a project

Start a new rust project

cargo new extreme_bevy

We’ll be using Bevy, it’s a great game engine built around the Entity Component System (ECS) architecture. I won’t go into great detail about Bevy here, though. If you find some of the tutorial hard to follow, the Bevy Book is a good place to start.

Add Bevy in Cargo.toml:

[dependencies]
bevy = "0.8"

Next, replace main.rs with the standard Bevy boiler-plate:

use bevy::prelude::*;

fn main() {
    App::new().add_plugins(DefaultPlugins).run();
}

That should be enough to get a window up and running if we do cargo run.

Web build

But wait! We’re making a web game, remember? Not a native one.

First, make sure you have a rust wasm toolchain installed. With rustup, it’s simply:

rustup target install wasm32-unknown-unknown

That’s enough to build the project for the web.

cargo build --target wasm32-unknown-unknown

However, that will just leave us with a wasm file in the target directory. In order to easily test our project while developing, we’ll install wasm-server-runner:

cargo install wasm-server-runner

And configure our project to use it by adding a new file, .cargo/config.toml:

[target.wasm32-unknown-unknown]
runner = "wasm-server-runner"

Now, when we run the project for the wasm target, it will start a local web server and log the link in the terminal:

$ cargo run --target wasm32-unknown-unknown
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `wasm-server-runner target\wasm32-unknown-unknown\debug\extreme_bevy.wasm`
 INFO wasm_server_runner: compressed wasm output is 11.67mb large
 INFO wasm_server_runner::server: starting webserver at http://127.0.0.1:1334

And we have a link to open in the browser, and it might even launch, but if it crashes the tab with a message saying it’s out of memory, don’t worry. It’s simply because debug builds are ridiculously large.

Make a release build instead:

cargo run --release --target wasm32-unknown-unknown

If you open the link, the “game” should start in the browser:

Before we go on, I have one more tip that will make things easier: Install cargo-watch as well:

cargo install cargo-watch

With it, you can detect changes in the project directory, and automatically rebuild and relaunch the server:

cargo watch -cx "run --release --target wasm32-unknown-unknown"

Now, when you want to test a change, you simply have to save and refresh your browser.

If you find it tiresome to type wasm32-unknown-unknown all the time, you can set it as your default target in .cargo/config.toml:

[build]
target = "wasm32-unknown-unknown"

And start developing with:

cargo watch -cx "run --release"

Filling the whole tab

So you might have notices that in the browser our window doesn’t fill the whole tab.

This is because bevy/winit for some peculiar reason has chosen to let the app control the size in a given number of device pixels. By default the number of device pixels is 1280x720, however, there is a pretty low chance that the browser tab window will be exactly 1280x720, or whatever other number we might choose.

We can fix this by telling bevy to resize its canvas to the parent html element:

    App::new()
        .insert_resource(WindowDescriptor {
            // fill the entire browser window
            fit_canvas_to_parent: true,
            ..default()
        })
        .add_plugins(DefaultPlugins)
        .run();

Now you can resize the tab, and the canvas should still fill the entire area.

Setting up the camera

With the boring build stuff out of the way, let’s actually start putting content into our black window.

First, add a setup system that initializes a camera and a player sprite

use bevy::{prelude::*, render::camera::ScalingMode};

// fn main() {
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup) // <-- NEW
        .run();

// ...

fn setup(mut commands: Commands) {
    let mut camera_bundle = Camera2dBundle::default();
    camera_bundle.projection.scaling_mode = ScalingMode::FixedVertical(10.);
    commands.spawn_bundle(camera_bundle);
}

We’re creating a 2d camera with a fixed vertical size of 10. Our character will be 1 unit tall, so this means our window will always be “10 players tall”. This means that you can’t just resize the window in order to see more of the game world. Players with ultra-wide monitors will have a slight advantage, though.

Adding a player

So now that we added a camera, the window is gray instead of black. This is because gray is the default clear color (background) for Bevy cameras.

Let’s add a player to this empty gray area:

fn spawn_player(mut commands: Commands) {
    commands.spawn_bundle(SpriteBundle {
        sprite: Sprite {
            color: Color::rgb(0., 0.47, 1.),
            custom_size: Some(Vec2::new(1., 1.)),
            ..default()
        },
        ..default()
    });
}

We also need to add the system to the schedule. And while we’re there, let’s also make the clear color a slightly lighter gray:

    App::new()
        .insert_resource(WindowDescriptor {
            // fill the entire browser window
            fit_canvas_to_parent: true,
            ..default()
        })
        .insert_resource(ClearColor(Color::rgb(0.53, 0.53, 0.53))) // <-- NEW
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .add_startup_system(spawn_player) // <-- NEW
        .run();

And now we have our little guy

Let’s make them move on keyboard input:

fn move_player(keys: Res<Input<KeyCode>>, mut player_query: Query<&mut Transform, With<Player>>) {
    let mut direction = Vec2::ZERO;
    if keys.any_pressed([KeyCode::Up, KeyCode::W]) {
        direction.y += 1.;
    }
    if keys.any_pressed([KeyCode::Down, KeyCode::S]) {
        direction.y -= 1.;
    }
    if keys.any_pressed([KeyCode::Right, KeyCode::D]) {
        direction.x += 1.;
    }
    if keys.any_pressed([KeyCode::Left, KeyCode::A]) {
        direction.x -= 1.;
    }
    if direction == Vec2::ZERO {
        return;
    }

    let move_speed = 0.13;
    let move_delta = (direction * move_speed).extend(0.);

    for mut transform in player_query.iter_mut() {
        transform.translation += move_delta;
    }
}

This system simply samples the keyboard and moves any objects with the Player marker component in the given direction.

Add the Player marker component:

#[derive(Component)]
struct Player;

And make sure to add it to the player entity:

fn spawn_player(mut commands: Commands) {
    commands
        .spawn_bundle(SpriteBundle {
            sprite: Sprite {
                color: Color::rgb(0., 0.47, 1.),
                custom_size: Some(Vec2::new(1., 1.)),
                ..default()
            },
            ..default()
        })
        .insert(Player); // <-- NEW
}

And register our new system:

        .add_startup_system(spawn_player)
        .add_system(move_player) // <-- NEW
        .run()

And now you should be able to move the sprite around the screen with the keyboard.

You can click the embed above to run it in your browser.

Adding rollback networking libraries

Ok, now that we have something that is at least somewhat interactive, let’s see how we can add multiplayer to it.

We’ll be using rollback networking with GGRS. What this means, is that we will only be synchronizing players inputs and no actual game state. Each time a peer receives input from another peer, it will roll back its game state to an earlier point in time, insert the input and then re-simulate the frames as if that input had been there all along.

This has an important implication for how we write our game: Everything needs to be perfectly deterministic. Fortunately, rust and wasm makes this easy for us, and the rewards for doing so are big:

  1. No state synchronization aside from input ⇒ very low bandwidth
  2. We can write the entire game in simple “forward” systems. No need to handle de-synced inputs. It’s all done automatically for us by rolling back and re-simulating.

Let’s go ahead and add ggrs as well as bevy_ggrs to our dependencies:

ggrs = "0.9"
bevy_ggrs = { version = "0.10", features = ["wasm-bindgen"] }

NOTE: bevy_ggrs needs the wasm-bindgen feature to work properly on wasm, otherwise connections will close after a short while.

Connecting players

Before we can start using GGRS, we need some kind of connection between the players. GGRS comes with built-in support for UDP, however that doesn’t work in the browser. This is where Matchbox comes in.

Matchbox is a wrapper around the WebRTC browser API and uses its data channels to achieve UDP-like (unordered, unreliable) connections between two browsers.

The project comes in two parts. matchbox_server is a tiny server that needs to run somewhere reachable by both browsers. In production, this would be somewhere on the web, for now we’ll just run it locally.

Go ahead and install and run matchbox_server:

cargo install matchbox_server
matchbox_server

Just leave it running in a terminal while developing.

The second part of Matchbox is matchbox_socket, which is used to briefly connect to the matchbox_server in order to bootstrap direct connections to other peers.

So let’s add it to our dependencies, and specify the ggrs-socket feature flag, which makes it compatible with GGRS.

matchbox_socket = { version = "0.4", features = ["ggrs-socket"] }

Now, we’re ready to create a new system that connects to the Matchbox server to establish direct connections to other clients.

use bevy::{prelude::*, render::camera::ScalingMode, tasks::IoTaskPool};
use matchbox_socket::WebRtcSocket;

// ...

        .add_startup_system(start_matchbox_socket)

// ...

fn start_matchbox_socket(mut commands: Commands) {
    let room_url = "ws://127.0.0.1:3536/extreme_bevy?next=2";
    info!("connecting to matchbox server: {:?}", room_url);
    let (socket, message_loop) = WebRtcSocket::new(room_url);

    // The message loop needs to be awaited, or nothing will happen.
    // We do this here using bevy's task system.
    IoTaskPool::get().spawn(message_loop).detach();

    commands.insert_resource(Some(socket));
}

TODO: In the above code, we connect to the Matchbox server running on our machine, and ask to join the extreme_bevy?next=2 room. The first part before the question mark, is an id. This means you can use the same matchbox instance for different scopes. This could be useful if you want to use the same matchbox server for several types of games, or different game modes, or different versions of the game etc.

The second part, next=2 is a special type of room that connects pairs of peers together. The first two peers to connect will be paired, and then the third and fourth will be paired and so on. It’s perfect for just testing that we can get 2-players up and running.

Next, we call WebRtcSocket::new, a GGRS-compatible socket type. It returns both a socket, and a message loop future that needs to be awaited in order to process network events. We await it using Bevy’s task system.

Finally, we insert the socket as an optional resource, so we can access it in other systems.

Let’s create such a system where we just wait until we’ve established a peer connection and then log to the console so can make sure everything works so far:

        .add_system(wait_for_players)

// ...

fn wait_for_players(mut socket: ResMut<Option<WebRtcSocket>>) {
    let socket = socket.as_mut();

    // If there is no socket we've already started the game
    if socket.is_none() {
        return;
    }

    // Check for new connections
    socket.as_mut().unwrap().accept_new_connections();
    let players = socket.as_ref().unwrap().players();

    let num_players = 2;
    if players.len() < num_players {
        return; // wait for more players
    }

    info!("All peers have joined, going in-game");
    // TODO
}

If you start two browsers side-by-side (note they need to both be visible! otherwise the browser will throttle the tab), you should see the message spammed in the console a little while after the second browser loads.

*NOTE: There currently is a bug with matchbox on Firefox that sometimes makes connection drop immediately after connecting. If you experience this, either try another browser or install the media panel extension for Firefox (yes, this is a heisenbug, which disappears when you look at it)*

Ok, so we have a connection, time to hand it over to GGRS!

fn wait_for_players(mut commands: Commands, mut socket: ResMut<Option<WebRtcSocket>>) {
    
    // ...

    info!("All peers have joined, going in-game");

    // create a GGRS P2P session
    let mut session_builder = ggrs::SessionBuilder::<GgrsConfig>::new()
        .with_num_players(num_players)
        .with_input_delay(2);

    for (i, player) in players.into_iter().enumerate() {
        session_builder = session_builder
            .add_player(player, i)
            .expect("failed to add player");
    }

    // move the socket out of the resource (required because GGRS takes ownership of it)
    let socket = socket.take().unwrap();

    // start the GGRS session
    let session = session_builder
        .start_p2p_session(socket)
        .expect("failed to start session");

    commands.insert_resource(session);
    commands.insert_resource(SessionType::P2PSession);
}

Here we start creating a bevy_ggrs session using its SessionBuilder. Ggrs need to know how many players there will be, and we also specify an input delay of 2 frames, the reason we do this, is to minimize the amount of rollback the other peer has to do if the inputs arrives late and doesn’t align with predicted input. i.e. it’s a trade-off between snapping and responsiveness. 2 frames is ~33ms, and is a good middle ground in most cases.

Then, we add the players to the session, the players we get from WebRtcSocket::players(), contain everything we need, we just need to assign a handle to each of them. We just assign integers in the order that they arrive.

Lastly, we pass the socket to SessionBuilder::start_p2p_session, and insert the session and its type as a bevy resources.

We also need to update our uses:

use bevy::{prelude::*, render::camera::ScalingMode, tasks::IoTaskPool};
use bevy_ggrs::*;
use matchbox_socket::WebRtcSocket;

This won’t compile just yet. The GGRS session builder also takes a generic type parameter, GgrsConfig, which we have to define. This struct implements a trait that tells GGRS about what kind of types our game uses.

struct GgrsConfig;

impl ggrs::Config for GgrsConfig {
    // 4-directions + fire fits easily in a single byte
    type Input = u8;
    type State = u8;
    // Matchbox' WebRtcSocket addresses are strings
    type Address = String;
}

In our case, the input consists of four direction buttons, and eventually the fire button as well. This means it fits easily within a single byte.

Now that we have a session up and running let’s start making sure our gameplay systems makes use of them.

Currently, we have a move_player system, but it just reads input directly from the keyboard and moves the local player only, so the GGRS session doesn’t do anything useful yet. Our input needs to be encoded to the u8 we defined in the GgrsConfig type and handed over to GGRS.

Let’s first define some bit mask constants to signify what bit means what:

const INPUT_UP: u8 = 1 << 0;
const INPUT_DOWN: u8 = 1 << 1;
const INPUT_LEFT: u8 = 1 << 2;
const INPUT_RIGHT: u8 = 1 << 3;
const INPUT_FIRE: u8 = 1 << 4;

We now move the input sampling from move_player into a special input system. This system need to return the same type we defined in our GgrsConfig type, a u8.

fn input(_: In<ggrs::PlayerHandle>, keys: Res<Input<KeyCode>>) -> u8 {
    let mut input = 0u8;

    if keys.any_pressed([KeyCode::Up, KeyCode::W]) {
        input |= INPUT_UP;
    }
    if keys.any_pressed([KeyCode::Down, KeyCode::S]) {
        input |= INPUT_DOWN;
    }
    if keys.any_pressed([KeyCode::Left, KeyCode::A]) {
        input |= INPUT_LEFT
    }
    if keys.any_pressed([KeyCode::Right, KeyCode::D]) {
        input |= INPUT_RIGHT;
    }
    if keys.any_pressed([KeyCode::Space, KeyCode::Return]) {
        input |= INPUT_FIRE;
    }

    input
}

Now that we’re properly sending input, we also need to make sure that we’re applying it correctly. We update move_player to use inputs from GGRS instead of Inputs<KeyCode>:

fn move_player(
    inputs: Res<Vec<(u8, InputStatus)>>,
    mut player_query: Query<&mut Transform, With<Player>>,
) {
    let mut direction = Vec2::ZERO;

    let (input, _) = inputs[0];

    if input & INPUT_UP != 0 {
        direction.y += 1.;
    }
    if input & INPUT_DOWN != 0 {
        direction.y -= 1.;
    }
    if input & INPUT_RIGHT != 0 {
        direction.x += 1.;
    }
    if input & INPUT_LEFT != 0 {
        direction.x -= 1.;
    }
    if direction == Vec2::ZERO {
        return;
    }

    let move_speed = 0.13;
    let move_delta = (direction * move_speed).extend(0.);

    for mut transform in player_query.iter_mut() {
        transform.translation += move_delta;
    }
}

Now we need to actually add the bevy plugin for GGRS, GGRSPlugin and add our two new systems in the right place.

The initialization of GGRS is a bit special compared to most Bevy plugins:

    let mut app = App::new();

    GGRSPlugin::<GgrsConfig>::new()
        .with_input_system(input)
        .with_rollback_schedule(Schedule::default().with_stage(
            "ROLLBACK_STAGE",
            SystemStage::single_threaded().with_system(move_player),
        ))
        .build(&mut app);

    app.insert_resource(ClearColor(Color::rgb(0.53, 0.53, 0.53)))
        .insert_resource(WindowDescriptor {
            // fill the entire browser window
            fit_canvas_to_parent: true,
            ..default()
        })
        .insert_resource(ClearColor(Color::rgb(0.53, 0.53, 0.53)))
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .add_startup_system(start_matchbox_socket)
        .add_startup_system(spawn_player)
        .add_system(wait_for_players)
        .add_system(move_player)
        .run();
}

It’s a builder that needs a &mut App. So we assign App::new() to a mut app. We then create a GGRSPlugin with it, telling it about our config and input system. It also needs to know about all the systems that are affected by rollback, currently only move_player. They are added to a special rollback schedule, that can run 0 or more times per frame, depending on whether the game had to roll back or not.

If you play it now, you can see that one of the players should be able to control the blue box, while the other is not. So we’re properly sending input!

Actually implementing rollback

While are sending input we’re still not actually using rollback yet. We need to tell bevy_ggrs what kind of entities and components should be rolled back. We don’t necessarily want to roll back everything, so this is a good thing!

First, we need to register the types we are interested in rolling back. So far Transform is the only thing that actually changes, so we add that

        .with_rollback_schedule(Schedule::default().with_stage(
            "ROLLBACK_STAGE",
            SystemStage::single_threaded().with_system(move_player),
        ))
        .register_rollback_type::<Transform>() // <-- NEW

As we add more components to our game, we need to add them here if they should be rolled back.

Second, we also need entities to opt in to being rolled back. We do this by adding a Rollback component to them. The Rollback component needs a unique id, the best way to get this, is using the RollbackIdProvider that comes with GGRS. Let’s add the Rollback component when we create our player:

fn spawn_player(mut commands: Commands, mut rip: ResMut<RollbackIdProvider>) {
    commands
        .spawn_bundle(SpriteBundle {
            sprite: Sprite {
                color: Color::rgb(0., 0.47, 1.),
                custom_size: Some(Vec2::new(1., 1.)),
                ..default()
            },
            ..default()
        })
        .insert(Player)
        .insert(Rollback::new(rip.next_id()));
}

And with that, the player should actually be rolling back. Since we’re running this locally, the latency is very low, and it should probably behave exactly the same as before.

Adding the other player

What we have now is essentially just a single player game with a spectator, which is kind of cool, but what we want is a multiplayer game, so let’s rename our system to spawn_players and spawn the other player as well, and put them some distance apart from each other.

fn spawn_players(mut commands: Commands, mut rip: ResMut<RollbackIdProvider>) {
    // Player 1
    commands
        .spawn_bundle(SpriteBundle {
            transform: Transform::from_translation(Vec3::new(-2., 0., 0.)),
            sprite: Sprite {
                color: Color::rgb(0., 0.47, 1.),
                custom_size: Some(Vec2::new(1., 1.)),
                ..default()
            },
            ..default()
        })
        .insert(Player)
        .insert(Rollback::new(rip.next_id()));

    // Player 2
    commands
        .spawn_bundle(SpriteBundle {
            transform: Transform::from_translation(Vec3::new(2., 0., 0.)),
            sprite: Sprite {
                color: Color::rgb(0., 0.4, 0.),
                custom_size: Some(Vec2::new(1., 1.)),
                ..default()
            },
            ..default()
        })
        .insert(Player)
        .insert(Rollback::new(rip.next_id()));
}

Now we should at least see two players on screen.

However they’re still both controlled by only one of the players input.

We need some way to differentiate between players. So let’s go ahead and add a number to our Player struct:

#[derive(Component)]
struct Player {
    handle: usize
};

// ...
// spawn_players()
            .insert(Player { handle: 0 })

// ...
            .insert(Player { handle: 1 })

And in move_player which we will now rename to move_players, let’s use these new handles to index the inputs vector:

fn move_players(
    inputs: Res<Vec<(u8, InputStatus)>>,
    mut player_query: Query<(&mut Transform, &Player)>,
) {
    for (mut transform, player) in player_query.iter_mut() {
        let (input, _) = inputs[player.handle];

        let mut direction = Vec2::ZERO;

        if input & INPUT_UP != 0 {
            direction.y += 1.;
        }
        if input & INPUT_DOWN != 0 {
            direction.y -= 1.;
        }
        if input & INPUT_RIGHT != 0 {
            direction.x += 1.;
        }
        if input & INPUT_LEFT != 0 {
            direction.x -= 1.;
        }
        if direction == Vec2::ZERO {
            continue;
        }

        let move_speed = 0.13;
        let move_delta = (direction * move_speed).extend(0.);

        transform.translation += move_delta;
    }
}

And with that we finally have a multiplayer box-moving game.

You can start the embeds below to try out what we’ve got so far:

Player 1:

Player 2:

This wraps up the basics. We now have a starting point to expand upon.

In the next tutorial, we will look at adding a background, map edges, and making the camera follow the local player.

Reference implementation

Comments

Loading comments...