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!