Introduction
For a quick tutorial about how to use the crate, click here.
What is lightyear?
Lightyear is a networking library for games written in Bevy. It uses a client-server networking architecture, where the server is authoritative over the game state.
It is heavily inspired by naia.
What is this book about?
This book serves several purposes:
- It contains some explanations of game networking concepts, as well as how they are implemented in this crate
- provide some examples of how to use the crate
- explain some of the design decisions that were made
This book does not aim to be a polished document, or a comprehensive reference for lightyear. It is more of a collection of notes and thoughts that I had while developing the crate; a networking-related wiki that I could reference later.
Tutorial
This section will teach you how to quickly setup networking in your bevy game using this crate.
You can find an example game in the examples folder.
In this tutorial, we will reproduce the simple box example to demonstrate the features of this crate.
We will build a simple game where each client can move a "box" on the screen, the box will be replicated to the server and to all other clients.
General architecture
Lightyear
essentially provides 2 plugins that will handle every networking-related concern for you: a ClientPlugin
and a ServerPlugin
.
The plugins will define various resources and systems that will handle the connection to the server. Some of the notable resources are:
- the TickManager:
lightyear
usesTicks
to handle synchronization between the client and server. TheTick
is basically the fixed-timestep unit of simulation, it gets incremented by 1 every time the FixedUpdate schedule runs. TheTickManager
has the tick method to return the current client or server tick. (depending on which plugin you are using) - the ClientConnectionManager or ServerConnectionManager which are used to send messages to the remote.
- the ClientConnection or
ServerConnection which
handle the general io connection. You can use them to get the current
ClientId
or to check that the connection is still alive. - the InputManager lets you send inputs from the client to the server
There are many different sub-plugins but the most important things that lightyear
handles for you are probably:
- the sending and receiving of messages.
- automatic replication of the World from the server to the client
- handling the inputs from the user.
Example code organization
In the most basic setup, you will run 2 separate apps: one for the client and one for the server.
(You can also run both in the same app in what is called HostServer
mode, but we will not cover that in this
tutorial.)
The simple_box
example has the following structure:
main.rs
: this is where we read the settings file fromassets/settings.ron
and create the client or server app depending on the passed CLI arguments.settings.rs
: here we parse thesettings.ron
file and have helpers to create theClientConfig
andServerConfig
structs which are all that is required to build aClientPlugin
or aServerPlugin
protocol.rs
: here we define a shared protocol, which is basically the list of messages, components and inputs that can be sent between the client and server.shared.rs
: this is where we define shared behaviour between the client and server. For example some simulation logic (physics/movement) should be shared between the client and server.client.rs
: this is where we define client-specific logic (input-handling, client-prediction, etc.)server.rs
: this is where we define server-specific logic (spawning players for newly-connected clients, etc.)
Defining a protocol
First, you will need to define a protocol for your game. (see here in the example) This is where you define the "contract" of what is going to be sent across the network between your client and server.
A protocol is composed of
- Input: Defines the client's input type, i.e. the different actions that a user can perform (e.g. move, jump, shoot, etc.).
- Message: Defines the message protocol, i.e. the messages that can be exchanged between the client and server.
- Components: Defines the component protocol, i.e. the list of components that can be replicated between the client and server.
- Channels: Defines channels that are used to send messages between the client and server.
A Message
is any struct that is Serialize + Deserialize + Clone
.
Components
The ComponentRegistry
is needed for automatic World replication: automatically replicating entities and components
from the server's World
to the client's World
.
Only the components that are defined in the ComponentRegistry
will be replicated.
The ComponentRegistry
is a Resource
that will store metadata about which components should be replicated and how.
It can also contain additional metadata for each component, such as prediction or interpolation settings.
lightyear
provides helper functions on the App
to register components to the ComponentRegistry
.
Let's define our components protocol:
#![allow(unused)] fn main() { /// A component that will identify which player the box belongs to #[derive(Component, Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct PlayerId(ClientId); /// A component that will store the position of the box. We could also directly use the `Transform` component. #[derive(Component, Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct PlayerPosition(Vec2); /// A component that will store the color of the box, so that each player can have a different color. #[derive(Component, Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct PlayerColor(pub(crate) Color); pub struct ProtocolPlugin; impl Plugin for ProtocolPlugin{ fn build(&self, app: &mut App) { app.register_component::<PlayerId>(ChannelDirection::ServerToClient) .add_prediction(ComponentSyncMode::Once) .add_interpolation(ComponentSyncMode::Once); app.register_component::<PlayerPosition>(ChannelDirection::ServerToClient) .add_prediction(ComponentSyncMode::Full) .add_interpolation(ComponentSyncMode::Full) .add_linear_interpolation_fn(); app.register_component::<PlayerColor>(ChannelDirection::ServerToClient) .add_prediction(ComponentSyncMode::Once) .add_interpolation(ComponentSyncMode::Once); } } }
Message
Similarly, the MessageProtocol
is an enum containing the list of possible Messages
that can be sent over the
network. When registering a message, you can specify the direction in which the message should be sent.
Let's define our message protocol:
/// We don't really use messages in the example, but here is how you would define them.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Message1(pub usize);
/// Again, you need to use the macro `message_protocol` to define a message protocol.
#[message_protocol(protocol = "MyProtocol")]
pub enum Messages {
Message1(Message1),
}
impl Plugin for ProtocolPlugin{
fn build(&self, app: &mut App) {
// Register messages
app.add_message::<Message1>(ChannelDirection::Bidirectional);
// Register components
...
}
}
Inputs
Lightyear handles inputs (the user actions that should be sent to the server) for you, you just need to define the list of possible inputs (like the message or component protocols).
(it is recommended to use the leafwing
feature to handle inputs with leafwing-input-manager
, but we will not cover
that in this tutorial)
Let's define our inputs:
#![allow(unused)] fn main() { /// The different directions that the player can move the box #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub struct Direction { pub(crate) up: bool, pub(crate) down: bool, pub(crate) left: bool, pub(crate) right: bool, } /// The `InputProtocol` needs to be an enum of the various inputs that the client can send to the server. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub enum Inputs { Direction(Direction), Delete, Spawn, /// NOTE: we NEED to provide a None input so that the server can distinguish between lost input packets and 'None' inputs None, } impl Plugin for ProtocolPlugin{ fn build(&self, app: &mut App) { // Register inputs app.add_plugins(InputPlugin::<Inputs>::default()); // Register messages ... // Register components ... } } }
Inputs have to implement the UserAction
trait, which means that they must be Send + Sync + 'static
and can be serialized.
Channels
We can also define some channels that will be used to send messages between the
client and server.
This is optional, since lightyear
already provides some default channels for inputs and components.
A Channel
defines some properties of how messages will be sent over the network:
- reliability: can the messages be lost or do we re-send them until we receive an ACK?
- ordering: do we guarantee that the messages are received in the same order that they were sent?
- priority: do we want to increase the priority of some messages in case the network is congested?
/// A channel is basically a ZST (Zero Sized Type) with the `Channel` derive macro.
#[derive(Channel)]
pub struct Channel1;
pub(crate) struct ProtocolPlugin;
impl Plugin for ProtocolPlugin {
fn build(&self, app: &mut App) {
// channels
app.add_channel::<Channel1>(ChannelSettings {
mode: ChannelMode::OrderedReliable(ReliableSettings::default()),
..default()
});
// register messages, inputs, components
...
}
}
We create a channel by simply deriving the Channel
trait on an empty struct.
Protocol
The complete protocol looks like this:
#![allow(unused)] fn main() { pub(crate) struct ProtocolPlugin; impl Plugin for ProtocolPlugin { fn build(&self, app: &mut App) { // messages app.add_message::<Message1>(ChannelDirection::Bidirectional); // inputs app.add_plugins(InputPlugin::<Inputs>::default()); // components app.register_component::<PlayerId>(ChannelDirection::ServerToClient) .add_prediction::<PlayerId>(ComponentSyncMode::Once) .add_interpolation::<PlayerId>(ComponentSyncMode::Once); app.register_component::<PlayerPosition>(ChannelDirection::ServerToClient) .add_prediction::<PlayerPosition>(ComponentSyncMode::Full) .add_interpolation::<PlayerPosition>(ComponentSyncMode::Full) .add_linear_interpolation_fn::<PlayerPosition>(); app.register_component::<PlayerColor>(ChannelDirection::ServerToClient) .add_prediction::<PlayerColor>(ComponentSyncMode::Once) .add_interpolation::<PlayerColor>(ComponentSyncMode::Once); // channels app.add_channel::<Channel1>(ChannelSettings { mode: ChannelMode::OrderedReliable(ReliableSettings::default()), ..default() }); } } }
Summary
We now have a complete Protocol
that defines:
- the data that can be sent between the client and server (inputs, messages, components)
- how the data will be sent (channels)
We can now start building our client and server Plugins.
Setting up the client and server
Client
A client is simply a bevy plugin: ClientPlugin
You create it by providing a ClientConfig
struct.
You can see how it is defined in the example here.
Shared Config
Some parts of the configuration must be shared between the server and the client to work correctly, so we define them in a separate function that can be re-used for both:
#![allow(unused)] fn main() { pub fn shared_config(mode: Mode) -> SharedConfig { SharedConfig { /// How often the client will send packets to the server (by default it is every frame). /// Currently, the client only works if it sends packets every frame, for proper input handling. client_send_interval: Duration::default(), /// How often the server will send packets to clients? You can reduce this to save bandwidth. server_send_interval: Duration::from_millis(40), /// The tick rate that will be used for the FixedUpdate schedule tick: TickConfig { tick_duration: Duration::from_secs_f64(1.0 / 64.0), }, /// Here we make the `Mode` an argument so that we can run `lightyear` either in `Separate` mode (distinct client and server apps) /// or in `HostServer` mode (the server also acts as a client). mode, } } }
ClientConfig
The ClientConfig struct lets us configure the client. There are a lot of parameters that can be configured, but for this demo we will mostly use the defaults.
let client_config = client::ClientConfig {
shared: shared_config(Mode::Separate),
net: net_config,
..default()
};
let client_plugin = client::ClientPlugin::new(client_config);
The NetConfig doesn't have any Default value and needs to be provided; it defines how (i.e. what transport layer) the client will connect to the server.
There are multiple options available, but for this demo we will use the Netcode
option.
netcode is a standard to establish a connection between two hosts, and we can use any io layer (UDP, WebSocket, WebTransport, etc.) to send the actual bytes.
You will need to provide the IoConfig which defines the transport layer (how the raw packets are sent), with the possibility of using a LinkConditionerConfig to simulate network conditions. Here are the different possible transport options: TransportConfig
/// You can add a link conditioner to simulate network conditions
let link_conditioner = LinkConditionerConfig {
incoming_latency: Duration::from_millis(100),
incoming_jitter: Duration::from_millis(0),
incoming_loss: 0.00,
};
/// Here we use the `UdpSocket` transport layer, with the link conditioner
let io_config = IoConfig::from_transport(TransportConfig::UdpSocket(addr))
.with_conditioner(link_conditioner);
With the Netcode
option, we use a ConnectToken to secure the connection.
Normally, a third-party server would generate the ConnectToken
and send it securely to the client.
For this demo, we will use the Manual
option, which lets us manually build a ConnectToken
on the client using a private key shared between the client and the server.
let server_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), server_port);
let auth = Authentication::Manual {
// server's IP address
server_addr,
// ID to uniquely identify the client
client_id: client_id,
// private key shared between the client and server
private_key: KEY,
// PROTOCOL_ID identifies the version of the protocol
protocol_id: PROTOCOL_ID,
};
Now we can build the complete NetConfig
:
let net_config = NetConfig::Netcode {
auth,
io: io_config,
..default()
};
Server
Building the server is very similar to building the client; we need to provide a ServerConfig
struct.
let server_config = server::ServerConfig {
shared: shared_config(Mode::Separate),
net: net_configs,
..default()
};
let server_plugin = server::ServerPlugin::new(server_config);
The server can listen for client connections using multiple transports at the same time! You can do this by providing multiple NetConfig to the server.
The simple_box
example generates the various NetConfig
s by parsing the settings.ron
file, but you can also just
define them manually:
#![allow(unused)] fn main() { let server_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), self.port); /// You need to provide the private key and protocol id when building the `NetcodeConfig` let netcode_config = NetcodeConfig::default() .with_protocol_id(PROTOCOL_ID) .with_key(KEY); /// You can also add a link conditioner to simulate network conditions for packets received by the server let link_conditioner = LinkConditionerConfig { incoming_latency: Duration::from_millis(100), incoming_jitter: Duration::from_millis(0), incoming_loss: 0.00, }; let net_config = NetConfig::Netcode { config: netcode_config, io: IoConfig::from_transport(TransportConfig::UdpSocket(server_addr)) .with_conditioner(link_conditioner), }; let config = ServerConfig { shared: shared_config().clone(), /// Here we only provide a single net config, but you can provide multiple! net: vec![net_config], ..default() }; /// Finally build the server plugin let server_plugin = server::ServerPlugin::new(server_config); }
Next we will start adding systems to the client and server.
Adding basic functionality
What we want to achieve is this:
- when a client connects to the server, the server spawns a player entity for that client
- that entity gets replicated to all clients
- a client can send inputs to move the player entity that corresponds to them
Initialization
Lightyear uses bevy States to manage the connection state.
The states indicate the current state of the connection.
The client states are Disconnected
, Connecting
, Connected
; and the server states are Started
, Stopped
.
The connection is started/stopped using the ClientCommands
and ServerCommands
commands.
Server:
fn init(mut commands: Commands) {
commands.start_server();
}
app.add_systems(Startup, init);
Client:
fn init(mut commands: Commands) {
commands.connect_client();
}
app.add_systems(Startup, init);
We also do some setup, like adding a Camera2dBundle
and displaying some text to let us know
if we are the server or the client.
Network events
The way you can access networking-related events is by using bevy Events
. lightyear
exposes a certain number of events which you can see here:
ConnectEvent
/DisconnectEvent
: when a client gets connected or disconnected. This can be used to access theClientId
of the client that connected/disconnected.EntitySpawnEvent
/EntityDespawnEvent
: the receiver emits these when it spawns/despawns an entity replicated from the remote worldComponentInsertEvent
/ComponentRemoveEvent
/ComponentUpdateEvent
: the receiver emits these when it inserts/removes/updates a component for an entity replicated from the remote worldInputEvent
: when a user action gets emitted. This event will be emitted on both the server and the client at the exactTick
where the input was emitted.MessageEvent
: when a message is received from the remote machine. This is used to access the message contents.
This is what we'll use to spawn an entity on the server whenever a client connects!
/// We will maintain a mapping from client id to entity id
// so that we know which entity to despawn when the client disconnects
#[derive(Resource)]
pub(crate) struct Global {
pub client_id_to_entity_id: HashMap<ClientId, Entity>,
}
/// Create a player entity whenever a client connects
pub(crate) fn handle_connections(
/// Here we listen for the `ConnectEvent` event
mut connections: EventReader<ConnectEvent>,
mut global: ResMut<Global>,
mut commands: Commands,
) {
for connection in connections.read() {
/// on the server, the `context()` method returns the `ClientId` of the client that connected
let client_id = *connection.context();
/// We add the `Replicate` bundle to start replicating the entity to clients
/// By default, the entity will be replicated to all clients
let replicate = Replicate::default();
let entity = commands.spawn((PlayerBundle::new(client_id, Vec2::ZERO), replicate));
// Add a mapping from client id to entity id
global.client_id_to_entity_id.insert(client_id, entity.id());
}
As you can see above, starting replicating an entity is very easy: you just need to add the Replicate
bundle to the entity
and it will start getting replicated.
(you can learn more in the replicate page)
The Replicate
bundle is composed of several components that control the replication logic. For example ReplicationTarget
specifies which clients should receive the entity.
If you remove the ReplicationTarget
component from an entity, any updates to that entity won't be replicated anymore.
(However the remote entity won't get despawned, it will just stop getting updated)
Handle client inputs
Then we want to be able to handle inputs from the user.
We need a system that reads keypresses/mouse movements and converts them into Inputs
(from our Protocol
).
You will need to call the add_input method on the InputManager
resource to send an input to the server.
There are some ordering constraints for inputs: you need to make sure that inputs are handled in the BufferInputs
SystemSet
, which runs in the FixedPreUpdate
schedule.
Then lightyear will make sure that the server will handle the input on the same tick as the client.
On the client:
pub(crate) fn buffer_input(
/// You will need to specify the exact tick at which the input was emitted. You can use
/// the `TickManager` to retrieve the current tick
tick_manager: Res<TickManager>,
/// You will use the `InputManager` to send an input
mut input_manager: ResMut<InputManager<Inputs>>,
keypress: Res<ButtonInput<KeyCode>>,
) {
let tick = tick_manager.tick();
let mut input = Inputs::None;
let mut direction = Direction {
up: false,
down: false,
left: false,
right: false,
};
if keypress.pressed(KeyCode::KeyW) || keypress.pressed(KeyCode::ArrowUp) {
direction.up = true;
}
if keypress.pressed(KeyCode::KeyS) || keypress.pressed(KeyCode::ArrowDown) {
direction.down = true;
}
if keypress.pressed(KeyCode::KeyA) || keypress.pressed(KeyCode::ArrowLeft) {
direction.left = true;
}
if keypress.pressed(KeyCode::KeyD) || keypress.pressed(KeyCode::ArrowRight) {
direction.right = true;
}
if !direction.is_none() {
input = Inputs::Direction(direction);
}
input_manager.add_input(input, tick)
}
app.add_systems(FixedPreUpdate, buffer_input.in_set(InputSystemSet::BufferInputs));
Then, on the server, you will want to listen to the InputEvent
event, in the FixedUpdate
schedule,
to move the client entity. Any changes to the entity will be replicated to all clients.
We define a function that specifies how a given input updates a given player entity. It is a good idea to define the simulation logic in a function that can be shared between the client and the server, in case we want to run the simulation ahead of time on the client (this is called client-prediction).
pub(crate) fn shared_movement_behaviour(mut position: Mut<PlayerPosition>, input: &Inputs) {
const MOVE_SPEED: f32 = 10.0;
match input {
Inputs::Direction(direction) => {
if direction.up {
position.y += MOVE_SPEED;
}
if direction.down {
position.y -= MOVE_SPEED;
}
if direction.left {
position.x -= MOVE_SPEED;
}
if direction.right {
position.x += MOVE_SPEED;
}
}
_ => {}
}
}
Then we can create a system that reads the inputs and applies them to the player entity. On the server:
fn movement(
mut position_query: Query<&mut PlayerPosition>,
/// Event that will contain the inputs for the correct tick
mut input_reader: EventReader<InputEvent<Inputs>>,
/// Retrieve the entity associated with a given client
global: Res<Global>,
) {
for input in input_reader.read() {
let client_id = input.context();
if let Some(input) = input.input() {
if let Some(player_entity) = global.client_id_to_entity_id.get(client_id) {
if let Ok(position) = position_query.get_mut(*player_entity) {
shared_movement_behaviour(position, input);
}
}
}
}
}
app.add_systems(FixedUpdate, movement);
Any fixed-update simulation system (physics, etc.) must run in the FixedUpdate
Schedule
to behave correctly.
Displaying entities
Finally we can add a system on both client and server to draw a box to show the player entity.
pub(crate) fn draw_boxes(
mut gizmos: Gizmos,
players: Query<(&PlayerPosition, &PlayerColor)>,
) {
for (position, color) in &players {
gizmos.rect(
Vec3::new(position.x, position.y, 0.0),
Quat::IDENTITY,
Vec2::ONE * 50.0,
color.0,
);
}
}
Now, running the server and client in parallel should give you:
- server spawns a cube when client connects
- client can send inputs to the server to control the cube
- the movements of the cube in the server world are replicated to the client (and to other clients) !
In the next section, we will see a couple more systems.
Advanced systems
In this section we will see how we can add client-prediction and entity-interpolation to make the game feel more responsive and smooth.
Client prediction
If we wait for the server to:
- receive the client input
- move the player entity
- replicate the update back to the client
We will have a delay of at least 1 RTT (round-trip-delay) before we see the impact of our inputs on the player entity. This can feel very sluggish/laggy, which is why often games will use client-side prediction. Another issue is that the entity on the client will only be updated whenever we receive a packet from the server. Usually the packet send rate is much lower than one packet per frame, for example it can be on the order of 10 packet per second. If the server's packet_send_rate is low, the entity will appear to stutter.
The solution is to run the same simulation systems on the client as on the server, but only for the entities that the client predicts. This is "client-prediction": we move the client-controlled entity immediately according to our user inputs, and then we correct the position when we receive the actual state of the entity from the server. (if there is a mismatch)
In lightyear, this is enabled by setting a prediction_target
on the Replicate
component, which lets you specify
which clients will predict the entity.
If prediction is enabled for an entity, the client will spawn a local copy of the entity along with a marker component called Predicted
.
The entity that is directly replicated from the server will have a marker component called Confirmed
(because it is only updated when we receive a new server update packet).
The Predicted
entity lives on a different timeline than the Confirmed
entity: it lives a few ticks in the future (at least 1 RTT), enough ticks
so that the client inputs for tick N
have time to arrive on the server before the server processes tick N
.
Whenever the player sends an input, we can apply the inputs instantly to the Predicted
entity; which is the only one that we
show to the player. After roughly 1 RTT, we receive the actual state of the entity from the server, which is used to update the Confirmed
entity.
If there is a mismatch between the Confirmed
and Predicted
entities, we perform a rollback: we reset the Predicted
entity to the state of the Confirmed
entity,
and re-run all the ticks that happened since the last server update was received. In particular, we will re-apply all the client inputs that were added
since the last server update.
As the Confirmed
and Predicted
entities are 2 separate entities, you will need to specify how the components that are received on the Confirmed
entity (replicated from the server) are copied to the Predicted
entity.
To do this, you will need to specify a ComponentSyncMode for each component in the ComponentProtocol
enum.
There are 3 different modes:
- Full: we apply client-side prediction with rollback
- Simple: the server-updates are copied from Confirmed to Predicted whenever we have an update
- Once: the components are copied only once from the Confirmed entity to the Predicted entity
You will need to modify your ComponentProtocol
to specify the prediction behaviour for each component;
if you don't specify one, the component won't be copied from the Confirmed
to the Predicted
entity.
On the server, we will update our entity-spawning system to add client-prediction:
/// Create a player entity whenever a client connects
pub(crate) fn handle_connections(
/// Here we listen for the `ConnectEvent` event
mut connections: EventReader<ConnectEvent>,
mut global: ResMut<Global>,
mut commands: Commands,
) {
for connection in connections.read() {
/// on the server, the `context()` method returns the `ClientId` of the client that connected
let client_id = *connection.context();
/// We add the `Replicate` component to start replicating the entity to clients
/// By default, the entity will be replicated to all clients
let replicate = Replicate {
prediction_target: NetworkTarget::Single(client_id),
..default()
};
let entity = commands.spawn((PlayerBundle::new(client_id, Vec2::ZERO), replicate));
// Add a mapping from client id to entity id
global.client_id_to_entity_id.insert(client_id, entity.id());
}
The only change is the line prediction_target: NetworkTarget::Single(client_id)
; we are specifying which clients should be predicting this entity.
Those clients will spawn a copy of the entity with the Predicted
component added.
Then, on the client, you need to make sure that you also run the same simulation logic as the server, for the Predicted
entities.
We will add a new system on the client that also applies the user inputs.
It is very similar to the server system, we also listen for the InputEvent
event. It also needs to run in the FixedUpdate
schedule to work correctly.
On the client:
fn player_movement(
/// Note: we only apply inputs to the `Predicted` entity
mut position_query: Query<&mut PlayerPosition, With<Predicted>>,
mut input_reader: EventReader<InputEvent<Inputs>>,
) {
for input in input_reader.read() {
if let Some(input) = input.input() {
for position in position_query.iter_mut() {
shared_movement_behaviour(position, input);
}
}
}
}
app.add_systems(FixedUpdate, movement);
Now you can try running the server and client again; you should see 2 cubes for the client; the Predicted
cube should
move immediately when you send an input on the client. The Confirmed
cube only moves when we receive a packet from the server.
Entity interpolation
Client-side prediction works well for entities that the player predicts, but what about entities that are not controlled by the player? There are two solutions to make updates smooth for those entities:
- predict them as well, but there might be much bigger mis-predictions because we don't have access to other player's inputs
- display those entities slightly behind the 'Confirmed' entity, and interpolate between the last two confirmed states
The second approach is called 'interpolation', and is the one we will use in this tutorial. You can read this Valve article that explains it pretty well.
You will need to modify your ComponentProtocol
to specify the interpolation behaviour for each component;
if you don't specify one, the component won't be copied from the Confirmed
to the Interpolated
entity.
You will also need to provide an interpolation function; or you can use the default linear interpolation.
On the server, we will update our entity-spawning system to add entity-interpolation:
/// Create a player entity whenever a client connects
pub(crate) fn handle_connections(
/// Here we listen for the `ConnectEvent` event
mut connections: EventReader<ConnectEvent>,
mut global: ResMut<Global>,
mut commands: Commands,
) {
for connection in connections.read() {
/// on the server, the `context()` method returns the `ClientId` of the client that connected
let client_id = *connection.context();
/// We add the `Replicate` component to start replicating the entity to clients
/// By default, the entity will be replicated to all clients
let replicate = Replicate {
prediction_target: NetworkTarget::Single(client_id),
interpolation_target: NetworkTarget::AllExceptSingle(client_id),
..default()
};
let entity = commands.spawn((PlayerBundle::new(client_id, Vec2::ZERO), replicate));
// Add a mapping from client id to entity id
global.client_id_to_entity_id.insert(client_id, entity.id());
}
This time we interpolate for entities that are not controlled by the player, so we use NetworkTarget::AllExceptSingle(id)
.
If interpolation is enabled for an entity, the client will spawn a local copy of the entity along with a marker component called Interpolated
.
The entity that is directly replicated from the server will have a marker component called Confirmed
(because it is only updated when we receive a new server update packet).
The Interpolated
entity lives on a different timeline than the Confirmed
entity: it lives a few ticks in the past.
We want it to live slightly in the past so that we always have at least 2 confirmed states to interpolate between.
And that's it! The ComponentSyncMode::Full
mode is required on a component to run interpolation.
Now if you run a server and two clients, each player should see the other's player slightly in the past, but with movements that are interpolated smoothly between server updates.
Conclusion
We have now covered the basics of lightyear, and you should be able to build a server-authoritative multiplayer game with client-side prediction and entity interpolation!
Examples
This page contains a list of examples that you can run to see how Lightyear works. Click on the links to run the example in your browser. The examples run using WASM and WebTransport, so they might not work on all browsers (for example, they don't work on Safari currently).
Simple Box
Simple example showing how to replicate a box between the client and server.
Two boxes are actually shown:
- the red box is the Confirmed entity, which is updated at every server update (i.e. every 100ms)
- the pink box is either:
- the Predicted entity (if the client is controlling the entity)
- the Interpolated entity (if the client is not controlling the entity): the entity's movements are smoothed between two server updates
Replication Groups
This is an example that shows how to make Lightyear replicate multiple entities in a single message, to make sure that they are always in a consistent state (i.e. that entities in a group are all replicated on the same tick).
It also shows how lightyear can replicate components that are references to an entity. Lightyear will take care of mapping the
entity from the server's World
to the client's World
.
Interest Management
This example shows how lightyear can perform interest management: replicate only a subset of entities to each player. Here, the server will only replicate the green dots that are close to each player.
Client Replication
This example shows how lightyear can be used to replicate entities from the client to the server (and to other clients). The replication can be client-controlled.
It also shows how to spawn entities directly on the client's predicted timeline.
Bullet Pre-spawn
This example shows how to easily pre-spawn entities on the client's predicted timeline. The bullets are created using the same system on both client and server; however when the server replicates a bullet to the client, the client will match it with the existing pre-spawned bullet (instead of creating a new entity).
Leafwing Input Prediction
This example showcases several things:
- how to integrate lightyear with
leafwing_input_manager
. In particular you can simply attach anActionState
and anInputMap
to anEntity
, and theActionState
for thatEntity
will be replicated automatically - an example of how to integrate physics replication with
bevy_xpbd
. The physics sets have to be run inFixedUpdateSet::Main
- an example of how to run prediction for entities that are controlled by other players. (this is similar to what RocketLeague does).
There is going to be a frequent number of mispredictions because the client is predicting other players without knowing their inputs.
The client will just consider that other players are doing the same thing as the last time it received their inputs.
You can use the parameter
--predict
on the server to enable this behaviour (if not, other players will be interpolated). - The prediction behaviour can be adjusted by two parameters:
input_delay
: the number of frames it will take for an input to be executed. If the input delay is greater than the RTT, there should be no mispredictions at all, but the game will feel more laggy.correction_ticks
: when there is a misprediction, we don't immediately snapback to the corrected state, but instead we visually interpolate from the current state to the corrected state. This parameter helps make mispredictions less jittery.
Priority
This examples shows how lightyear
can help with bandwidth management.
See this chapter of the book.
Lightyear can limit the bandwidth used by the client or the server, for example to limit server traffic costs, or because the client's connection cannot handle a very high bandwidth. You can then assign a priority score to indicate which entities/messages are important and should be sent first.
In this example, the middle row has a priority of 1.0, and the priority increases by 1.0 for each row further away from the center. (i.e. the edge rows have a priority of 7.0 and are updated 7 times more frequently than the center row)
Concepts
There are several layers that enable lightyear to act as a games networking library. Let's list them from the bottom up (closer to the wire):
- transport: how do you send/receive unreliable-unordered packets on the network (UDP, QUIC, etc.)
- connection: abstraction of a stateful connection between two peers
- channels/reliability: how do you add ordering and reliability to packets
- replication: how do you replicate components between the client and server
- advanced replication: prediction, interpolation, etc.
Transport
The Transport
trait is the trait that is used to send and receive raw data on the network.
It is very general:
pub trait PacketSender: Send + Sync {
/// Send data on the socket to the remote address
fn send(&mut self, payload: &[u8], address: &SocketAddr) -> Result<()>;
}
pub trait PacketReceiver: Send + Sync {
/// Receive a packet from the socket. Returns the data read and the origin.
///
/// Returns Ok(None) if no data is available
fn recv(&mut self) -> Result<Option<(&mut [u8], SocketAddr)>>;
}
The trait currently has 4 implementations:
- UDP sockets
- WebTransport (using QUIC)
- WebSocket
- crossbeam-channels: used for internal testing
Serialization
We use the bitcode library to serialize and deserialize messages. Bitcode is a very compact serialization format that uses bit-packing (a bool will be serialized as a single bit).
When sending messages, we start by serializing the message early into a Bytes
structure.
This allows us to:
- know the size of the message right away (which helps with packet fragmentation)
- cheaply copy the message if we need to send it multiple times (for reliable channels) However:
- it is much more expensive and inefficient to call
serialize
on each individual message compared with the final packet, and the serialized bytes compress less efficiently
Buffers
We use a Buffer
to serialize/deserialize messages in order to re-use memory allocations.
When we receive a packet (&[u8]
), we create a ReadBuffer
from it, which starts by copying the bytes into the buffer.
Packet
On top of the transport layer (which lets us send some arbitrary bytes) we have the packet layer.
A packet is a structure that contains some data and some metadata (inside the header).
Packet header
The packet header will contain the same data as described in the Gaffer On Games articles:
- the packet type (single vs fragmented)
- the packet id (a wrapping u16)
- the last ack-ed packet id received by the sender
- an ack bitfield containing the ack of the last 32 packets before last_ack_packet_id
- the current tick
Packet data
The data will be a list of Messages that are contained in the packet.
A message is a structure that knows how to serialize/deserialize itself.
This is how we store messages into packets:
- the message get serialized into raw bytes
- if the message is over the packet limit size (roughly 1200 bytes), it gets fragmented into multiple parts
- we build a packet by iterating through the channels in order of priority, and then storing as many messages we can into the packet
Connection
Introduction
Our transport layer only allows us to send/receive raw packets to a remote address. But we want to be able to create a stateful 'connection' where we know that two peers are connected.
To establish that connection, that needs to be some machinery that runs on top of the transport layer and takes care of:
- sending handshake packets to authenticate the connection
- sending keep-alive packets to check that the connection is still open
- storing the list of connected remote peers
- etc.
lightyear
uses the traits NetClient
and NetServer
to abstract over the connection logic.
Multiple implementations are provided:
- Netcode
- Steam
- Local
Netcode
This implementation is based on the netcode.io standard created by Glenn Fiedler (of GafferOnGames fame). It describes a protocol to establish a secure connection between two peers, provided that there is an unoredered unreliable transport layer to exchange packets.
For my purpose I am using this Rust implementation of the standard.
You can use the Netcode connection by using the NetcodeClient
and NetcodeServer
structs, coupled with any of the available
transports (Udp, WebTransport, etc.)
To connect to a game server, the client needs to send a ConnectToken
to the game server to start the connection process.
There are several ways to obtain a ConnectToken
:
- the client can request a
ConnectToken
via a secure (e.g. HTTPS) connection from a backend server. The server must use the sameprotocol_id
andprivate_key
as the game servers. The backend server could be a dedicated webserver; or the game server itself, if it has a way to establish secure connection. - when testing, it can be convenient for the client to create its own
ConnectToken
manually. You can useAuthentication::Manual
for those cases.
Currently lightyear
does not provide any functionality to let a game server send a ConnectToken
securely to a client.
You will have to handle this logic youself.
Steam
This implementation is based on the Steamworks SDK.
Local
Local connections are used when running in host-server mode: the server and the client are running in the same bevy App.
No packets are actually sent over the network since the client and server share the same World
.
Multi connection
In lightyear, the server can handle multiple connection protocol as the same time. This means that the server could:
- open a port to establish steam socket connections
- open another port for UDP connections
- open another port for WebTransport connections
- etc.
and have all these connections running at the same time.
You can therefore have cross-play between different platforms.
Another potential usage is to have a "ListenServer" setup where a client acts as the "host":
- the Client and the Server run in the same process
- the Server has multiple connection protocols:
- one is based on local channels to talk to the Client that is running in the same process
- the other could be for example a UDP connection to allow other clients to connect to the server
To achieve this, you can just provide multiple NetConfig
when creating the ServerConfig
that will be used to create the server.
Reliability
In this layer we add some mechanisms to be able to send and receive messages reliably or in a given order.
It is similar to the reliable layer created by Glenn Fiedler on top of his netcode.io code.
This layer introduces:
- reliability: make sure a packets is received by the remote peer
- ordering: make sure packets are received in the same order they were sent
- channels: allow to send packets on different channels, which can have different reliability and ordering guarantees
PacketHeader
Channels
Lightyear introduces the concept of a Channel
to handle reliability.
A Channel
is a way to send packets with specific reliability, ordering and priority guarantees.
You can add a channel to your protocol like so:
#[derive(Channel)]
struct MyChannel;
pub fn protocol() -> MyProtocol {
let mut p = MyProtocol::default();
p.add_channel::<MyChannel>(ChannelSettings {
mode: ChannelMode::OrderedReliable(ReliableSettings::default()),
direction: ChannelDirection::Bidirectional,
});
p
}
Mode
The mode
field of ChannelSettings
defines the reliability/ordering guarantees of the channel.
Reliability:
Unreliable
: packets are not guaranteed to arriveReliable
: packets are guaranteed to arrive. We will resend the packet until we receive an acknowledgement from the remote. You can define how often we resend the packet via theReliableSettings
field.
Ordering:
Ordered
: packets are guaranteed to arrive in the order they were sent (client sends 1,2,3,4,5, server receives 1,2,3,4,5)Unordered
: packets are not guaranteed to arrive in the order they were sent (client sends 1,2,3,4,5, server receives 1,3,2,5,4)Sequenced
: packets are not guaranteed to arrive in the order they were sent, but we will discard packets that are older than the last received packet (client sends 1,2,3,4,5, server receives 1,3,5 (2 and 4 are discarded))
Direction
The direction
field can be used to restrict a Channel
from sending packets from client->server or server->client.
Replication
Protocol
Overview
The Protocol module in this library is responsible for defining the communication protocol used to send messages between the client and server. The Protocol must be shared between client and server, so that the messages can be serialized and deserialized correctly.
Key Concepts
Protocol Trait
A Protocol
contains multiple sub-parts:
-
Input
: Defines the user inputs, which is an enum of all the inputs that the client can send to the server. Input handling can be added by adding theInputPlugin
plugin:app.add_plugins(InputPlugin::<I>::default());
-
LeafwingInput
: (only if the featureleafwing
is enabled) Defines the leafwingActionState
that the client can send to the server. Input handling can be added by adding theLeafwingInputPlugin
plugin:app.add_plugins(LeafwingInputPlugin::<I>::default());
-
MessageRegistry
: Will hold metadata about the all the messages that can be sent over the network. Each message must beSerializable + Deserializeable + Clone
. You can register a message with the commandapp.add_message::<Message1>(ChannelDirection::Bidirectional);
-
Components
: Defines the component protocol, which is an enum of all the components that can be replicated between the client and server. Each component must beSerializable + Clone + Component
. You can register a component with:app.register_component::<PlayerId>(ChannelDirection::ServerToClient) .add_prediction::<PlayerId>(ComponentSyncMode::Once) .add_interpolation::<PlayerId>(ComponentSyncMode::Once);
(You can specify additional behaviour for the component, such as prediction or interpolation.)
-
Channels
: the protocol should also contain a list of channels to be used to send messages. AChannel
defines guarantees about how the packets will be sent over the network: reliably? in-order? etc. You can register a channel with:app.add_channel::<Channel1>(ChannelSettings { mode: ChannelMode::OrderedReliable(ReliableSettings::default()), ..default() });
Replication
You can use the Replicate
bundle to initiate replicating an entity from the local World
to the remote World
.
It is composed of multiple smaller components that each control an aspect of replication:
ReplicationTarget
to decide who to replicate toVisibilityMode
to enable interest managementControlledBy
so the server can track which entity is owned by each clientReplicationGroup
to know which entity updates should be sent together in the same messageReplicateHierarchy
to control if the children of an entity should also be replicatedDisabledComponent<C>
to disable replication for a specific componentReplicateOnceComponent<C>
to specify that some components should not replicate updates, only inserts/removalsOverrideTargetComponent<C>
to override the replication target for a specific component
By default, every component in the entity that is part of the ComponentRegistry
will be replicated. Any changes in
those components will be replicated.
However the entity state will always be 'consistent': the remote entity will always contain the exact same combination
of components as the local entity, even if it's a bit delayed.
You can remove the ReplicationTarget
component to pause the replication. This can be useful when you want to despawn the
entity on the server without replicating the despawn.
(e.g. an entity can be despawned immediately on the server, but needs to remain alive on the client to play a dying
animation)
You can find some of the other usages in the advanced_replication section.
Replicating resources
You can also replicate bevy Resources
. This is useful when you want to update a Resource
on the server and keep synced
copies on the client. This only works for Resources
that also implement Clone
, and should be limited to resources which are cheap to clone.
- Then, to replicate a
Resource
, you can use thecommands.replicate_resource::<R>(replicate)
method. You will need to provide an instance of theReplicate
struct to specify how the replication should be done (e.g. to which clients should the resource be replicated). To stop replicating aResource
, you can use thecommands.stop_replicate_resource::<R>()
method. Note that this won't delete the resource from the client, but it will stop updating it.
System order
Lightyear provides several SystemSets
that you can use to run your systems in the correct order.
The main things to keep in mind are:
- All packets are read during the
PreUpdate
schedule. This is also where all components that were received are replicated to the Client World. - All packets are sent during the
PostUpdate
schedule. All messages that were buffered are then sent to the remote, and all replication messages (entity spawn, component updated, etc.) are also sent - There are 2
SystemSets
that you should interact with:BufferInputs
: this is where you should be runningclient.add_inputs()
so that they are buffered and sent to the server correctlyMain
: this is where all yourFixedUpdate
Schedule systems (physics, etc.) should be run, so that they interact correctly with client-side prediction, etc.
Here is a simplified version of the system order:
--- title: Simplified SystemSet order --- stateDiagram-v2 classDef flush font-style:italic; ReceiveFlush: Flush PreUpdate --> FixedUpdate FixedUpdate --> PostUpdate state PreUpdate { Receive --> ReceiveFlush ReceiveFlush --> Prediction ReceiveFlush --> Interpolation } state FixedUpdate { TickUpdate --> BufferInputs BufferInputs --> Main } state PostUpdate { Send }
Full system order
--- title: SystemSet order --- stateDiagram-v2 classDef flush font-style:italic; SpawnPredictionHistory : SpawnHistory SpawnInterpolationHistory : SpawnHistory SpawnPredictionHistoryFlush : Flush SpawnInterpolationHistoryFlush : Flush SpawnPredictionFlush : Flush SpawnInterpolationFlush: Flush CheckRollbackFlush: Flush DespawnFlush: Flush ReceiveFlush: Flush FixedUpdatePrediction: Prediction PreUpdate --> FixedUpdate FixedUpdate --> PostUpdate state PreUpdate { Receive --> ReceiveFlush ReceiveFlush --> Prediction ReceiveFlush --> Interpolation } state Prediction { SpawnPrediction --> SpawnPredictionFlush SpawnPredictionFlush --> SpawnPredictionHistory SpawnPredictionHistory --> SpawnPredictionHistoryFlush SpawnPredictionHistoryFlush --> CheckRollback CheckRollback --> CheckRollbackFlush CheckRollbackFlush --> Rollback } state Interpolation { SpawnInterpolation --> SpawnInterpolationFlush SpawnInterpolationFlush --> SpawnInterpolationHistory SpawnInterpolationHistory --> SpawnInterpolationHistoryFlush SpawnInterpolationHistoryFlush --> Despawn Despawn --> DespawnFlush DespawnFlush --> Interpolate } state FixedUpdate { TickUpdate --> BufferInputs BufferInputs --> WriteInputEvent WriteInputEvent --> Main Main --> ClearInputEvent Main --> FixedUpdatePrediction } state FixedUpdatePrediction { PredictionEntityDespawn --> PredictionEntityDespawnFlush PredictionEntityDespawnFlush --> UpdatePredictionHistory UpdatePredictionHistory --> IncrementRollbackTick : if rollback } state PostUpdate { state Send { SendEntityUpdates --> SendComponentUpdates SendComponentUpdates --> SendInputMessage SendInputMessage --> SendPackets } -- Sync }
Authority
Networked entities can be simulated on a client or on a server. We define by 'Authority' the decision of which peer is simulating an entity. The authoritative peer (client or server) is the only one that is allowed to send replication updates for an entity, and it won't accept updates from a non-authoritative peer.
Only one peer can be the authority over an entity at a given time.
Benefits of distributed client-authority
Client authority means that the client is directly responsible for simulating an entity and sending replication updates for that entity.
Cons:
- high exposure to cheating.
- lower latency Pros:
- less CPU load on the server since the client is simulating some entities
How it works
We have 2 components:
HasAuthority
: this is a marker component that you can use as a filter in queries to check if the current peer has authority over the entity.- on clients:
- a client will not accept any replication updates from the server if it has
HasAuthority
for an entity - a client will send replication updates for an entity only if it has
HasAuthority
for that entity
- a client will not accept any replication updates from the server if it has
- on server:
- this component is just used as an indicator for convenience, but the server can still send replication
updates even if it doesn't have
HasAuthority
for an entity. (because it's broadcasting the updates coming from a client)
- this component is just used as an indicator for convenience, but the server can still send replication
updates even if it doesn't have
- on clients:
AuthorityPeer
: this component is only present on the server, and it indicates to the server which peer currently holds authority over an entity. (None
,Server
or aClient
). The server will only accept replication updates for an entity if the sender matches theAuthorityPeer
.
Authority Transfer
On the server, you can use the EntityCommand
transfer_authority
to transfer the authority for an entity to a different peer.
The command is simply commands.entity(entity).transfer_authority(new_owner)
to transfer the authority of entity
to the AuthorityPeer
new_owner
.
Under the hood, authority transfers do two things:
- on the server, the transfer is applied immediately (i.e. the
HasAuthority
andAuthorityPeer
components are updated instantly) - than the server sends messages to clients to notify them of an authority change. Upon receiving the message, the client will add or remove the
HasAuthority
component as needed.
Implementation details
-
There could be a time where both the client and server have authority at the same time
-
server is transferring authority from itself to a client: there is a period of time where no peer has authority, which is ok.
-
server is transferring authority from a client to itself: there is a period of time where both the client and server have authority. The client's updates won't be accepted by the server because it has authority, and the server's updates won't be accepted by the client because it has authority, so no updates will be applied.
-
server is transferring authority from client C1 to client C2:
- if C1 receives the message first, then for a short period of time no client has authority, which is ok
- if C2 receives the message first, then for a short period of time both clients have authority. However the
AuthorityPeer
is immediately updated on the server, so the server will only accept updates from C2, and will discard the updates from C1.
-
-
We have to be careful on the server about how updates are re-broadcasted to other clients. If a client 1 has authority and the server broadcasts the updates to all entities, we keep the
ReplicationTarget
asNetworkTarget::All
(it would be tedious to keep track of how the replication target needs to be updated as we change authority again), but instead the server never sends updates to the client that has authority. -
One thing that we have to be careful about is that lightyear used to only apply entity mapping on the receiver side. The reason is that the receiver receives a 'Spawn' message with the remote entity id so it knows how to map from the local to the remote id. In this case, the authority can now be transferred to the receiver. The receiver will now send replication updates, but the peer who was originally the spawner of the entity doesn't have an entity mapping. This means that the new sender (who was originally the receiver) must do the entity mapping on the send side.
- the Entity in EntityUpdates or EntityActions can now be mapped by the sender, if there is a mapping detected in
local_to_remote
entity map - the entity mappers used on the send side and the receiver side are not the same anymore. To avoid possible conflicts, on the send side we flip a bit to indicate that we did a local->remote mapping so that the receiver doesn't potentially reapply a remote->local mapping. The send entity_map flips the bit, and the remote entity_map checks the bit.
- since we are now potentially doing entity mapping on the send side, we cannot just replicate a component
&C
because we might have to update the component to do entity mapping. Therefore if the component implementsMapEntities
, we clone it first and then apply entity mapping.- TODO: this is potentially inefficient because it should be quite rare that the sender needs to do entity mapping (it's only if the authority over an entity was transferred). However components that contain other entities should change pretty infrequently so this clone should be ok. Still, it would be nice if we could avoid it
- the Entity in EntityUpdates or EntityActions can now be mapped by the sender, if there is a mapping detected in
-
We want the
Interpolated
entity to still get updated even if the client has authority over theConfirmed
entity. To do this, we populate theConfirmedHistory
with the server's updates when we don't have authority, and with the client'sConfirmed
updates if we have authority. This makes sense becauseInterpolated
should just interpolate between ground truth states.
TODO:
- what to do with prepredicted?
- client spawns an entity with PrePredicted
- server receives it, adds Replicate
- currently: server replicates a spawn, which will become the Confirmed entity on the client.
- if the Spawn has entity mapping, then we're screwed! (because it maps to the client entity)
- if the Spawn has no entity mapping, but the Components don't, we're screwed (it will be interpreted as 2 different actions)
- sol 1: use the local entity for bookkeeping and apply entity mapping at the end for the entire action. If the action has a spawn, no mapping. (because it's a new entity)
- sol 2: we change how PrePredicted works. It spawns a Confirmed AND a Predicted on client; and replicates the Confirmed. Then the server transfers authority to the client upon receipt.
- test with conflict (both client and server spawn entity E and replicate it to the remote)
TODO:
- maybe let the client always accept updates from the server, even if the client has
HasAuthority
? What is the goal of disallowing the client to accept updates from the server if it hasHasAuthority
? - maybe include a timestamp/tick to the
ChangeAuthority
messages so that any in-flight replication updates can be handled correctly?- authority changes from C1 to C2 on tick 7. All updates from C1 that are previous to tick 7 are accepted by the server. Updates after that are discarded. We receive updates from C2 as soon as it receives the
ChangeAuthority
message. - authority changes from C1 to S on tick 7. All updates from C1 that are previous to tick 7 are accepted by the server.
- authority changes from C1 to C2 on tick 7. All updates from C1 that are previous to tick 7 are accepted by the server. Updates after that are discarded. We receive updates from C2 as soon as it receives the
- how do we deal with
Predicted
?- if Confirmed has authority, we probably want to disable rollback and set the predicted state to be equal to the confirmed state?
- ideally, if an entity is client-authoritative, then it should interact with 0 delay with the client predicted entities. But currently only the Confirmed entity would get the Authority. Would we also want to sync the HasAuthority component so that it gets added to Predicted?
- maybe have an API
request_authority
where the client requests the authority? and receives a response from the server telling it if the request is accepted or not? Look at this page: https://docs-multiplayer.unity3d.com/netcode/current/basics/ownership/
Bandwidth management
By default, lightyear sends all messages (created by the user, or messages created from replication updates)
every send_interval
(this interval is configurable) without any regard for the bandwidth available to the client.
But in some situations you might want to limit the bandwidth used by the client or the server, for example to limit server traffic costs, or because the client's connection cannot handle a very high bandwidth.
This page will explain how to do that. There are several options to choose from.
Limiting the number of replication objects
The simplest thing you can do is to carefully choose which entities and components you need to replicate. For example, rendering-related components (particles, assets, etc.) do not need to spawned on the server and replicated to the client. They can be created on the client and only the necessary information (position, rotation, etc.) can be replicated.
This also saves CPU costs on the server.
Updating the send interval
Another thing you can do is to update the send_interval
of the client or server. This will reduce the number of times
the SystemSet::Send
systems will run.
This SystemSet
is responsible for aggregating all the messages that were buffered and are ready to send, as well as generating all the
replication-messages (entity-actions, entity-updates) that should be sent.
NOTE: Currently lightyear
expects send_interval
to be 0 on the client (i.e. the client sends all updates immediately) to manage client inputs properly.
This will also reduce the CPU usage of the server as it runs the replication-send logic less often.
TODO: Updating the replication rate per replication group
You can also override the replication rate per replication group. For some entities it might not be important to run replication at a very high rate, so you can reduce the rate for those entities.
NOTE: this is currently not possible
Prioritizing replication groups
Even so, there might be situations where you have more messages to send than the bandwidth available to you. In that case you can set a priority to indicate which messages are important and should be sent first.
Every time the server (or client) is ready to send messages, it will first:
- aggregate the list of messages that should be sent
- then sort them by priority. The priority is computed with the formula
channel_priority * message_priority
. - it will send messages in order of priority until all the bandwidth is used
- it will then discard the remaining messages
- note that this means that discarded messages via an unreliable channel will simply not be sent
- for entity updates, we still try to send an update until the remote world is consistent with the local world, so we will keep trying sending updates until we receive an ack from the remote that it received the updates.
Only the relative priority values matter, not their absolute value: an entity with priority 10 will be replicated twice as often as an entity with priority 5.
To avoid having some replication groups entities be starved of updates (because their priority is always too low), we do priority accumulation:
- every send_interval, we accumulate the priority of all messages:
accumulated_priority += priority
- if a replication groups successfully sends an update or an action, we reset the accumulated priority to 0. (note that it's not guaranteed that the message was received by the remote, just that the message was sent)
- for reliable channels, we also keep accumulating the priority until we receive an ack from the remote that the message was successfully received
Replication Logic
This page explains how replication works and what guarantees can be made.
Replication makes a distinction between:
- Entity Actions (entity spawn/despawn, component insert/remove): these events change the archetype of an entity
- Entity Updates (component update): these events don't change the archetype of an entity but simply update the value of some components. Most (90%+) replication messages should be Entity Updates.
Those two are handled differently by the replication system.
Invariants
There are certain invariants/guarantees that we wish to maintain with replication.
Rule #1a: we would like a replicated entity to be in a consistent state compared to what it was on the server: at no point do we want a situation where a given component is on tick T1 but another component of the same entity is on tick T2. The replicated entity should be equal to a version of the remote entity in the past. Similarly, we would not want one component of an entity to be inserted later than other components. This could be disastrous because some other system could depend on both components being present together!
Rule #2: we want to be able to extend this guarantee to multiple entities. I will give two relevant examples:
- client prediction: for client-prediction, we want to rollback if a receives server-state doesn't match with the predicted history. If we are running client-prediction for multiple entities that are not in the same tick, we could have situations where we need to rollback one entity starting from tick T1 and another entity starting from tick T2. This can be fairly hard to achieve, so we'd like to have all predicted entities be on the same tick.
- hierarchies: some entities have relationships. For example you could have an entity with a component Head, and an entity Body with a component
HasParent(Entity)
which points to the Head entity. If we want to replicate this hierarchy, we need to make sure that the Head entity is replicated before the Body entity. (otherwise theEntity
pointed to inHasParent
would be invalid on the client). Therefore we need to make sure that all updates for both the parent and the head are in sync.
The only way to guarantee that these rules are respected is to send all the updates for a given "replication group" as a single message. (if we send multiple messages, they could be added to multiple packets, and therefore arrive in a different time/order on the client because of jitter and packet loss)
Lightyear introduces the concept of a ReplicationGroup
which is a group of entity whose EntityActions
and EntityUpdates
will be sent
over the network as a single message.
It is guaranteed that the state of all entities in a given ReplicationGroup
will be consistent on the client, i.e.
will be equivalent to the state of the group on the server at a given previous tick T.
Entity Actions
For each ReplicationGroup
, Entity Actions are replicated in an OrderedReliable
manner.
Send
Whenever there are any actions for a given ReplicationGroup
, we send them as a single message AND we include any updates for this group as well.
This is to guarantee consistency; if we sent them as 2 separate messages, the packet containing the updates could get lost and we would be in an inconsistent state.
Each message for a given [ReplicationGroup
] is associated with a message id (a monotonically increasing number) that is used to order the messages on the client.
Receive
On the receive side, we buffer the EntityActions that we receive, so that we can read them in order (message id 1, 2, 3, 4, etc.) We keep track of the next message id that we should receive.
Entity Updates
Send
We gather all updates since the last time we got an ACK from the client that the EntityUpdates was received
The reason for this is:
- we could be gathering all the component changes since the last time we sent EntityActions, but then it could be wasteful if the last time we had any entity actions was a long time ago and many components got updated since.
- we could be gathering all the component changes since the last time we sent a message, but then we could have a situation where:
- we send changes for C1 on tick 1
- we send changes for C2 on tick 2
- packet for C1 gets lost, and we apply the C2 changes -> the entity is now in an inconsistent state at C2
Receive
For each ReplicationGroup
, Entity Updates are replicated in a SequencedUnreliable
manner.
We have some additional constraints:
- we only apply EntityUpdates if we have already applied all the EntityActions for the given
ReplicationGroup
that were sent when the Updates were sent.- for example we send A1, U2, A3, U4; we receive U4 first, but we only apply it if we have applied A3, as those are the latest EntityActions sent when U4 was sent
- if we received a more recent update that can be applied, we discard the older one (Sequencing)
- for example if we send A1, U2, U3 and we receive A1 then U3, we discard U2 because it is older than U3
Input handling
Lightyear handles inputs for you by:
- buffering the last few inputs on both client and server
- re-using the inputs from past ticks during rollback
- sending client inputs to the server with redundancy
Client-side
Input handling is currently done in the FixedUpdate schedule.
There are multiple SystemSets
involved that should run in the following order:
BufferInputs
: the user must add their inputs for the given tick in this system setWriteInputEvents
: we get the inputs for the current tick and return them as the bevy eventInputEvent<I>
- notably, during rollback we get the inputs for the older rollback tick
ClearInputEvents
: we clear the bevy events.SendInputMessage
: we prepare a message with the last few inputs. For redundancy, we will send the inputs of the last few frames, so that the server can still get the correct input for a given tick even if some packets are lost.
Server-side
Input handling is also done in the FixedUpdate schedule.
These are the relevant SystemSets
:
WriteInputEvents
: we receive the input message from the client, add the inputs into an internal buffer. Then in this SystemSet we retrieve the inputs for the current tick for the given client. The retrieved inputs will be returned asInputEvent<I>
ClearInputEvents
: we clear the events
Interpolation
Introduction
Interpolation means that we will store replicated entities in a buffer, and then interpolate between the last two states to get a smoother movement.
See this excellent explanation from Valve: link or this one from Gabriel Gambetta: link
Implementation
In lightyear, interpolation can be automatically managed for you.
Every replicated entity can specify to which clients it should be interpolated to:
Replicate {
interpolation_target: NetworkTarget::AllExcept(vec![id]),
..default()
},
This means that all clients except for the one with id id
will interpolate this entity.
In practice, it means that they will store in a buffer the history for all components that are enabled for Interpolation.
Component Sync Mode
Not all components in the protocol are necessarily interpolated.
Each component can implement a ComponentSyncMode
that defines how it gets handled for the Predicted
and Interpolated
entities.
Only components that have ComponentSyncMode::Full
will be interpolated.
Interpolation function
By default, the implementation function for a given component will be linear interpolation. It is also possibly to override this behaviour by implementing a custom interpolation function.
Here is an example:
#[derive(Component, Message, Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct Component1(pub f32);
#[derive(Component, Message, Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct Component2(pub f32);
#[component_protocol(protocol = "MyProtocol")]
pub enum MyComponentProtocol {
#[sync(full)]
Component1(Component1),
#[sync(full, lerp = "MyCustomInterpFn")]
Component2(Component2),
}
// custom interpolation logic
pub struct MyCustomInterpFn;
impl<C> InterpFn<C> for MyCustomInterpFn {
fn lerp(start: C, _other: C, _t: f32) -> C {
start
}
}
You will have to add the attribute lerp = "TYPE_NAME"
to the component.
The TYPE_NAME
must be a type that implements the InterpFn
trait.
pub trait InterpFn<C> {
fn lerp(start: C, other: C, t: f32) -> C;
}
Complex interpolation
In some cases, the interpolation logic can be more complex than a simple linear interpolation. For example, we might want to have different interpolation functions for different entities, even if they have the same component type. Or we might want to do interpolation based on multiple comments (applying some cubic spline interpolation that relies not only on the position, but also on the velocity and acceleration).
In those cases, you can disable the default per-component interpolation logic and provide your own custom logic.
rust,noplayground
Client-side Prediction
Introduction
Client-side prediction means that some entities are on the 'client' timeline instead of the 'server' timeline: they are updated instantly on the client.
The way that it works in lightyear is that for each replicated entity from the server, the client can choose to spawn 2 entities:
- a
Confirmed
entity that simply replicates the server updates for that entity - a
Predicted
entity that is updated instantly on the client, and is then corrected by the server updates
The main difference between the two is that, if you do an action on the client (for example move a character), the action will be applied instantly on the Predicted entity, but will be applied on the Confirmed entity only after the server executed the action and replicated the result back to the client.
Wrong predictions and rollback
Sometimes, the client will predict something, but the server's version won't match what the client has predicted. For example the client moves their character by 1 unit, but the server doesn't move the character because it detects that the character was actually stunned by another player at that time and couldn't move. (the client could not have predicted this because the 'stun' action from the other player hasn't been replicated yet).
In those cases the client will have to perform a rollback. Let's say the client entity is now at tick T', but the client is only receiving the server update for tick T. (T < T') Every time the client receives an update for the Confirmed entity at tick T, it will:
- check for each updated component if it matches what the predicted version for tick T was
- if it doesn't, it will restore all the components to the confirmed version at tick T
- then the client will replay all the systems for the predicted entity from tick T to T'
Pre-predicted entities
In some cases, you might want to spawn a player-controlled entity right away on the client, without waiting for it to be
replicated from the server.
In this case, you can spawn an entity directly on the client with the PrePredicted
component. You also need to
add Replicate
on the client entity
so that the entity gets replicated to the server.
Then on the server, you can replicate back the entity to the original client. The client will receive the entity, but
instead of spawning a new separate Predicted
entity,
it will re-use the existing entity that had the PrePredicted
component!
Edge cases
Component removal on predicted
Client removes a component on the predicted entity, but the server doesn't remove it. There should be a rollback and the client should re-add that component on the predicted entity.
Status: added unit test. Need to reconfirm that it works.
Component removal on confirmed
Server removes a component on the confirmed entity, but the Predicted entity had that component. There should be a rollback where the component gets removed from the Predicted entity.
Status: added unit test. Need to reconfirm that it works.
Component added on predicted
The client adds a component on the Predicted entity, but the Confirmed entity doesn't add it. There should be a rollback and that component gets removed from the Predicted entity.
Status: added unit test. Need to reconfirm that it works.
Component added on confirmed
The server receives an update where a new component gets added to the Confirmed entity. If it was not also added on the Predicted entity, there should be a rollback, where the component gets added to the Confirmed entity.
Status: added unit test. Need to reconfirm that it works.
Pre-predicted entity gets spawned
See more information in the client-replication section.
Status:
- the pre-predicted entity get spawned. Upon server replication, we re-use it as Predicted entity: no unit tests but tested in an example that it works.
- the pre-predicted entity gets spawned. The server doesn't agree that an entity should be spawned, the pre-spawned entity should get despawned: not handled currently.
Confirmed entity gets despawned
We never want to directly modify the Confirmed entity on the client; the Confirmed entity will get despawned only when the server despawns the entity and the despawn is replicated.
When that happens:
- Then the predicted entity should get despawned as well.
- Pre-predicted entities should still get attached to the confirmed entity on spawn, become Predicted entities and get despawned only when the confirmed entity gets despawned.
Status: no unit tests but tested in an example that it works.
Predicted entity gets despawned
There are several options:
OPTION A: Despawn predicted immediately but leave the possibility to rollback and re-spawn it.
We could despawn the predicted entity immediately on the client timeline. If it turns out that the server doesn't despawn the confirmed entity, we then have to rollback and re-spawn the predicted entity with all its components. We can achieve this by using the trait
pub trait PredictionCommandsExt {
fn prediction_despawn<P: Protocol>(&mut self);
}
that is implemented for EntityCommands
.
Instead of actually despawning the entity, we will just remove all the synced components, but keep the entity and the
components' histories.
If it turns out that the confirmed entity was not despawned, we can then rollback and re-add all the components for that
entity.
The main benefit is that this is very responsive: the entity will get despawned immediately on the client timeline, but respawning it (during rollback) can be jarring. This can be improved somewhat by animations: instead of the entity disappearing it can just start a death animation. If the death is cancelled, we can simply cancel the animation.
Status:
- predicted despawn, server doesn't despawn, rollback: no unit tests but tested in an example that it works.
- TODO: this needs to be improved! See note below.
- NOTE: the way it works now is not perfect. We rely on getting a rollback (where we can see that the confirmed
entity
does not match the fact that the predicted entity was despawned). However we only initiate rollbacks on receiving
server updates,
and it's possible that we are not receiving any updates because the confirmed entity is not changing, or because
of packet loss!
One option would be that
predicted_despawn
sends a messageRe-Replicate(Entity)
to the server, which will answer back by replicating the entity again. Let's wait to see how big of an issue this is first.
- predicted despawn, server despawns, we should not rollback but instead despawn both confirmed/predicted when the server despawn gets replicated: no unit tests but tested in an example that it works
OPTION B: despawn the confirmed entity and wait for that to be replicated
If we want to avoid the jarring effect of respawning the entity, we can instead wait for the server to confirm the despawn. In that case, we will just wait for the Confirmed entity to get despawned. When that despawn is propagated, the client entity will despawned as well.
Status: no unit tests but tested in example.
There is no jarring effect, but the despawn will be delayed by 1 RTT.
OPTION C: despawn predicted immediately and don't allow rollback
If you don't care about rollback and just want to get rid of the Predicted entity, you can just call
despawn
on it normally.
Status: no unit tests but tested in example.
Pre-predicted entity gets despawned
Same thing as Predicted entity getting despawned, but this time we are despawning the pre-predicted entity before we even received the server's confirmation. (this can happen if the entity is spawned and despawned soon after)
Status:
- pre-predicted despawn before we have received the server's replication, server doesn't despawn, rollback:
- no unit tests but tested in an example that it works
- TODO: same problem as with normal predicted entities: only works if we get a rollback, which is not guaranteed
- pre-predicted despawn before we have received the server's replication, server despawns, no rollback:
- the Predicted entity should visually get despawned (all components removed). When the server entity gets replicated, it should start re-using the Predicted entity, initiate a rollback, and see at the end of the rollback that the entity should indeed be despawned.
- no unit tests but tested in an example that it works
Visual Interpolation
Usually, you will want your simulation (physics, etc.) to run during the FixedUpdate
schedule.
The reason is that if you run this logic during the Update
schedule, the simulation will run at a rate that is
influenced by the frame rate of the client. This can lead to inconsistent results between clients: a beefy machine
might have a higher FPS, which would translate into a "faster" game/simulation.
This article by GafferOnGames talks a bit more about this: Fix Your Timestep
The issue is that this can cause the movement of your entity to appear jittery (if the entity only moves during the FixedUpdate schedule). There could be frames where the FixedUpdate schedule does not run at all, and frames where the FixedUpdate schedule runs multiple times in a row.
To solve this, lightyear
provides the VisualInterpolation
plugin.
The plugin will take care of interpolating the position of the entity between the last two FixedUpdate
ticks, thus making sure that
the entity is making smooth progress on every frame.
Three Approaches to Visual Interpolation
How Lightyear Does it
lerp(previous_tick_value, current_tick_value, time.overstep_fraction())
Using the time overstep, it lerps between the current and previous value generated during FixedUpdate
ticks in accordance with how much time has passed.
The interpolated values are written during PostUpdate
(see below). The original / canonical value, which was typically set by the physics logic in FixedUpdate
, is stored, to be written back to the component in PreUpdate
on the next tick. This means the rendering code should "just work" without being aware interpolation is happening.
PROS:
- relatively simple to implement
CONS:
- introduces a visual delay of 1 simulation tick
- need to store the previous and current value (so extra component clones)
Alternative A
lerp(current_tick_value, future_tick_value, time.overstep_fraction())
Simulate an extra step during FixedUpdate to compute the future_tick_value
, then interpolate between the current_tick_value
and the future_tick_value
PROS:
- simulation completely up-to-date, and accurate (if we have inputs for the future tick)
CONS:
- could be less accurate in some cases (inputs didn't arrive in time)
- need to store the previous and current value (so extra component clones)
Alternative B
Do not interpolate, but instead run the simulation (FixedUpdate schedule) for one 'partial' tick, i.e. we use (time.overstep_fraction() * fixed_timestep) as the timestep for the simulation.
PROS:
- no visual delay
- no need to store copies of the components
CONS:
- we might run many extra simulation steps if we run an extra partial step in every frame
VisualInterpolationPlugin systems
There are 3 main systems:
- during FixedUpdate, we run
update_visual_interpolation_status
after theFixedUpdate::Main
set (meaning that the simulation has run). In that system we keep track of the value of the component on the current tick and the previous tick - during PostUpdate, we run
visual_interpolation
which will interpolate the value of the component between the last two tick values stored in theupdate_visual_interpolation_status
system. We use theTime<Fixed>::overstep_percentage()
to determine how much to interpolate between the two ticks - during PreUpdate, we run
restore_from_visual_interpolation
to restore the component value to what it was before visual interpolation. This is necessary because the interpolated value is not the "real" value of the component for the simulation, it's just a visual representation of the component. We need to restore the real value before the simulation runs again.
Example
- you have a component that gets incremented by 1.0 at every fixed update step (and starts at 0.0)
- the fixed-update step takes 9ms, and the frame takes 12ms
Frame 0:
- tick is 0, the component is at 0.0
Frame 1:
- We run FixedUpdate once in this frame:
- tick is 1
update_visual_interpolation_status
: set current_value for tick 1 is 1.0, previous_value is None
visual_interpolation
: we do not interpolate because we don't have 2 ticks to interpolate between yet. So the component value is 1.0- the time is at 12ms, the overstep_percentage is 0.33
Frame 2:
restore_from_visual_interpolation
: we restore the component to 1.0- We run FixedUpdate once in this frame:
- tick is 2
update_visual_interpolation_status
: set current_value for tick 2 is 2.0, previous_value is 1.0
visual_interpolation
: the time is 24ms, the overstep percentage is 0.667, we interpolate between 1.0 and 2.0 so the component is now at 1.667
Frame 3:
restore_from_visual_interpolation
: we restore the component to 2.0- We run FixedUpdate twice in this frame:
- tick is 3
update_visual_interpolation_status
: set current_value for tick 3 is 3.0, previous_value is 2.0- tick is 4
update_visual_interpolation_status
: set current_value for tick 4 is 4.0, previous_value is 3.0
visual_interpolation
: the time is 36, the overstep percentage is 0.0, we interpolate between 3.0 and 4.0 so the component is now at 3.0
Frame 4:
restore_from_visual_interpolation
: we restore the component to 4.0- We run FixedUpdate once in this frame:
- tick is 5
update_visual_interpolation_status
: set current_value for tick 5 is 5.0, previous_value is 4.0
visual_interpolation
: the time is 48, the overstep percentage is 0.33, we interpolate between 5.0 and 4.0 so the component is now at 4.33
So overall the component value progresses by 1.33 every frame, which is what we expect because a frame duration (12ms) is 1.33 times the fixed update duration (9ms).
Usage
Visual interpolation is currently only available per component, and you need to enable it by adding a plugin:
app.add_plugins(VisualInterpolationPlugin::<Position>::default());
You will also need to add the VisualInterpolateState
component to any entity you want to enable visual interpolation for:
fn spawn_entity(mut commands: Commands) {
commands.spawn().insert(VisualInterpolateState::<Position>::default());
}
Usage with Avian Physics and FixedUpdate
Here's how you might enable Visual Interpolation for Avian's Position
and Rotation
:
app.add_plugins(VisualInterpolationPlugin::<Position>::default());
app.add_plugins(VisualInterpolationPlugin::<Rotation>::default());
app.observe(add_visual_interpolation_components::<Position>);
app.observe(add_visual_interpolation_components::<Rotation>);
// ...
fn add_visual_interpolation_components<T: Component>(
trigger: Trigger<OnAdd, T>,
q: Query<&RigidBody, With<T>>,
mut commands: Commands,
) {
let Ok(rigid_body) = q.get(trigger.entity()) else {
return;
};
// No need to interp static bodies
if matches!(rigid_body, RigidBody::Static) {
return;
}
// triggering change detection necessary for SyncPlugin to work
commands
.entity(trigger.entity())
.insert(VisualInterpolateStatus::<T> {
trigger_change_detection: true,
..default()
});
}
If you draw your entities with gizmos based on their Position
and Rotation
components, this is all you need.
However, if you have meshes, sprites, or anything that depends on Transform
(as is the norm), you need to ensure that changes made by the visual interpolation systems are synched to the transforms.
Avian's SyncPlugin
does this for you, but beware if you use FixedUpdate, you need to run the SyncPlugin
in PostUpdate
, otherwise you won't be syncing changes correctly. Visual interp happens in PostUpdate
, even if physics runs in FixedUpdate
.
// Run physics in FixedUpdate, but run the SyncPlugin in PostUpdate
app
.add_plugins(
PhysicsPlugins::new(FixedUpdate)
.build()
.disable::<SyncPlugin>(),
)
.add_plugins(SyncPlugin::new(PostUpdate));
Be aware that moving the SyncPlugin
to PostUpdate
could cause issues if you rely on modifying Transforms
during FixedUpdate
– they will no longer be synced back to the Avian components during FixedUpdate
. If you manipulate physics objects by changing the Avian components directly, it should be fine.
Avian's SyncConfig
Using Avian's SyncConfig you can control how position and transform are synced. You might wish to disable transform_to_position
, depending on how you game is built.
Caveats
-
The
VisualInterpolationPlugin
is currently only available for components that are present in the protocol. This is because the plugin needs to know how interpolation will be performed. The interpolation function is registered on the protocol directly instead of the component to circumvent the orphan rule (we want users to be able to define a custom interpolation function for external components)- NOTE: This will probably be changed in the future, by letting the user provide a custom interpolation function when creating the plugin.
-
The interpolation doesn't progress at the same rate at the very beginning, because we wait until there are two ticks available before we start doing the interpolation. (you can see in the example above that on the first frame the component is 1.0, and on the second frame 1.667, so the component value didn't progress by 1.33 like it will in the other frames).
- NOTE: it's probably possible to avoid this by just not displaying the component until we have 2 ticks available, but I haven't implemented this yet.
Prespawning
Introduction
There are several types of entities you might want to create on the client (predicted) timeline:
- normal ("delayed") predicted entities: they are spawned on the server and then replicated to the client. The client creates a Confirmed entity, and performs a rollback to create a corresponding Predicted entity on the client timeline.
- client-issued pre-predicted entities: the entity is first created on the client, and then replicated to the server. The server then receives the entity, decides if it's valid, and if so, replicates back the original client.
- prespawned entities: the entity is created on the client (in the predicted timeline) and the server using the same system. When the server replicates the entity back to the client, the client creates a Confirmed entity, but instead of creating a new Predicted entity, it re-uses the pre-spawned entity.
This section focuses about the third type of predicted entity: prespawned entities.
How does it work
You can find an example of prespawning in the prespawned example.
Let's say you want to spawn a bullet when the client shoots. You could just spawn the bullet on the server and wait for it to be replicated + predicted on the client. However that would introduce a delay between clicking on the 'shoot' button and seeing the bullet spawned.
So instead you run the same system on the client to prespawn the bullet in the predicted timeline.
The only thing you need to do is add the PreSpawnedPlayerObject
component to the entity spawned (on both the client and server).
commands.spawn((BulletBundle::default(), PreSpawnedPlayerObject));
That's it!
- The client will assign a hash to the entity, based on its components and the tick at which it was spawned. You can also override the hash to use a custom one.
- When the client receives a server entity that has
PreSpawnedPlayerObject
, it will check if the hash matches any of its pre-spawned entities. If it does, it will remove thePreSpawnedPlayerObject
component and add thePredicted
component. If it doesn't, it will just spawn a normal predicted entity.
In-depth
The various system-sets for prespawning are:
-
PreUpdate schedule:
PredictionSet::SpawnPrediction
: we first run the prespawn match system to match the pre-spawned entities with their corresponding server entity. If there is a match, we remove the PreSpawnedPlayerObject component and add the Predicted/Confirmed components. We then run an apply_deferred, and we run the normal predicted spawn system, which will skip all confirmed entities that already have apredicted
counterpart (i.e. were matched)
-
FixedUpdate schedule:
- FixedUpdate::Main: prespawn the entity
- FixedUpdate::SetPreSpawnedHash: we compute the hash of the prespawned entity based on its archetype (only the components that are present in the ComponentProtocol) + spawn tick.
We store the hash and the spawn tick in the
PredictionManager
(not in thePreSpawnedPlayerObject
component). - FixedUpdate::SpawnHistory: add a PredictionHistory for each component of the pre-spawned entity. We need this to:
- not rollback immediately when we get the corresponding server entity
- do rollbacks correctly for pre-spawned entities
-
PostUpdate schedule:
- we cleanup any pre-spawned entity no the clients that were not matched with any server entity.
We do the cleanup when the
(spawn_tick - interpolation_tick) * 2
ticks have elapsed. Normally at interpolation tick we should have received all the matching replication messages, but it doesn't seem like it's the case for some reason.. To be investigated.
- we cleanup any pre-spawned entity no the clients that were not matched with any server entity.
We do the cleanup when the
One thing to note is that we updated the rollback logic for pre-spawned entities. The normal rollback logic is:
- we receive a confirmed update
- we check if the confirmed update matches the predicted history
- if not, we initiate a rollback, and restore the predicted history to the confirmed state. (Thanks to replication group, all components of all entities in the replication group are guaranteed to be on the same confirmed tick)
However for pre-spawned entities, we do not have a confirmed entity yet! So instead we need to rollback to history of the pre-spawned entity.
- we compute the prediction history of all components during FixedUpdate
- when we have a rollback, we also rollback all prespawned entities to their history
- Edge cases:
- if the prespawned entity didn't exist at the rollback tick, we despawn it
- if a component didn't exist at the rollback tick, we remove it
- if a component existed at the rollback tick but not anymore, we re-spawn it
- TODO: if the preentity existed at the rollback tick but not anymore, we re-spawn it
This one is NOT handled (or maybe it is via
prediction_despawn()
, check!)
Caveats
There are some things to be careful of:
- the entity must be spawned in a system that runs in the
FixedUpdate::Main
SystemSet, because only then are you guaranteed to have exactly the same tick between client and server.- If you spawn the prespawned entity in the
Update
schedule, it won't be registered correctly for rollbacks, and also the tick associated with the entity spawn might be incorrect.
- If you spawn the prespawned entity in the
Interest management
Interest management is the concept of only replicating to clients the entities that they need.
For example: in a MMORPG, replicating only the entities that are "close" to the player.
There are two main advantages:
- bandwidth savings: it is pointless to replicate entities that are far away from the player, or that the player cannot interact with. Those bandwidth savings become especially important when you have a lot of concurrent connected clients.
- prevent cheating: if you replicate entities that the player is not supposed to see, there is a risk that clients read that data and use it to cheat. For example, in a RTS, you can avoid replicating units that are in fog-of-war.
Implementation
VisibilityMode
The first step is to think about the NetworkRelevanceMode
of your entities. It is defined on the Replicate
component.
#[derive(Default)]
pub enum VisibilityMode {
/// We will replicate this entity to all clients that are present in the [`NetworkTarget`] AND use relevance on top of that
InterestManagement,
/// We will replicate this entity to all clients that are present in the [`NetworkTarget`]
#[default]
All
}
If NetworkRelevanceMode::All
, you have a coarse way of doing interest management, which is to use the replication_target
to
specify which clients will receive client updates. The replication_target
is a NetworkTarget
which is a list of clients
that we should replicate to.
In some cases, you might want to use NetworkRelevanceMode::InterestManagement
, which is a more fine-grained way of doing interest management.
This adds additional constraints on top of the replication_target
, we will never send updates for a client that is not in the
replication_target
of your entity.
Interest management
If you set NetworkRelevanceMode::InterestManagement
, we will add a ReplicateVisibility
component to your entity,
which is a cached list of clients that should receive replication updates about this entity.
There are several ways to update the relevance of an entity:
- you can either update the relevance directly with the
RelevanceManager
resource - we also provide a more static way of updating the relevance with the concept of
Rooms
and theRoomManager
resource.
Immediate relevance update
You can simply directly update the relevance of an entity/client pair with the RelevanceManager
resource.
#![allow(unused)] fn main() { use bevy::prelude::*; use lightyear::prelude::*; use lightyear::prelude::server::*; fn my_system( mut relevance_manager: ResMut<RelevanceManager>, ) { // you can update the relevance like so relevance_manager.gain_relevance(ClientId::Netcode(1), Entity::PLACEHOLDER); relevance_manager.lose_relevance(ClientId::Netcode(2), Entity::PLACEHOLDER); } }
Rooms
An entity can join one or more rooms, and clients can similarly join one or more rooms.
We then compute which entities should be replicated to which clients by looking at which rooms they are both in.
To summarize:
- if a client is in a room but the entity is not (or vice-versa), we will not replicate that entity to that client
- if the client and entity are both in the same room, we will replicate that entity to that client
- if a client leaves a room that the entity is in (or an entity leaves a room that the client is in), we will despawn that entity for that client
- if a client joins a room that the entity is in (or an entity joins a room that the client is in), we will spawn that entity for that client
This can be useful for games where you have physical instances of rooms:
- a RPG where you can have different rooms (tavern, cave, city, etc.)
- a server could have multiple lobbies, and each lobby is in its own room
- a map could be divided into a grid of 2D squares, where each square is its own room
#![allow(unused)] fn main() { use bevy::prelude::*; use lightyear::prelude::*; use lightyear::prelude::server::*; fn room_system(mut manager: ResMut<RoomManager>) { // the entity will now be visible to the client manager.add_client(ClientId::Netcode(0), RoomId(0)); manager.add_entity(Entity::PLACEHOLDER, RoomId(0)); } }
Client replication
It is also possible to use lightyear to replicate entities from the client to the server. There are different possibilities.
Client authoritative
To replicate a client-entity to the server, it is exactly the same as for a server-entity.
Just add the Replicate
component to the entity and it will be replicated to the server.
#![allow(unused)] fn main() { fn handle_connection( mut connection_event: EventReader<ConnectEvent>, mut commands: Commands, ) { for event in connection_event.read() { let local_client_id = event.client_id(); commands.spawn(( /* your other components here */ Replicate { replication_target: NetworkTarget::All, interpolation_target: NetworkTarget::AllExcept(vec![local_client_id]), ..default() }, )); } } }
Note that prediction_target
and interpolation_target
will be unused as the server doesn't do any
prediction or interpolation.
If you want to then broadcast that entity to other clients, you will have to add a Replicate
component
on the server entity. This should generally happen in the ServerReplicationSet::ClientReplication
SystemSet on the server, so that it happens right
after receiving the client entity.
Be careful to not replicate the entity back to the original client, as it would create a duplicate entity on the client.
Example flow:
--- title: Client Authoritative --- sequenceDiagram participant Client1 participant Server participant Client2 participant Client3 Client1->>Server: Connect() Server->>Client1: Connected Client1->>Client1: ConnectEvent Client1->>Client1: SpawnPredicted(PlayerID: 1) Client1->>Server: Replicate(PlayerID: 1) Server-->>Client2: Replicate(PlayerID: 1) Client2->>Client2: SpawnConfirmed(PlayerID: 1) Server-->>Client3: Replicate(PlayerID: 1) Client3->>Client3: SpawnConfirmed(PlayerID: 1)
Pre-spawned predicted entities
Sometimes you might want to spawn a predicted entity on the client, but then replicate it to the server and let the server get control over the entity (server-authoritative).
For example you might have a predicted character that spawns another predicted entity (a projectile that other clients should see, for example).
You could wait for the user input to reach the server, then for the server to spawn the projectile and for the projectile to be replicated back to the client, but that would mean a delay of 1-RTT on the client to see the projectile when they pres a button.
A solution is to spawn the predicted projectile on the client; but then replicate back to the server. When the server replicates back the projectile, it creates a Confirmed entity on the client, but will re-use the existing predicted projectile as the Predicted entity.
The way to do this is to add a PrePredicted
component on the client entity that you want to predict.
On the server, to replicate back the entity to the client, you will need to manually add a Replicate
component to the entity
to specify to which clients you want to rebroadcast it.
Note that you must add the Replicate
component in the ServerReplicationSet::ClientReplication
SystemSet
for proper handling of
pre-spawned predicted entities!
When the server replicates back the entity, the client will check if the entity has a PrePredicted
component.
If not present, that means this is not a pre-spawned Predicted entity, and the client will spawn both the Confirmed
and Predicted entities.
If it's present, the client will spawn a new Confirmed entity, but will re-use the entity
as the Predicted entity.
Note that pre-spawned predicted entities will give authority to the server's entity immediately, the client to server replication will stop immediately after the initial replication, and the server entity should be the authoritative one.
Example flow:
--- title: Client PrePredicted --- sequenceDiagram participant Client1 participant Server participant Client2 participant Client3 Client1->>Server: Connect() Server->>Client1: Connected Client1->>Client1: ConnectEvent Client1->>Client1: SpawnPredicted(PlayerID: 1) Client1->>Server: Replicate(PlayerID: 1) Server-->>Client1: Replicate(PlayerID: 1) Client1->>Client1: SpawnConfirmed(PlayerID: 1) Server-->>Client2: Replicate(PlayerID: 1) Client2->>Client2: SpawnConfirmed(PlayerID: 1) Server-->>Client3: Replicate(PlayerID: 1) Client3->>Client3: SpawnConfirmed(PlayerID: 1)
Notes to myself:
- one thing to be careful for is that we want to immediately stop replicating updates from the pre-spawned predicted entity
to the server; because that entity should be server-authoritative. Right after the first time the
Send
SystemSet
runs, runclean_prespawned_entities
to removeReplicate
from those entities. - another thing we have to be careful of is this: let's say we receive on the server a pre-predicted entity with
ShouldBePredicted(1)
. Then we rebroadcast it to other clients. If an entity1
already exists on other clients; we will start using that entity as our Prediction target! That means that we should:- even if pre-spawned replication, require users to set the
prediction_target
correctly - only broadcast
ShouldBePredicted
to the clients who haveprediction_target
set. - be careful that
ShouldBePredicted
can be added once during spawn, and once from replication. In that case the second one should win out!
- even if pre-spawned replication, require users to set the
Connecting to a remote server
You've tested your multiplayer locally, and now you want to try connecting to a remote server.
This is a quick guide mostly for myself, as I knew nothing about networking and didn't know where to start.
Set up a server
From what I understand you can either:
- rent a dedicated node with a cloud provider. Smaller cloud providers (Linode, DigitalOcean) seem cheaper than bigger ones (Google Cloud, AWS) unless you have free credits. I used Kamatera, which worked excellently
- rent a VPS, which is a virtual machine on a dedicated node. I believe this means that you are sharing the resources of the nodes with other customers.
You need to get the public ip address of your server: S-IP.
Connect using UDP
On the client:
- You will need to create a local UDP socket that binds your local client ip address
- I believe that when the server receives a packet on their UDP socket, they will receive the public ip address of the client. By specifying the local client ip address, you enable the server to do NAT-traversal? I'm not sure.
- You can either manually specify your local client ip address, or you can use
0.0.0.0
(INADDR_ANY), which binds to any of the local ip addresses of your machine. I think that if your computer only has 1 network card, then it will bind to that one. (source: Beej's networking guide)
On the server:
- Same thing, you will need to create UDP socket that binds to your local server ip address.
- Again, you can either manually specify your local server ip address, or you can use
0.0.0.0
(INADDR_ANY) if your machine only has 1 network card, or if you don't care which of the local ip addresses the socket binds to - Keep track of the port used for the server: S-P
- Again, you can either manually specify your local server ip address, or you can use
- You will need to keep track of the public ip address of your server: S-IP
- Start the lightyear process on the server, for example with
cargo run --example interest_management -- server --headless
On the client:
- connect to the server by specifying the server public ip address and the server port:
cargo run --example interest_management -- client -c 1 --server-addr=S-IP --server-port=S-P