Albin Corén

While making multiplayer games, I personally felt like I had to balance Client trust and Server Authority.

While my title was in development we decided to go with a mixed setting. Clients had authority over movement (They sent their position x amount of times per second) and then the server would verify it. But when it came to shooting. We decided to go for full Server Authority. While creating that we released that we needed lag compensation. Since there is always a delay between the servers view and your view, if you aim straight at someone and fire. You’re going to miss. I came up with my own solution to this problem and it’s loosley based on these two articles:

http://www.gabrielgambetta.com/lag-compensation.html

https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking

The network library that was used for our title was UNET. It used a Polling method to recieve messages. You basically polled by calling a method until there was no more messages to process. This is what our flow looked like:

We poll recieve method until the message queue was empty and process every message If a position update was added. We added that position update to a dictionary rather than sending it to the other clients directly. The connectionId was used as key Once every message had been processed, and all new positons etc had been applied. We had a dictionary with pending position updates. Each of those position update messages also had a frameId identifier. This is the Time.frameCount that we included to the clients. We send off the position updates to all the clients We then do something we call SaveFrame.

SaveFrame

Every object that we concidered to be moving (Pretty much all Players) had a script on them. This script looked something like this

using System.Collections.Generic;
using UnityEngine;

public class SimulationObject : MonoBehaviour
{
    public Dictionary<int, SimulationFrameData> FrameData = new Dictionary<int, SimulationFrameData>();
    public List<int> Framekeys = new List<int>();

    private SimulationFrameData savedFrameData = new SimulationFrameData();

    void Start()
    {
        SimulationHelper.SimulationObjects.Add(this);
    }


    void OnDestroy()
    {
        SimulationHelper.SimulationObjects.Remove(this);
    }

    public void AddFrame()
    {
        if (Framekeys.Count >= ServerSettings.singleton.FrameHistory)
        {
            int key = Framekeys[0];
            Framekeys.RemoveAt(0);
            FrameData.Remove(key);
        }

        FrameData.Add(Time.frameCount, new SimulationFrameData()
        {
            Position = transform.position,
            Rotation = transform.rotation
        });
        Framekeys.Add(Time.frameCount);
    }

    public void SetStateTransform(int frameId, float clientSubFrame)
    {
        savedFrameData.Position = transform.position;
        savedFrameData.Rotation = transform.rotation;

        transform.position = Vector3.Lerp(FrameData[frameId - 1].Position, FrameData[frameId].Position, clientSubFrame);
        transform.rotation = FrameData[frameId - 1].Rotation;
    }

    public void ResetStateTransform()
    {
        transform.position = savedFrameData.Position;
        transform.rotation = savedFrameData.Rotation;
    }
}

And I also had a SimulationHelper where I would have a list of the active SimulationObjects. This means that we always have (ServerSettings.singleton.FrameHistory) amount of position and rotation updates. In my case, my servers are set to run at 64 frames per second. So my frame history is 64 (1 second worth of data)

The clients then recieve these position updates and also now has the frame id. It sets the FrameId as a static int somewhere. When it then wants to shoot. We just have to roll back time to that frameId. I used my SimulationHelper class to do that. Here is what it looks like:

    public static List<SimulationObject> SimulationObjects = new List<SimulationObject>();

    public static void Simulate(int frameId, Action action, float clientSubFrame)
    {
        if (frameId == Time.frameCount)
            frameId = Framekeys[Framekeys.Count - 1];

        
        for (int i = 0; i < SimulationObjects.Count; i++)
        {
            SimulationObjects[i].SetStateTransform(frameId, clientSubFrame);
        }

        action.Invoke();

        for (int i = 0; i < SimulationObjects.Count; i++)
        {
            SimulationObjects[i].ResetStateTransform();
        }
    }

So now I can simply pass a lambda method do that Simulate method and it would be run as the simulation. This means that I can simply roll back time to a specified frameId, and then invoke my action that could contain raycasts etc. Then roll back.

So when the client then want’s to fire. It can simply specify it’s current FrameId!

Perfect right? Well, there is two issues with this.

Problems and solutions

Problem #1

The first one is, if there has been no position updates. Ex if everybody stays still you will not get any new FrameId’s and the when you then shoot. The frameId will not exist.

Problem #2

The client’s don’t just blindly apply the position and rotation each update. They store it, and then they do interpolation between the previous and the current position. This means that a client can be inbetween two server frames.

Solution #1

This problem was super easy to fix. Basically if there was no position updates to send. And last position / frameUpdate was sent more than 0.25 seconds ago. I would send a FrameUpdate message that just contains the current frame. (This would basically be sent 4 times a second).

Solution #2

This one was a bit more tricky. But not too difficult. Everytime I moved the players client sided (Lerped, in my case. It was in my Update method) I also saved a float that contained the progress of the lerp. So the first iteration of the lerp. The progress would be 0, and if it’s reached it’s goal. It would be 1. I would then include this in my ShootRequest and the server would set all the objects positions to:

Vector3.Lerp(positionAtFrameBeforeTheOneTheyGaveUs, positionAtTheFrameTheyGaveUs, theSubFrameIdTheyGaveUs)

That way it would also account for interpolation this way.

Hope you found my first article helpful.

Thanks, TwoTen