Replicon

2024-02-01    5 minute read

Replicon is a bevy-centric interface built on top of the renet networking library. It provides some derivable traits and bevy plugins to apply to your game in, ideally, a very modular and straightforward way.

NOTE: for the following, I'm using bevy version 0.11

First task: Headless dedicated server

In the various bevy networking libraries I've evaluated, the examples tend to be for a "main" client that also performs the role of a server, and "sub" clients that hook into that main client's server to connect to each other. While that's neat, in my case, I want my server to run headlessly on an always-on computer, so for me, it would be nice for the server-side to be in a separate binary with no UI or input functionality. Adapting an example to do this also seems like a good way to learn more about the library.

Turning off display

The bevy repo has a nice example for headless operation.

The important part is:

let refresh = Duration::from_secs_f64(1.0 / 60.0);
MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(refresh))

Though there are some plugins in DefaultPlugins that are required by some of the plugins like rapier-3d that I'm using for server-side game logic checks.

let refresh = Duration::from_secs_f64(1.0 / 60.0);
let app = App::new()
	.add_plugins(DefaultPlugins.build().disable::<WinitPlugin>())
	.add_plugins(ScheduleRunnerPlugin::run_loop(refresh))
;

Refactoring

In preparation for networking my existing game, I reorganized most of my game logic for better separation between the parts that are going to be on the client and server. In the end, of the systems I've implemented so far, there are going to be:

  • some that run in the client, ex: input handling
  • some than run in the server, ex: determining when the game is over
  • some that run in both, ex: most of the game logic

For the most part, I use const generics so that I can keep the number of structs relatively low:

pub struct PlayerPlugin<const IS_SERVER: bool>;

impl<const IS_SERVER: bool> Plugin for PlayerPlugin<IS_SERVER> {
	fn build(&self, app: &mut App) {
		app
			.add_systems(
				Update,
				(
					tick_player.before(bevy_mod_wanderlust::movement),
					ocean_kill,
					draw_gizmos,
					tick_unit,
				).run_if(in_state(GameState::Playing)),
			)
			// ...
		;

		if IS_SERVER {
			app
				.add_systems(
					Update,
					(
						player_events_server,
						confirm_projectile,
					).run_if(in_state(GameState::Playing)),
				)
			;
		}
		else {
			app
				.add_systems(
					Update,
					(
						player_init_system,
						player_events_client,
						tick_player_spell,
					).run_if(in_state(GameState::Playing)),
				)
			;
		}
	}
}

After doing this, I was able to add replicon with relatively little friction. Of course, it didn't work at all straight out of the gate, but at the very least, it compiled. Usually, with a Rust program, that would mean it's mostly working, but I think part of the tradeoff that Bevy makes is that it does a lot of stuff (running systems, managing components) dynamically, so some of Rust's strong typing advantages aren't quite so prominent in their API.

This results in a bit of a strange pattern:

fn player_events_server(
	mut commands: Commands,
	mut player_events: EventReader<FromClient<PlayerEvent>>,
	mut players: Query<(Entity, &UnitId, &mut PlayerState, &mut UnitStateSync)>,
) {
	for FromClient { client_id: _, event } in player_events.iter() {
		player_events_helper(true, event, &mut commands, &mut players);
	}
}

fn player_events_client(
	mut commands: Commands,
	mut player_events: EventReader<PlayerEvent>,
	mut players: Query<(Entity, &UnitId, &mut PlayerState, &mut UnitStateSync)>,
) {
	for event in player_events.iter() {
		player_events_helper(false, event, &mut commands, &mut players);
	}
}

fn player_events_helper(
	is_server: bool,
	event: &PlayerEvent,
	commands: &mut Commands,
	players: &mut Query<(Entity, &UnitId, &mut PlayerState, &mut UnitStateSync)>,
) {
	match event.event_kind {
		PlayerEventKind::Spawn(id) => {
			if is_server {
				commands
					.spawn((
						PlayerBundle::new(id),
						Replication,
					));
			}
		}
		PlayerEventKind::StartAction(idx) => {
			if idx > 4 { return }
			if let Some(mut player) = players.iter_mut().filter(|ea| *ea.1 == event.id).next() {
				player.2.spells[idx].1 = SpellState::Cast;
			}
		}
		PlayerEventKind::FinishAction(idx) => {
			if idx > 4 { return }
			if let Some(mut player) = players.iter_mut().filter(|ea| *ea.1 == event.id).next() {
				player.2.spells[idx].1 = SpellState::Release;
			}
		}
		PlayerEventKind::Moving(dir) => {
			if let Some(mut player) = players.iter_mut().filter(|ea| *ea.1 == event.id).next() {
				player.3.direction_input = dir.clamp_length(0.0, 1.0);
			}
		}
		PlayerEventKind::Facing(dir) => {
			if let Some(mut player) = players.iter_mut().filter(|ea| *ea.1 == event.id).next() {
				player.3.direction_input_2 = dir.clamp_length(0.0, 1.0);
			}
		}
	}
}

Server to client

There are three things you have to do for a bevy Entity to be replicated from your server to your client:

  • Implement serde::{Serialize, Deserialize} for the Component
  • Mark the compenent as replicatable by using App::replicate() when instatiating your app
  • Instantiate the Entity on your server with Replicon's Replication component

To send events from the client to the server, you follow three similar steps:

  • Implement serde::{Serialize, Deserialize} for the Event
  • Mark the event as one that should be sent using App::add_client_event(EventType)
  • The event can be sent using the usual EventWriter<YourEvent> parameter in your client system, but to read these from the server system, you use EventReader<FromClient<YourEvent>>, which wraps the event and bundles it with the client_id

Client to server

For some game events, letting the client send an event and just waiting until the server processes it and sends back a replicated entity is fine, but there are many actions that should be much more responsive, like movement and abilities. Unfortunately, the server will not replicate entities created on the client. After spending an hour perusing through various Overwatch videos to understand better how their netcode works, I found the solution in the Replicon documentation after looking for just a few minutes.

The resource ClientEntityMap allows you to connect an entity on the client with an entity on the server to synchronize with.

Usage is fairly straightforward:

  • Create the entity on the client
  • Send the Entity (a simple ID for the entity for use locally in the client's ECS) to the server, for example by using an event
  • Once it receives the Entity, the server creates a similar entity with all of the components that should be replicated across the server and all the clients
  • Use ClientEntityMap::insert to connect the client Entity with the server Entity (you only have to do this for the client that created the original entity, as the other clients should use the server's entity instead)

For example, I have a projectile that gets instantiated in certain conditions. It is implemented with a cast function that handles all the logic for it, and returns a SpellEvent::Projectile(Spell, UnitId, Entity) where Spell and UnitId is information that the server needs to identically run the cast function on the server side, and the Entity that the client created so that it can connect it to the Entity that the server makes.

TODO:

  • I have a bug where two projectiles are created when two clients are connected to the server when only one should be created
  • How should non-deterministic events be handled? For example, the random spread of a shotgun-like projectile
  • Need to test on a high-latency connection to make sure that the projectile is not rubber-banded on the client side (or at least, any rubber-banding is minimal)
  • Use replicon_snap to interpolate instead of just hard snapping