Unity Authoritative Server Part 3

3 years 2 months ago by damagefilter

NOTE: This is a copy of the original text and might lack photos and proper formatting. I'm leaving it here for reference and will eventually come around to fix these articles again!

Welcome back to the next (and for now last) Unity 3D Networking article!
If you were reading the last two articles and have used the presented code, or parts of it, for your own project, you will have noticed that player movement is far from being smooth. In fact it's completely lagged and in the real world (of gaming) this is unacceptable.

Today I will introduce a method of working against this lag with a something called "prediction". In case you don't know what that is, it's basically the client trying to guess the position it will receive with the next movement update from the server. It will then already process this guess and move acording to what it thinks is correct and will later be corrected with the server input, once this arrrives.
That means we need to partially let go of the "dumb client" idea as the it needs to think about its movement now. This is a tradeoff for a (far) better player experience and since the server will still override the client if results differ, it's not all too bad.
I highly recommend you this paper from Yahn Bernier of Valvesoftware for further reading. Only, remember this: Unity is not the Source Engine (or GldSrc for that matter) and things work slightly different here. Mostly due to the fact that we're not at a sourcecode level but on a scripting level where all the dirty work is already done for us.
Another important note, I took a lot of leaves out of the books of this collection thread on stttutter.com (which you should have a look at too, for further reading)

Okay so lets think about this. We have two peers connected to the server and both their positions are controlled server-side, thus both appear to be lagging a lot. To combat that on the local player (the one you're playing) we can simply use position lerping to smooth out movement. This works reasonably good and with some trickery the lag effect is removed even when fast movement changes occur. Prediction errors will be smoothed out, literally.

It's not that easy on remote objects though. It will still move in a laggy sort of way and we need to think about something else here. The idea is to play back a buffered version of the remote objects movement for the local player instead of the actual movement updates. This will elimitate the laggy movement. How? By playing it with a delay which is calculated from the players average ping to the server plus some top-margin to counter lagspikes. However, this is not an accurate view thus the positions a local player is seeing other objects at are not 100% correct. That's the downside of it and actually roots in the fact that the server controls all movement. Apart from not using an authoritative server, there is nothing you can do against it.

Let's get started with the new stuff then. First thing is; We open the old C_PlayerManager code because we need to add two members and a new method to it additionally to changing the Update() code slightly. First the members and the new method.

public var positionErrorThreshold : float = 0.2;
public var serverPos : Vector3;
public var serverRot : Quaternion;

public function lerpToTarget() {
    var distance = Vector3.Distance(transform.position, serverPos);
    
        //only correct if the error margin (the distance) is too extreme
    if (distance >= positionErrorThreshold) {
        var lerp = ((1 / distance) * speed) / 100;
        //Debug.Log("Lerp time: " + lerp);
        transform.position = Vector3.Lerp(transform.position, serverPos, lerp);
        transform.rotation = Quaternion.Slerp(transform.rotation, serverRot, lerp);
    }
}

So this function interpolates the current client position with the real server position with a calculated lerp time to ensure there's no stuttering or odd movement when correcting the position. It's called by the Predictor, which we will discuss in detail further down the page.
Furthermore we now have the server position and rotation stored on the client, which are the last known server positions. We use them to correct the clients predicted positions. You may make them private and add getters and setters to it instead of having them public members.
The next and last thing in C_PlayerManager is to add movement logic to the Update() function. For this we simply use the same logic the server uses.

public function Update () {
    if (Network.isServer) {
        return; //get lost, this is the client side!
    }
    //Check if this update applies for the current client
    if ((owner != null) && (Network.player == owner)) {
        var motionH : float = Input.GetAxis("Horizontal");
        var motionV : float = Input.GetAxis("Vertical");
        networkView.RPC("updateClientMotion", RPCMode.Server, motionH, motionV);
        //Simulate how we think the motion should come out
        controller.Move(Vector3(
        motionH * speed * Time.deltaTime, 
        0, 
        motionV * speed * Time.deltaTime));
    }
}

You see, we basically work with the same data as the server now and that's about it. The rest of the logic will be handled somewhere else as that unfortunately requires a mix of client and server code in one class. In case I come up with something better I will revise this article though.
Now, lets make a new class in src/shared. I dubbed it the "Predictor" - therefore saved it as Predictor.js. You may call it something else instead. This class will take care of getting the networking straight, update the local player aswell as smoothly predict all remote peers for the local player. Before this all the movement data was sent automatically.
Why was this? Because the assumed NetworkView was observing the Transform component. That automates the task of sending and receiving data. We will change that now so the NetworkView observes the Predictor class and the Predictor on the other hand, will get the Transform it should work on. In the end it should look like this:

I hope this helps you to understand and clears up potential confusions. From this picture you can already see that the Predictor will also have a receiver and a ping margin as public members. I will explain them once we get there.
The Predictor will also use a data structure to save and manage data that was received from the server, so before we actually start with it, lets do this data structure first. I called it NetState and it resides in src/shared/NetState.js

class NetState {

    public var timestamp : float; //The time this state occured on the network
    public var pos : Vector3; //Position of the attached object at that time
    public var rot : Quaternion; //Rotation at that time
    
    function NetState() {
        timestamp = 0.0f;
        pos = Vector3.zero;
        rot = Quaternion.identity;
    }
    
    function NetState(time : float, pos : Vector3, rot : Quaternion) {
        timestamp = time;
        this.pos = pos;
        this.rot = rot;
    }
}

It has nothing in it besides the three public members you can see and a constructor override for convenience.
I think this does not need to be explained any further so lets move on to the Predictor.
What it needs to do is taking care of what gets sent over the network and how this data is received at the client-side. This is very important and it uses the OnSerializeNetworkView(...) event which is received by MonoBehaviours that are observed by a NetworkView, so that is our Predictor. Lets start with the basic thing now:

#pragma strict
@RequireComponent(NetworkView)

public class Predictor extends MonoBehaviour {
    public var observedTransform : Transform;
    public var receiver : C_PlayerManager; //Guy who is receiving data
    public var pingMargin : float = 0.5f; //ping top-margin

    private var clientPing : float;
    private var serverStateBuffer : NetState[] = new NetState[20];
}

Things are going to be more confusing soon so lets start small.

observedTransform : Transform
This is the Transform component that is observered, the data of this one will be sent over the network.

receiver : C_PlayerManager
This is the client-side player manager that is receiving the new server data, it is used for the local player, not the remote peers.

pingMargin : float
That is the top-margin which is added to the current average player ping to counter lagspikes during movement prediction of remote clients.

serverStateBuffer : NetState[]
This array will contain 20 states that have been sent by the server where the first element will always be the latest and the last element will be the oldest. Those elements will be used to interpolate between each other to smooth out the movement of remote players.

The OnSerializeNetworkView method is next. This one has grown a bit as it must process client and server-side at the same time.
A quick review: How can we determine if a NetworkView is sending or receiving data?
It sends data if the attached object is its owner
it receives data if the attached object is a remote object (clients)
Programatically we do this by checking the state of the stream we're getting. Here's how it looks like.

public function OnSerializeNetworkView(stream : BitStream, info : NetworkMessageInfo) {
    var pos = observedTransform.position;
    var rot = observedTransform.rotation;
    
    if (stream.isWriting) {
        //Debug.Log("Server is writing");
        stream.Serialize(pos);
        stream.Serialize(rot);
    }
    else {
        //This code takes care of the local client!
        stream.Serialize(pos);
        stream.Serialize(rot);
        receiver.serverPos = pos;
        receiver.serverRot = rot;
        //Smoothly correct clients position
        receiver.lerpToTarget();
        
        //Take care of data for interpolating remote objects movements
        // Shift up the buffer
        for ( var i : int = serverStateBuffer.Length - 1; i >= 1; i-- ) {
            serverStateBuffer[i] = serverStateBuffer[i-1];
        }
        //Override the first element with the latest server info
        serverStateBuffer[0] = new NetState(info.timestamp, pos, rot);
    }
}

There is a good example of how to determine if the method is invoked on the server (stream.isWriting) or on the client. This is as documented here. It couldn't be easier.

If the stream is writing we're taking the observed transform and put its position and rotation into the stream.
Here's an important notice: There is an override for BitStream.Serialize which allows you to define a max. delta value, which is basically to reduce the bits that need serializing. What exactly that means? It means that you can, for example, cut down the bits of floats or doubles. In case you don't need the whole 32bit floating point precision, you can simply trim it and reduce the data that is being sent over the network. Dreamora on the Unity 3D forums explained that very good.

If the stream is reading we're doing three things.

  • Read the stream data
  • Update the local client and apply prediction correction (receiver.lerpToTarget();)
  • Update the serverStateBuffer with the new data and re-arrange the data so the latest position is the first element.

Additionally to re-arranging the serverStateBuffer you could iterate over the newly arranged data to make sure the frames are consistent. That is, check if the current frames timestamp is newer that the previous one. As you cannot simply invent a better matching state for inconsistent timestamps, and can therefore cannot do much against it, you might aswell not check for it though.
To view the prediction errors on the client, that is the distance between the assumed position and the position the server has calculated, you can simply log the distance variable in C_PlayerManager.lerpToTarget(). Depending on latency this can range from less than 0.002 to a whole bunch of units.

Okay that's about the serializing data part. No magic involved, and after all, not too hard to grasp, I guess. Lets think about what we have after this call has finished (in theory).
The most interesting part would be the serverStateBuffer now. Important here, and not to confuse: The timestamp we just saved form the NetworkMessageInfo has nothing to do with the time the game is running already, it's not a deltaTime either. It's a relative value to the current Network.time. By subtracting this timestamp from Network.time you get the time this state has spent in the aether (that place between server and client ;) ).
So we have that collection of states, we have the timestamps that are basically information about when this state was sent and we can calculate the time this state has spent travelling through the network, also known as latency, or ping. That sounds like we can use it to smooth out remote object movement, does it not?
Lets do this now.

public function Update() {
    if ((Network.player == receiver.getOwner()) || Network.isServer) {
        return; //This is only for remote peers, get off
    }
    //client side has !!only the server connected!!
    clientPing = (Network.GetAveragePing(Network.connections[0]) / 100) + pingMargin;
    var interpolationTime = Network.time - clientPing;
    //ensure the buffer has at least one element:
    if (serverStateBuffer[0] == null) {
        serverStateBuffer[0] = new NetState(0, 
                                    transform.position, 
                                    transform.rotation);
    }
    //Try interpolation if possible. 
    //If the latest serverStateBuffer timestamp is smaller than the latency
    //we're not slow enough to really lag out and just extrapolate.
    if (serverStateBuffer[0].timestamp > interpolationTime) {
        for (var i : int = 0; i < serverStateBuffer.Length; i++) {
            if (serverStateBuffer[i] == null) {
                continue;
            }
            // Find the state which matches the interp. time or use last state
            if (serverStateBuffer[i].timestamp <= interpolationTime|| 
                i == serverStateBuffer.Length - 1) {
                
                // The state one frame newer than the best playback state
                var bestTarget : NetState = serverStateBuffer[Mathf.Max(i-1, 0)];
                // The best playback state (closest current network time))
                var bestStart : NetState = serverStateBuffer[i];
                
                var timediff : float = bestTarget.timestamp - bestStart.timestamp;
                var lerpTime : float = 0.0F;
                // Increase the interpolation amount by growing ping
                // Reverse that for more smooth but less accurate positioning
                if (timediff > 0.0001) {
                    lerpTime = ((interpolationTime - bestStart.timestamp) / timediff);
                }
                
                transform.position = Vector3.Lerp(  bestStart.pos, 
                                                    bestTarget.pos, 
                                                    lerpTime);

                transform.rotation = Quaternion.Slerp(  bestStart.rot, 
                                                        bestTarget.rot, 
                                                        lerpTime);
                //Okay found our way through to lerp the positions, lets return here
                return;
            }
        }
    }
    //so it appears there is no lag through latency.
    else {
        var latest : NetState = serverStateBuffer[0];   
        transform.position = Vector3.Lerp(transform.position, latest.pos, 0.5);
        transform.rotation = Quaternion.Slerp(transform.rotation, latest.rot, 0.5);
    }
}

Okay, that is something right there. What this does in short:
Run through the recorded server states
Check if there is one that matches our current network time, if so use this to interpolate with the the next more recent state (bufferIndex-1)
If there is no match, use the latest kown server position and use it for extrapolation. This happens if the ping is not high enough to produce lag so we don't need to play older states first.
And here's some more detail about how things work here.
The clientPing variable is an important one. It is basically the latency of the connection to the server plus the ping margin value (for lagspike issues). It is used to calculate if we need to use interpolation (playing back older server states) or if we can simply extrapolate.
Important: A client NEVER has information about connections of other players, except if explicitly requested from the server as isolated data. If you find that your client knows about more connections in the Network.connections array than the one to the server, there's something wrong in your protocol. Because that means someone is actually connected to your client directly.
This is possible in Unity networking (peer-to-peer networking), however it's not desirable in the authoritative server model.

The interpolationTime variable is just as important. It is calculated by the Network.time - (clientPing + pingMargin) and we're going to use this to determine if we need to play back old server states or if we can go on and just extrapolate the latest server state.
This variable could also be called latency or transitTime as it really defines the time the data has travelled through the network plus some error margin.

Going on, to the for loop. As already mentioned, if the timestamp of the latest server state is smaller than the last interpolationTime we skip interpolation as we're too fast and there is no reason to play older states. However, IF we're slow enough to have lag, we start running through the serverStateBuffer and check for a state that is smaller (or equal to) our last interpolationTime.
In plain english, if this condition is met, we have found the server state that has been the most recent on the server, while we were still behind, lagging out.
Now we're going to look up the next best "newer" state (current index - 1) and interpolate it with the old state.
Why do we do this? To catch up with the real server state. Easy as that.
And really, this ia all the magic about it. You should now have a running (and working) prediction system.
Congratualtions.

On a related note: You could use this very method for your local player too. It will work more reliable if extremely high pings occur (like 250ms +). While they are not a rare thing I think most games are unplayable with a ping like this in any case so there is no real need to bother. Also, the simple method we use for the local player is slightly faster.

And that's that.
I hope you enjoyed my articles and that I could shed some light on the slightly deeper insights of authoritative networking.