2D Top Down Shooter UNET Lag Compensation

Hi, hope someone is able to offer some guidance. I have a game in the app stores (links at the bottom) called Battle Royale. It’s a 2D top down multiplayer shooter using UNET with an authoritative server approach when it comes to firing bullets. Unfortunately as the bullets are GameObjects that move relatively slowly (as dodging is very much a part of the gameplay) this leads to bullets not spawning from the gun tip if the player is moving but rather where the player last was by the time the bullet is spawned on the server.

What would be the best approach to vastly improving the feel of shooting while not opening up the game to cheating (client side authority) or making the game very unfair for people with worse latency than others?

Android - https://play.google.com/store/apps/details?id=com.solidstategroup.battleroyale
iOS - ‎Battle Royale on the App Store

I do both server-side and client-side prediction. I’ve found that 99% of the time the player’s motion has not changed from the previous tick so you can assume that the player is at the last known location plus their last known velocity times delta time. It won’t be perfect (nothing ever will) but it will make a considerable difference.

Feel free to use my implementation, let me know if you find any bugs in it. Add both components to your networked object and link your Interpolator to the NetworkSyncTransform and everything else should just work.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Runtime.CompilerServices;

public class Interpolator : MonoBehaviour {
    public bool isSource;
    public bool dirtyRot;
    public bool dirtyPos;

    private const float _posLerpRate = 20;
    private const float _rotLerpRate = 15;
    private const float _posThreshold = 0.1f;
    private const float _rotThreshold = 1f;
 
    public Vector3 _serverPosition;
    public Vector3 _serverSpeed;
    public Vector3 _serverRotation;

    private Vector3 previousPosition;
    private Vector3 previousRotation;


    private Vector3 previousLastPosition;
    private float updateTimer = 999.9f;  //Will do the update on the very first frame
    private Vector3 nextPosition;

    private bool sendPositionAnywayFlag;

    void Start(){
        previousPosition = nextPosition = _serverPosition = transform.position;
        previousRotation = _serverRotation = transform.rotation.eulerAngles;
    }
 
    void Update(){
        if (isSource){
            updateTimer += Time.deltaTime;
            if (updateTimer > 0.0333333f){  //30ish times per second
                Vector3 spd = transform.position - previousPosition;
                if (IsPositionChanged()){
                    if (spd.magnitude > 3.0f) spd = Vector3.zero;  //Likely a teleport
                    SendPosition(transform.position, spd / updateTimer);
                    previousPosition = transform.position;
                } else if (spd != Vector3.zero){
                    SendPosition(transform.position, Vector3.zero);
                }
                if (IsRotationChanged()){
                    SendRotation(transform.localEulerAngles);
                    previousRotation = transform.localEulerAngles;
                }
                updateTimer = 0;
            }
        } else {
            InterpolatePosition();
            InterpolateRotation();
        }
    }

    //This is called when whatever is using it has retrieved the data
    public void clean(){
        dirtyPos = dirtyRot = false;
    }
 
    private void InterpolatePosition(){
        Debug.Assert(!isSource);  //Only interpolate if it's a remote object
        if (previousPosition != _serverPosition){
            previousPosition = nextPosition = _serverPosition;
        } else {
            nextPosition += _serverSpeed * Time.deltaTime / 1.25f;  //Prediction isn't perfect and only doing a little interpolation makes it smoother
        }

        if (Vector3.Distance(transform.position, nextPosition) > 3.0f){  //If it's really laggy or if we teleport the character
            transform.position = nextPosition;
        } else {
            transform.position = Vector3.Lerp(transform.position, nextPosition, Time.deltaTime * _posLerpRate);
        }
    }
 
    private void InterpolateRotation(){
        Debug.Assert(!isSource);  //Only interpolate if it's a remote object
        transform.rotation = Quaternion.Lerp(transform.rotation, Quaternion.Euler(_serverRotation), Time.deltaTime * _rotLerpRate);
    }
 
    private void SendPosition(Vector3 pos, Vector3 spd){
        _serverSpeed = spd;
        _serverPosition = pos;
        dirtyPos = true;
    }
 
    private void SendRotation(Vector3 rot){
        _serverRotation = rot;
        dirtyRot = true;
    }

    public void debugOverride(Vector3 spd, Vector3 pos, Vector3 rot){
        _serverSpeed = spd;
        _serverPosition = pos;
        _serverRotation = rot;
    }
 
    private bool IsPositionChanged(){
        Debug.Assert(isSource);  //We only care if position has changed if local
        if (Vector3.Distance(transform.position, previousPosition) > _posThreshold){
            sendPositionAnywayFlag = false;
            return true;
        }
        //Even if we're not moving much, sync it, just slower
        if (sendPositionAnywayFlag) return true;
        sendPositionAnywayFlag = !sendPositionAnywayFlag;
        return false;
    }
 
    private bool IsRotationChanged(){
        Debug.Assert(isSource);  //We only care if rotation has changed if local
        return Vector3.Distance(transform.localEulerAngles, previousRotation) > _rotThreshold;
    }
}
using UnityEngine;
using System.Collections;
using UnityEngine.Networking;
 
public class NetworkSyncTransform : NetworkBehaviour {
    public Interpolator interpolator;
 
    [SyncVar]
    public Vector3 _serverPosition;

    [SyncVar]
    public Vector3 _serverSpeed;
 
    [SyncVar]
    public Vector3 _serverRotation;
 
    void Update(){
        if (isLocalPlayer){
            interpolator.isSource = true;
            if (interpolator.dirtyPos){
                if (interpolator.dirtyRot){
                    CmdSendRotation(interpolator._serverRotation);
                } else {
                    CmdSendBoth(interpolator._serverPosition, interpolator._serverSpeed, interpolator._serverRotation);
                }
            } else if (interpolator.dirtyRot){
                CmdSendRotation(interpolator._serverRotation);
            }
            interpolator.clean();
        } else {
            interpolator._serverPosition = _serverPosition;
            interpolator._serverSpeed = _serverSpeed;
            interpolator._serverRotation = _serverRotation;
        }
    }
 
 
    [Command(channel = Channels.DefaultUnreliable)]
    private void CmdSendPosition(Vector3 pos, Vector3 spd){
        _serverSpeed = spd;
        _serverPosition = pos;
    }
 
    [Command(channel = Channels.DefaultUnreliable)]
    private void CmdSendRotation(Vector3 rot){
        _serverRotation = rot;
    }

    [Command(channel = Channels.DefaultUnreliable)]
    private void CmdSendBoth(Vector3 pos, Vector3 spd, Vector3 rot){
        _serverSpeed = spd;
        _serverPosition = pos;
        _serverRotation = rot;
    }

    public override int GetNetworkChannel(){
        return Channels.DefaultUnreliable;
    }

    public override float GetNetworkSendInterval(){
        return 0.01f;
    }
}
1 Like

Two other approaches off the top of my head.

The easiest would be to require players to be stationary or move slowly while shooting, which would make it far more likely that when the bullet appears on the client that fired it that it will be at or near the end of the gun.

Second would be on the player firing the bullet to instantiate it immediately locally and then sync it up with the bullet that the server instantiates when it eventually is spawned on the client that fired it. Might be tricky depending on how quickly bullets are moving.

@newjerseyrunner Thanks for the code! I should have mentioned that player movement is being sync’d using the 3rd party asset Smooth Sync (prior to that it was just the standard Network Transform component). I don’t think I can use your components on a bullet because clients cannot spawn GameObjects. I assume those components are really for the player GameObject.

@Joe-Censored I really like your 2nd idea. I am wary tho that it might look a bit strange. It may well be a price I have to pay. I would imagine that if you’re strafing left shooting forward, the bullet would appear to curve off from the gun tip to the right as it interpolates over to the server instantiated bullets position.

I have an article on Lag Compensation where I sync positions at different “ticks”. When I actually want to do compensation I also submit the interpolation state between the previous and current tick. Might help you TwoTenPvP.github.io

Edit:
The advantage of sending the interpolation state aswell as the tick is that we get a exact position. In the MLAPI we have a similar tehcnique but it’s time based. And in the MLAPI it’s also garbage free

6 Likes

Thanks for the link @TwoTen . That was an interesting read. I ended up going with @Joe-Censored 's idea. The curve effect isn’t that bad unless latency is high and it was not too difficult to implement. Blood is only spawned by the server in my game so players do have an indication of whether they actually did damage or not.