Cargo Space devlog #2 - ship physics, networking, input

Cargo Space, my coop 2d space game prototype is chugging along. Since last time, I've added physics to ship modules, thrusters, terminals, and peer-to-peer multiplayer. In this post I'll go through the challenges I encountered and how I solved them. And some challenges I solved that I didn't really need to solve...

Making ships move

When I left off last time I was about to implement ship physics and flying around. Most of this was relatively smooth sailing. I simply added mass to the ships and adjusted the physics simulation so corrections resulting from player-ship overlap are applied proportionally to their relative mass. I took this code almost straight out of my bevy_xpbd tutorial.

I also added a quick and dirty version of ship terminals

...and connected them to the thrusters. Basically, when you are close to a terminal and push the "activate" button, the corresponding thruster emits some particles and applies a force to the ship module:

The challenging part, as I anticipated, was to not have the player slide around like crazy when driving the ship.

I took a simple-stupid approach to this, and simply made sure that all acceleration from thrusters on the ship are also applied to the player, so they accelerate "in-sync". Similarly, the ship and the player also has the exact same linear drag (they slow down after a while).

This means that while driving normally the player appears completely unaffected by the ship movements.

The platforming code also had to be adjusted quite a bit, and it's now operating on a "ship-relative velocity" instead of regular velocity.

This seems to work quite well with the current features. I'm curious to see if it works down the line as well.

Making peer-to-peer connections

I used my own Matchbox library for dead-simple matchmaking to establish direct connections between the first two players to start the game. I essentially took the implementation almost straight out of the Matchbox' demo "game".

Now, when I launch the game, it will wait for another player before it goes to the regular test environment.

Yak 1: Porting bevy_ggrs to Bevy 0.9

Now that the peers were connected it was time to start supporting rollback. As I mentioned last time, I was going to use bevy_ggrs for this, but the library hadn't been updated for Bevy 0.9 so I spent some time helping getting the PR merged.

I also spent some time updating my extreme_bevy tutorial series to the new Bevy and bevy_ggrs API, but I'm still waiting for ggrs and bevy_ggrs to publish new crates.io releases before I publish the updated tutorials.

That took up quite some time, but once it was done, I simply copied code over from my own tutorials and demos to get going with a basic rollback session.

Yak 2: Making Leafwing Input Manager play nice with GGRS

So the first thing I needed in order for GGRS sessions to be meaningful was to send some input through. Up until now, I'd used Leafwing Input Manager for input, and that worked great for single player. Networked multiplayer, however was more challenging.

Now I could still use it for detecting local input, so I didn't have to touch keybindings or anything, the problem is that, with GGRS, one needs to serialize input to send it over the network. While Leafwing is probably also built with serialization and networking in mind, it doesn't quite agree with GGRS on how that should happen.

Leafwing Input Manager's ActionState implements Serde's Serialize and Deserialize traits, GGRS on the other hand requires the bytemuck traits, NoUninit CheckedBitPattern Zeroable, which is a perhaps needlessly strict requirement (some progress has been made relaxing it a bit).

Essentially this means the ggrs input struct has to be really simple-stupid. Implementing the various bytemuck traits for ActionState was not something I wanted to try to attempt.

So I ended up with the following struct for GGRS input:

#[repr(C)]
#[derive(
Copy,
Clone,
PartialEq,
Pod,
Zeroable,
Component,
Debug,
Default,
Serialize,
Deserialize,
Reflect,
)]
pub struct GgrsInput {
pub dir: Vec2,
pub activate: u8,
pub jump: u8,
}


And implemented From<ActionState<Action>> for it. That meant my GGRS input system became simply:

fn sample_ggrs_input(_: In<ggrs::PlayerHandle>, action_state: Res<ActionState<Action>>) -> GgrsInput {
action_state.clone().into()
}


The only problem now, though, was that I'd implemented all my systems using things like just_pressed e.g.:

if state.can_jump() && action_state.just_pressed(Action::Jump) {
// ...
}


...and that's not really possible with the GGRS input struct i defined above, as it only contains the most recent input.

I really liked this API of Leafwing's ActionState, and while I could probably have implemented something similar myself by keeping track of previous input states I found out it's also possible to manually update ActionState... and that's just what I did:

pub fn update_action_state_from_ggrs_input(
ggrs_inputs: Res<bevy_ggrs::PlayerInputs<GGRSConfig>>,
mut players: Query<&mut ActionState<Action>, With<Player>>,
) {
use Action::*;

// todo: proper handle -> player mapping
// for now, the first peer controls the player
let (ggrs_input, _input_status) = ggrs_inputs.iter().next().unwrap();

for mut action_state in players.iter_mut() {
// dir
{
let mut a = action_state.action_data_mut(Move);
a.state.tick();
a.axis_pair = Some(DualAxisData::from_xy(ggrs_input.dir));
}

// jump
{
{
let mut a = action_state.action_data_mut(Jump);
a.state.tick();
a.value = ggrs_input.jump as f32;
}
if ggrs_input.jump != 0 {
action_state.press(Jump);
} else {
action_state.release(Jump);
}
}
}
}


This was actually enough to start seeing things happening across the network. One client would now control the player on both screen, while the other player's input was ignored.

Also, one problem was that since the ActionState component was now significant for gameplay, it needed to be part of the world snapshots used when rolling back on mis-predictions. In order to do that, I just had to call register_rollback_component::<ActionState>()... That however, required the component to implement Reflect, which ActionState didn't implement. Fortunately implementing Reflect was a lot easier than Pod, it could simply be derived, so I made a PR to leafwing-input-manager doing just that.

That meant that without changing any of the gameplay systems, I could now move the player around on one client and have it be replicated on the other. However, as expected, there were issues with de-syncing:

As you can see in the video above, the position of the players drifts more and more off as the game progresses.

Adding rollback and hunting for non-determinism

First, I added .register_rollback_component() calls for all the gameplay-significant types, then I moved all the systems operating on them to the GGRS rollback schedule and added Rollback markers on the player and the ship.

However, I was still seeing de-syncs, which I expected were mainly due to system order ambiguities (the order of systems being racy: do you move first, then jump or the other way around?).

To find these issues, I enabled Bevy's built-in diagnostic for system order ambiguities:

app.insert_resource(bevy::ecs::schedule::ReportExecutionOrderAmbiguities);


This essentially gave me a todo-list of things to fix:

2022-12-11T07:56:10.442871Z  INFO bevy_ecs::schedule::ambiguity_detection: Execution order ambiguities detected, you might want to add an explicit dependency relation between some of these systems:
* Parallel systems:
-- "cargo_space::physics::solve_pos_dynamic_tilemaps" and "cargo_space::physics::solve_pos_static_tilemaps"
conflicts: ["cargo_space::physics::components::Pos"]
-- "cargo_space::physics::solve_vel" and "cargo_space::physics::solve_vel_statics"
conflicts: ["cargo_space::physics::components::Vel"]

2022-12-11T07:56:10.443468Z  WARN bevy_ecs::schedule::graph_utils: cargo_space::player_movement::player_run wants to be before unknown label: Step::Integrate
2022-12-11T07:56:10.443600Z  INFO bevy_ecs::schedule::ambiguity_detection: Execution order ambiguities detected, you might want to add an explicit dependency relation between some of these systems:
* Parallel systems:
-- "cargo_space::player_movement::activate_terminals" and "cargo_space::player_movement::update_player_state"
conflicts: ["cargo_space::components::PlayerState"]
-- "cargo_space::player_movement::player_jetpack_movement" and "cargo_space::player_movement::player_jump"
conflicts: ["cargo_space::physics::components::Vel"]
-- "cargo_space::player_movement::player_jetpack_movement" and "cargo_space::player_movement::player_run"
conflicts: ["cargo_space::physics::components::Vel"]
-- "cargo_space::player_movement::player_jetpack_movement" and "cargo_space::player_movement::update_player_state"
conflicts: ["cargo_space::components::PlayerState"]
-- "cargo_space::player_movement::player_jump" and "cargo_space::player_movement::player_run"
conflicts: ["cargo_space::physics::components::Vel"]
-- "cargo_space::player_movement::player_jump" and "cargo_space::player_movement::update_player_state"
conflicts: ["cargo_space::components::PlayerState"]


I'd tried to be somewhat mindful of keeping things deterministic so far. Some things I'd knowingly ignored, but the rest had completely passed me by. So this diagnostic probably saved me a lot of debugging time.

This led to lots of system order changes like this:

    .with_system(
player_jump
// arbitrary ordering for determinism
.after(player_run)
// mutually exclusive since players can't jump while flying
.ambiguous_with(player_jetpack_movement),
)


Some systems I simply mark with ambiguous_with, since while they operate on the same components, they should never operate on the same entities. For other conflicts, I add explicit ordering even though it doesn't conceptually matter. For instance, both player_run and player_jump modify velocity, but in orthogonal ways. However the order of operations and floating point imprecision will still cause non-determinism unless the order is specified, so I just chose an order at random.

Once I had a deterministic networked simulation. Adding multiplayer was ridiculously simple.

I just needed spawn one player for each ggrs peer, map input correctly, and make the camera follow the local player.

Below is the game running cross-platform between Chrome and native Windows.

One player is controlled by my wife on a gamepad, the other by me on the keyboard.

The big picture

This marks an important milestone for the project. I now feel like a lot of the big and scary pieces of this prototyping puzzle has finally fallen into place.

To recap from my earlier posts, the goals for the prototype were:

1. 2-player p2p networking
2. Basic 2D platforming that feels good, even on a moving ship
3. Implement simple physics without rotation or contact friction
4. Make the prototype look good

I'm now actually at a place were all of these are done to some degree.

1. Networking

Networking is working and appears to be completely deterministic. I haven't seen a single de-sync since I went through the list of system order ambiguities even when playing cross-platform. Still, it remains to be seen if this is still the case when I add more complex gameplay and ship-to-ship collisions... and also whether rollback will be cheap enough as the simulation grows in number of simulated entities.

Adding to that, I still haven't tested with people in other countries.

I'm also a bit uncertain about what to do as people lose connections. I had hoped to have long play sessions, so I either need some way to restore game state, or some way for people to reconnect. I expect lots of tricky stuff remains here.

2. Platforming

2D platformer movement is working. It doesn't feel good, though, as is evident in the video above where my wife repeatedly fails to hit the spaceship door.

On the bright side, it doesn't appear to be noteworthy harder to control the player when the ship is moving.

Admittedly, we're moving at pretty low speeds, and it remains to be seen how it feels to run around inside as the ship collides with other ships or asteroids at higher speeds.

Still, it feels as though the really risky parts of this is solved, the rest of this task will probably be unrelated to the ships moving. And I'm relatively confident it's something I'll be able to solve.

3. Physics

Physics also appears to be working as intended. Player-to-ship and player-to-asteroid collision is working, however some features are still not implemented:

• Ship-to-ship collisions
• Ship-to-asteroid collisions
• Fixed joints

I think these should be relatively straightforward to implement, the question on the physics side is more about nice-to-have features like:

• Rotation
• Friction

I will hold off on those, though. They won't make or break the game.

4. "Make the prototype look good"

This is the only task where I've made almost no progress this time. I made the terminal sprites, but otherwise the prototype looks exactly the same as last time. And last time, it also felt like I was lagging behind.

I think the things I've made look promising, but not nearly as good as I want them to. I have a lot of work to do.

Plan forward

The last couple of weeks were pretty scary, as I put the last pieces in the networked demo into place, I was at a point where I'd spent a lot of time on the prototype and was still pretty uncertain about whether it would all work as intended or if I'd have to scrap the project entirely.

Things are looking good, though, and I feel like I can breathe a little more easily.

I hope now that I can relax a bit on the technical side, I'll be able to focus on making the missing player sprites and animations, and improving the game feel.

On the technical side, I don't have much planned, I just want to finish the missing collision checks, experiment with adding a couple more ship module to the mix, and do what needs to be done to improve game feel.

For future updates, join the discord server or follow me on Mastodon