Client-side Prediction for Smooth Multiplayer Gameplay

Multiplayer games are a whole lot of fun for players, but are full of complexities and considerations that are immaterial when developing single-player games. For this reason, developing multiplayer games is more time consuming and expensive!

When you’re developing an online server-authoritative game, you want your player movement to feel responsive for both low and high latency players. To achieve this, you’ll need to implement client-side prediction and smoothing.

There are many variants of client-side prediction but the basic idea is always the same: the client responds to player input by moving the player before the server processes the input and tells the client where they player should be. This of course means where a player sees themselves and where they actually are on the server can be different. You’ll need a way to prevent the client and server from diverging too far. There are many ways of handling this, and which works best for you will depend on your game.

In this article, we touch on different approaches to client-side prediction, and describe the client-side prediction algorithm used in our online 2d space-shooter io game, Kazap.io.

Client-side prediction in Kazap.io with high-latency (North America to Europe). A ghost ship shows the server location.

 An overlay of the client ship (orange) and the server representation - the 'ghost' (white)

An overlay of the client ship (orange) and the server representation - the 'ghost' (white)

First, we need a way for the client to predict the player movement that will happen on the server. To do this we wrote a player controller class that uses player inputs to determine movement and we run it on both the client and the server. Although the server and client are running the same code, there are still many things that can cause them to diverge. Probably the most obvious is differing game state; for example, the player may have bumped into another player on the server that the client didn’t know was there. But there are other subtler things that can cause divergence. One is timesteps; if the server and client use different timesteps, the player controller can produce different results. You either need to ensure the server and client use the same timesteps, or use a client-side prediction algorithm that works independently of timesteps. We chose the latter approach for Kazap.io. Our server runs at a fixed-time step, but our client does not. But even if you use the same timesteps and the client is aware of all the game state that can affect player movement, there’s something else that can cause the client and server to diverge: floating points. Floating-point math is non-deterministic and can give different results when run on different hardware.

So we need to handle the case where the server and client diverge too far. One approach is to keep a history of your positions going back to the last time you have a server position for. When a new position arrives from the server, you test it against the predicted position for that time in your history, and if the difference exceeds some tolerance, correct the client position. To determine the new client position, you need to replay your inputs from the moment the client and server diverged, using the new game state from the server. This means in addition to keeping a history of positions, you also need a history of inputs, and, if not using a fixed-timestep, the time deltas for those inputs. Once you have the corrected position, immediately setting the player’s position to it can cause a jarring “snapping” effect. Instead, you’ll want to smoothly interpolate between the old and new positions.

Depending on your type of game this approach may work well for you. There is, however, a drawback with this method: the client is always ahead of the server. Let’s say you have 100 ms of lag and your character moves at a speed of 10 units per second. The client will see their player start moving 100 ms before they receive the server frame where they actually started moving. The client’s position will stay 100 ms ahead of the server, and at a speed of 10 units per second, 1 unit ahead of the server. In a game like Kazap.io where bullets can hit your ship, this can lead to situations where a bullet hits you when it looks like it did not. Given that we want to render the player locally at a location that’s different from the server location, this is always a possibility. But we want to reduce the separation between client and server positions to reduce the occurrence of hits that look like they should have missed. The result of our method is that it takes longer accelerate on the client so when you do reach your top speed, you’ll converge with the server location. Players with more latency will feel like they have more inertia—it takes longer for them to change their momentum.

 Graphs showing server and client position over time for the two client-side prediction methods

Graphs showing server and client position over time for the two client-side prediction methods

 

 

 A frame-by-frame comparison of both prediction methods. The server sends an update once every two client frames.

A frame-by-frame comparison of both prediction methods. The server sends an update once every two client frames.

The following diagram helps illustrate our algorithm.

S is the last known server position and C is the current client position. P is where we predict the server will be in L milliseconds where L is the round-trip latency. We then extrapolate P using the last predicted velocity to get E. Finally, we interpolate C towards E to get R, the new position to render the client at.

It's important to determine what “position” means for your game. It may be position and rotation. Rotation may be a quaternion or just a float if you’re only rotating around one axis. You also need to determine which variables are needed to calculate a new “position”. Together these make up a player state. In Kazap.io, a player state consists of Vector2 position, a float rotation, and a Vector2 velocity.

To predict the server position P, we store a history of position and time deltas for a period equal to the latency. Every client frame we run the player controller on P to get a new P. We subtract the old P from the new P to get a position delta to add to our history of deltas. When a server update arrives, we remove frames from our history until the history contains L milliseconds of deltas where L is the round-trip latency. The last history frame will be shortened if needed; if the frame is for 20 ms and we need to remove 10 ms from our history, the frame will have its time, position, and rotation deltas cut in half. We then sum the deltas in our history and add them to S to get a new P. For some games this may be sufficient to predict P. In Kazap.io, however, a player’s velocity can change drastically when the player collides with another ship. We don’t predict ship collisions on the client. Because a player’s velocity on a given frame is dependent on their velocity last frame, just applying the deltas from history would give wildly inaccurate predictions for several seconds after a collision. To solve this, we also store predicted velocity and inputs in our history. If the predicted velocity and the server velocity exceed a tolerance, we replay our inputs through the player controller using the new server velocity to get a more accurate prediction.

Our algorithm is presented below as pseudo code.

// Called when we receive a player state update from the server. 
function OnServerFrame(serverFrame)
{
    // Remove frames from history until it's duration is equal to the latency.
    dt = Max(0, historyDuration - latency);
    historyDuration -= dt;
    while (history.Count > 0 && dt > 0)
    {
        if (dt >= history[0].DeltaTime)
        {
            dt -= history[0].DeltaTime;
            history.RemoveAt(0);
        }
        else
        {
            t = 1 - dt / history[0].DeltaTime;
            history[0].DeltaTime -= dt;
            history[0].DeltaPosition *= t;
            history[0].DeltaRotation *= t;
            break;
        }
    }

    serverState = serverFrame;

    // If predicted and server velocity difference exceeds the tolerance,
    // replay inputs. This is only needed if the
    // velocity for one frame depends on the velocity of the previous
    // frame. Depending on your game you may also need
    // to do this for angular velocity or other variables.
    if ((serverState.Velocity - history[0].Velocity).Magnitude >     
        velocityTolerance)
{
        predictedState = serverState;
        foreach (frame in history)
        {
            newState = playerController.Update(predictedState,
                frame.DeltaTime, frame.Input);
            frame.DeltaPosition = newState.Position - 
                predictedState.Position;
            frame.DeltaRotation = newState.Rotation - 
                predictedState.Rotation;
            frame.Velocity = newState.Velocity;
            predictedState = newState;
        }
    }
    else
    {
        // Add deltas from history to server state to get predicted state.
        predictedState.Position = serverState.Position;
        predictedState.Rotation = serverState.Rotation;
        foreach (frame in history)
        {
            predictedState.Position += history.DeltaPosition;
            predictedState.Rotation += history.DeltaRotation;
        }
    }
}

// Called every client frame.
function Update(deltaTime, input)
{
    // Run player controller to get new prediction and add to history
    newState = playerController.Update(predictedState, deltaTime, input);
    frame = new Frame(deltaTime, input);
    frame.DeltaPosition = newState.Position - predictedState.Position;
    frame.DeltaRotation = newState.Rotation - predictedState.Rotation;
    frame.Velocity = newState.Velocity;
    history.Add(frame);
    historyDuration += deltaTime;

    // Extrapolate predicted position
    // CONVERGE_MULTIPLIER is a constant. Lower values make the client converge with the server more aggressively.
    // We chose 0.05.
    rotationalVelocity = (newState.Rotation - predictedState.Rotation) /
        deltaTime;
    extrapolatedPosition = predictedState.Position + newState.Velocity *
        latency * CONVERGE_MULTIPLIER;
    extrapolatedRotation = predictedState.Rotation + rotationalVelocity *
        latency * CONVERGE_MULTIPLIER;

    // Interpolate client position towards extrapolated position
    t = deltaTime / (latency * (1 + CONVERGE_MULTIPLIER));
    clientState.Position = (extrapolatedPosition - clientState.Position) *
        t;
    clientState.Rotation = (extrapolatedRotation - clientState.Rotation) *
        t;

    predictedState = newState;
}

 

Hopefully you now have a better understanding of what client-side prediction is and how it can be implemented. Client-side prediction is just one of many things you can do to hide latency. If you want to learn more about different latency compensation technique, check out this blog by Valve geared towards first person shooters!

To see this client-side prediction technique in action, check out our game, kazap.io!

To learn more about what we are doing to help developers build better games, faster – check out our multi-user scene collaboration tool for Unity, Scene Fusion.

 

Written by Alyosha Pushak, Sr. developer at KinematicSoup.