Lag Compensation in Unreal Engine 4 (C++) – Part 2: Latency Tracking

This series is no longer being worked on for the foreseeable future

This article is a part of a series – make sure to read Part 1 – Hitbox Tracking before moving forward!

Now that we’ve got our hitbox history properly tracked, it’s time to do some collision checking against it. However, we still need a couple of things in place before we can perform these checks:

  1. A way to determine how much latency we need to rewind the shooter by,
  2. A function to retrieve a saved hitbox based on the aforementioned latency of the shooter,
  3. A way to determine if a line segment is intersecting within a bounding volume (our hitbox)

In this article, we’ll be focusing on implementing item #1 from our list. While UE4 does do some internal ping tracking in the PlayerState, it’s not updated on a consistent basis. To guarantee that we will always have a fresh ping value, we’ll need to introduce functionality to update on a fixed interval.

Please note that this series of articles assumes the reader has a fair grasp on C++ and Unreal Engine 4. Please don’t hesitate to leave a comment with any questions you may have!


Latency Tracking

In our PlayerController class, GameplayController, we’ll declare all of the following. Note that we’ve also created a custom implementation of PlayerState, MPlayerState:

// GameplayController.h
// ...

#include "MPlayerState.h"
#include "GameplayController.generated.h"

UCLASS()
class MERCILESS_API AGameplayController : public APlayerController {

    // ...

    public:
        virtual void PlayerTick(float DeltaTime) override;


        ///@brief Last time this client's ping was updated.
        UPROPERTY()
        float LastPingUpdateTime;


        ///@brief Ran on the client. Checks whether or not we should
        ///       send a ping to the server - if so, calls ServerBouncePing.
        UFUNCTION()
        void CheckSendPing();


        ///@brief Request a ping update. Called on client.
        UFUNCTION(Unreliable, Server, WithValidation)
        virtual void ServerBouncePing(float TimeStamp);


        ///@brief Respond to a client-requested ping update.
        UFUNCTION(Unreliable, Client)
        virtual void ClientReturnPing(float TimeStamp);


        ///@brief Client informs server of new ping.
        UFUNCTION(Unreliable, Server, WithValidation)
        virtual void ServerUpdatePing(float TimeStamp);


        UPROPERTY(BlueprintReadOnly)
        AMPlayerState* MPlayerState;


        void InitPlayerState();


        virtual void OnRep_PlayerState();


    // ...

}

Let’s make sure that we have MPlayerState properly initialized within our GameplayController:

void AGameplayController::InitPlayerState() {
    Super::InitPlayerState();
    MPlayerState = Cast<AMPlayerState>(PlayerState);
    if (PlayerState && PlayerState->PlayerName.IsEmpty()) {
        UWorld* const World = GetWorld();
        if (World) {
            PlayerState->PlayerName = "Player";
        }
    }
}

We’ll also need to keep refreshing our MPlayerState whenever the PlayerState is replicated from the server:

// GameplayController.cpp
// ...

void AGameplayController::OnRep_PlayerState() {
    Super::OnRep_PlayerState();
    MPlayerState = Cast<AMPlayerState>(PlayerState);
}

We’ll start by implementing PlayerTick, in which the only thing we need to do is call CheckSendPing:

// GameplayController.cpp
// ...

void AGameplayController::PlayerTick(float DeltaTime) {
    Super::PlayerTick(DeltaTime);
    CheckSendPing();
}

We’ll then implement CheckSendPing. The objective here is to call ServerBouncePing on an interval (PingUpdateInterval):

// GameplayController.cpp
// ...

void AGameplayController::CheckSendPing() {

    if (HasAuthority()) {
        // We should only ever send ping updates from the client to the server.
        return;
    }

    const float WorldSeconds = GetWorld()->GetTimeSeconds();

    if ((WorldSeconds - LastPingUpdateTime > PingUpdateInterval)) {
        LastPingUpdateTime = WorldSeconds;
        ServerBouncePing(WorldSeconds);
    }

}

ServerBouncePing does an extremely simple job – we just return the TimeStamp passed by the client back to the client, via ClientReturnPing:

// GameplayController.cpp

bool AGameplayController::ServerUpdatePing_Validate(float TimeStamp) {
    return true;
}


void AGameplayController::ServerBouncePing_Implementation(float TimeStamp) {
    ClientReturnPing(TimeStamp);
}

ClientReturnPing will then calculate the round trip time (RTT) by taking the difference of the current world time and the timestamp we were returned. We’ll then pass the RTT, which is also our ping, into our custom PlayerState implementation, MPlayerState::CalculatePing:

// GameplayController.cpp

void AGameplayController::ClientReturnPing_Implementation(float TimeStamp) {

    if (!MPlayerState) {
        return;
    }

    const float RoundTripTime = GetWorld()->GetTimeSeconds() - TimeStamp;
    MPlayerState->CalculatePing(RoundTripTime);

}

Next, we’ll define out what we’ll need within our custom PlayerState implementation, MPlayerState:

#pragma once

#include "GameFramework/PlayerState.h"
#include "MPlayerState.generated.h"

UCLASS()
class MERCILESS_API AMPlayerState : public APlayerState {

    GENERATED_BODY()

    public:

        // ...

        ///@brief Overriding to avoid doing engine-style ping updates.
        virtual void UpdatePing(float InPing) override;

        virtual void CalculatePing(float NewPing);

        // ...

}

We override UpdatePing as we want to control the behavior of ping updates within this implementation. We’ll manually invoke Super::UpdatePing from within our CalculatePing implementation:

// MPlayerState.cpp
// ...

void AMPlayerState::UpdatePing(float InPing) {
    // Do nothing
}

void AMPlayerState::CalculatePing(float NewPing) {

    // Ignore overflow
    if (NewPing < 0.f) {
        return;
    }

    float OldPing = ExactPing;
    Super::UpdatePing(NewPing);

    AGameplayController* PC = Cast<AGameplayController>(GetOwner());
    if (!PC) {
        return;
    }

    PC->LastPingUpdateTime = GetWorld()->GetTimeSeconds();
    if (ExactPing != OldPing) {
        PC->ServerUpdatePing(ExactPing);
    }

}

With our ping now updated on the PlayerState, we need to tell the server that we’ve calculated a new Ping value. Back in our PlayerController implementation, GameplayController, we define the following:

// GameplayController.h
// ...

UCLASS()
class MERCILESS_API AGameplayController : public APlayerController {

    public:

        // ...

        ///@brief Client informs server of new ping.
        UFUNCTION(Unreliable, Server, WithValidation)
            virtual void ServerUpdatePing(float TimeStamp);

        // ...

}

…and its implementation:

bool AGameplayController::ServerUpdatePing_Validate(float ExactPing) {
    return true;
}


void AGameplayController::ServerUpdatePing_Implementation(float ExactPing) {

    if (!MPlayerState) {
        return;
    }

    MPlayerState->ExactPing = ExactPing;

    // We need to compress ExactPing (float) to Ping (int32) for replication - see docs for PlayerState:
    // https://docs.unrealengine.com/latest/INT/API/Runtime/Engine/GameFramework/APlayerState/index.html
    MPlayerState->Ping = (int32) (ExactPing / 4);

    // Handle overflow
    if (MPlayerState->Ping < 0) {
        MPlayerState->Ping = 255;
        MPlayerState->ExactPing = (float) (MPlayerState->Ping * 4);
    }

}

With all of the above in place, we should now be consistently tracking player latencies. We can do some sanity checking by adding a ping widget to our game’s UI:

Get Ping Text function
Hooking into our UI
Hooking into our UI

We’ll then launch into our game, and run the console command Net PktLag=200 to simulate network latency. Without moving, we can see our ping start to spike up to around 200ms:

Testing out our Ping Update functions
Testing out our Ping Update functions

With latencies now consistently tracked, we’re now ready to write a function that will tell us how far back we should rewind the shooting player by during our collision checks. Let’s add GetPredictionTime to our PlayerController class, GameplayController:

// GameplayController.h
// ...

UCLASS()
class MERCILESS_API AGameplayController : public APlayerController {

    public:

        // ...

        ///@brief Max amount of ping to predict ahead for.
        const float MaxPredictionPing = 255.0f;

        ///@brief Used to correct prediction error.
        ///       We've found that a value of 1.8 here seems to be
        ///       'good enough' for our use case. It's wise to play around
        ///       with the implementation of GetPredictionTime()
        ///       in your project - the time returned by it is extremely
        ///       crucial in getting accurate lag compensation.
        ///
        const float PredictionCorrectionFactor = 1.8f;


        ///@brief Returns amount of time, in seconds, to tick to make up
        ///       for network lag.
        virtual float GetPredictionTime();

        // ...

}

…and its implementation:

// GameplayController.cpp
// ...

float AGameplayController::GetPredictionTime() {

    if (!PlayerState) {
        return 0.f;
    }

    const float ClampedPingMillis = FMath::Clamp(PlayerState->ExactPing, 0.f, MaxPredictionPing);
    const float ClampedPingSeconds = (ClampedPingMillis / 1000.0f);

    return ClampedPingSeconds * PredictionCorrectionFactor;

}

Keep in mind that it’s really important to tweak your implementation of GetPredictionTime to fit your use case. The value that this function returns is crucial for getting accurate rewind data. If you’re getting inconsistent lag compensation behavior down the line, this should be one of the first functions to inspect.

In Part 3 of this series, we’ll be able to use GetPredictionTime to retrieve a set of hitboxes from our tracked history and then do a collision check. Stay tuned!

6 thoughts on “Lag Compensation in Unreal Engine 4 (C++) – Part 2: Latency Tracking

  1. These post are fantastic. Literally the only resource I have found that goes over this for UE4. I noticed you haven’t posted in awhile, I hope you can find time to finish this series, i have learned so much already. Thank you for doing these, keep up the good work!

  2. Your posts about lag compensation have been exactly what I’ve been looking for. Will you ever be continuing this series? This is the only concise place ive found that goes indepth on this

  3. To all who commented about whether or not I’m still going to finish this series – yes, I still intend to! I’ve had to take a hiatus from game development for a good while, this series will be the first thing I revisit.

  4. Why do u need the PredictionCorrectionFactor? Shouldnt the Ping that u calculated be exactly what u need?

    Really great series btw, loved what u doen until here.

Leave a Reply

Your email address will not be published. Required fields are marked *