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 = from_secs_f64;
MinimalPlugins.set
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 = from_secs_f64;
let app = new
.add_plugins
.add_plugins
;
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:
;
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:
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 theComponent
- Mark the compenent as replicatable by using
App::replicate()
when instatiating your app - Instantiate the
Entity
on your server with Replicon'sReplication
component
To send events from the client to the server, you follow three similar steps:
- Implement
serde::{Serialize, Deserialize}
for theEvent
- 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 useEventReader<FromClient<YourEvent>>
, which wraps the event and bundles it with theclient_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 clientEntity
with the serverEntity
(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