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.6"

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 us the standard gray window 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
   Compiling extreme_bevy v0.1.0 (C:\dev\extreme_bevy)
    Finished dev [unoptimized + debuginfo] target(s) in 1.74s
     Running `wasm-server-runner target\wasm32-unknown-unknown\debug\extreme_bevy.wasm`
 INFO wasm_server_runner: wasm output is 50.05mb 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"

Setting up the camera

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

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

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

fn setup(mut commands: Commands) {
    let mut camera_bundle = OrthographicCameraBundle::new_2d();
    camera_bundle.orthographic_projection.scale = 1. / 50.;
    commands.spawn_bundle(camera_bundle);
}

We’re creating an orthographic camera with scale to $\frac1{50}$. This means that 1 unit in the scene will take up 50 “pixels”. The reason I chose 50, is that 50 seems like a good size for the player character and grid. I prefer working like this because it’s now easy to say that if something 3 units long it’s 3 times longer than the player, rather than some arbitrary “pixel” number that probably doesn’t correspond to pixels anyway because of high-dpi displays and browser zoom.

Adding a player

So it’s still gray. Let’s add a player.

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()
        },
        ..Default::default()
    });
}

We also need to add the system to the schedule. While we’re at it, let’s also make the clear color a slightly lighter gray:

    App::new()
        .insert_resource(ClearColor(Color::rgb(0.53, 0.53, 0.53)))
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .add_startup_system(spawn_player)
        .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()
            },
            ..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.

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.8"
bevy_ggrs = "0.1.3"

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.3", 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::*, tasks::IoTaskPool};
use matchbox_socket::WebRtcNonBlockingSocket;

// ...

        .add_startup_system(start_matchbox_socket)

// ...

fn start_matchbox_socket(mut commands: Commands, task_pool: Res<IoTaskPool>) {
    let room_url = "ws://127.0.0.1:3536/next_2";
    info!("connecting to matchbox server: {:?}", room_url);
    let (socket, message_loop) = WebRtcNonBlockingSocket::new(room_url);

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

    commands.insert_resource(Some(socket));
}

NOTE: your IDE might give you errors at this point (for the message_loop), if it’s configured with native as build target. It should not be a problem when you actually build for wasm, though.

In the above code, we connect to the Matchbox server running on our machine, and ask to join the next_2 room. This is a special type of room that connects pairs of peers together. It’s perfect for just testing that we can get 2-players up and running.

Next, we call WebRtcNonBlockingSocket::new, this is the GGRS-compatible socket type. It returns both a socket, and a massage 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<WebRtcNonBlockingSocket>>) {
    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.

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

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

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

    // consume the socket (currently required because GGRS takes ownership of its socket)
    let socket = socket.take().unwrap();

    let max_prediction = 12;

    // create a GGRS P2P session
    let mut p2p_session =
        ggrs::P2PSession::new_with_socket(num_players as u32, INPUT_SIZE, max_prediction, socket);

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

        if player == PlayerType::Local {
            // set input delay for the local player
            p2p_session.set_frame_delay(2, i).unwrap();
        }
    }

    // start the GGRS session
    commands.start_p2p_session(p2p_session);
}

Here we pass the socket to the ggrs::P2PSession constructor and make sure we tell GGRS about the other player(s). The local player needs a bit of special handling.

We also need to update our uses:

use bevy::{prelude::*, tasks::IoTaskPool};
use bevy_ggrs::*;
use ggrs::PlayerType;
use matchbox_socket::WebRtcNonBlockingSocket;

And finally, there is the matter of the INPUT_SIZE constant. This is the number of bytes one peer’s input is. 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:

const INPUT_SIZE: usize = std::mem::size_of::<u8>();

While we’re at it let’s add some bit mask constants to signify which 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;

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.

We need to make sure we pass the input on to GGRS.

We do this by setting up a designated input system:

fn input(_: In<ggrs::PlayerHandle>, keys: Res<Input<KeyCode>>) -> Vec<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;
    }

    vec![input]
}

And also update move_player to use ggrs::GameInput instead of Inputs<KeyCode>:

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

    let input = inputs[0].buffer[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 add our two new systems in the right place.

First, add the GGRSPlugin

        .add_plugins(DefaultPlugins)
        .add_plugin(GGRSPlugin) // new

Then, add our input system using the special .with_input_system app build method:

        .with_input_system(input)

Then, remove the .add_system(move_player) line, and instead, we’ll create a new schedule for the systems that are affected by rollback:

        .with_rollback_schedule(Schedule::default().with_stage(
            "ROLLBACK_STAGE",
            SystemStage::single_threaded().with_system(move_player),
        ))

Finally, the game should compile again.

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()
            },
            ..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()
            },
            ..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()
            },
            ..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<ggrs::GameInput>>,
    mut player_query: Query<(&mut Transform, &Player)>,
) {
    for (mut transform, player) in player_query.iter_mut() {
        let input = inputs[player.handle].buffer[0];

        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 cube-moving game.

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

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

Reference implementation

Comments

Loading comments...