Lag Compensation in Unreal Engine 4 (C++) – Part 1: Hitbox Tracking

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

When creating a multiplayer shooter, one will eventually need to address the issue of lag. At the time of this writing, Unreal Engine 4 doesn’t include any built-in solution for lag compensation, forcing us to roll our own lag compensation solution.

Time Rewind lag compensation is a relatively popular implementation of lag compensation. This method involves storing the location of each player’s hitboxes at a given time, and rewinding back to where each player’s hitboxes would be based on the latency of the player who fired the bullet during bullet collision checking.

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!


Defining the Player’s Hitboxes

In our project, for bullet collisions, we check against a number of smaller hitboxes on the player character so that we can apply different damages based on where the player is hit. We implement each of these hitboxes as a UBoxComponent instance.

// BasePlayer.h

UCLASS()
class MERCILESS_API ABasePlayer : public ACharacter {

	GENERATED_BODY()

public:

    // ...

    UBoxComponent* HB_Head;
    UBoxComponent* HB_LowerTorso;
    UBoxComponent* HB_UpperTorso;
    UBoxComponent* HB_LowerLeftLeg;
    UBoxComponent* HB_UpperLeftLeg;
    UBoxComponent* HB_LeftFoot;
    UBoxComponent* HB_RightFoot;
    UBoxComponent* HB_LowerRightLeg;
    UBoxComponent* HB_UpperRightLeg;
    UBoxComponent* HB_LeftHand;
    UBoxComponent* HB_LowerLeftArm;
    UBoxComponent* HB_UpperLeftArm;
    UBoxComponent* HB_RightHand;
    UBoxComponent* HB_LowerRightArm;
    UBoxComponent* HB_UpperRightArm;
 
    ///@brief Called from constructor. 
    void GenerateHitboxes();

        
    // ...

};

In a blueprint class based on BasePlayer, we then manually size and position each hitbox onto the Character’s mesh, copying these values into the BasePlayer::GenerateHitboxes function we previously defined:

// BasePlayer.cpp

void ABasePlayer::GenerateHitboxes() {

    USkeletalMeshComponent* PlayerMesh = GetMesh();
	
    // Head
    HB_Head = CreateDefaultSubobject<UBoxComponent>(TEXT("HB_Head"));
    HB_Head->SetupAttachment(PlayerMesh, FName(TEXT("Head")));
    HB_Head->SetRelativeLocation(FVector(1.287602f, 0.721266f, -0.49535f));
    HB_Head->SetRelativeRotation(FRotator(0.f, -87.780823f, 0.f));
    HB_Head->SetWorldScale3D(FVector(0.414147f, 0.469481f, 0.349886f));
    // COLLISION_HITBOX is a custom collision channel
    HB_Head->SetCollisionObjectType(COLLISION_HITBOX);
    HB_Head->SetCollisionProfileName("Hitbox");
    HB_Head->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
    HB_Head->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);

    // Upper Torso
    // ... Repeat process for each hitbox ...

}

Finished hitboxes!
 
Building the Data Structure

Next, we’ll need to build a data structure to hold all of the information necessary for us to properly rewind.

In BasePlayer.h, we define a struct, FSavedPosition, to hold a player’s Location and Rotation at time Time:

// BasePlayer.h
// ...

USTRUCT(BlueprintType)
struct FSavedPosition {

    GENERATED_BODY()

    // Position of player at time Time.
    UPROPERTY()
        FVector Position;

    // Rotation of player at time Time.
    UPROPERTY()
        FRotator Rotation;

    // Server world time when this position was updated
    UPROPERTY()
        float Time;

    // Array of Saved Hitbox data for this position
    UPROPERTY()
        TArray<FSavedHitbox> Hitboxes;

    FSavedPosition() :
        Position(FVector(0.f)),
        Rotation(FRotator(0.f)),
        Time(0.f),
        Hitboxes() {};

    FSavedPosition(
        FVector InPos,
        FRotator InRot,
        float InTime,
        TArray<FSavedHitbox> InHitboxes
    ) :
        Position(InPos),
        Rotation(InRot),
        Time(InTime),
        Hitboxes(InHitboxes) {}; 

};

We also need to store the Location and Rotation of each of the player’s hitboxes at time Time. It’s important to note that the location and rotation of each box is sufficient to determine collisions down the road – we’ll be reconstructing the bounds of each box using the extents of each hitbox. With that being said, let’s add the following struct, FSavedHitbox, to BasePlayer.h:

// BasePlayer.h
// ...

USTRUCT(BlueprintType)
struct FSavedHitbox {

    GENERATED_BODY()

    // Position of hitbox at time Time.
    UPROPERTY()
    FVector Position;

    // Rotation of hitbox at time Time.
    UPROPERTY()
    FRotator Rotation;

    // Hitbox type. This will be used to retrieve Extent information.
    UPROPERTY()
    EHitboxType HitboxType;

    FSavedHitbox() : Position(FVector(0.f)), Rotation(FRotator(0.f)), HitboxType(EHitboxType::None) {};

    FSavedHitbox(
        FVector InPos,
        FRotator InRot,
        EHitboxType InHitboxType
    ) :
        Position(InPos),
        Rotation(InRot),
        HitboxType(InHitboxType) {};

};

We also need to store a type on the saved hitbox, which we’ll use to map back to the UBoxComponent hitboxes on the Player. In BasePlayer.h, we add the enum EHitboxType:

// BasePlayer.h
// ...

UENUM(BlueprintType)
enum class EHitboxType : uint8 {

    None            UMETA(DisplayName="None"),
    Head            UMETA(DisplayName="Head"),
    UpperTorso	    UMETA(DisplayName="UpperTorso"),
    LowerTorso	    UMETA(DisplayName="LowerTorso"),
    UpperLeftArm    UMETA(DisplayName="UpperLeftArm"),
    LowerLeftArm    UMETA(DisplayName="LowerLeftArm"),
    LeftHand        UMETA(DisplayName="RightFoot"),
    UpperRightArm   UMETA(DisplayName="UpperRightArm"),
    LowerRightArm   UMETA(DisplayName="LowerRightArm"),
    RightHand       UMETA(DisplayName="RightFoot"),
    UpperLeftLeg    UMETA(DisplayName="UpperLeftLeg"),
    LowerLeftLeg    UMETA(DisplayName="LowerLeftLeg"),
    LeftFoot        UMETA(DisplayName="LeftFoot"),
    UpperRightLeg   UMETA(DisplayName="UpperRightLeg"),
    LowerRightLeg   UMETA(DisplayName="LowerRightLeg"),
    RightFoot       UMETA(DisplayName="RightFoot")

};

 
Populating the Position History

Now with our data structures defined, we are able to write a function that can take the current state of the hitboxes and store them in a running history. We’ll add two new functions, PositionUpdated() and BuildSavedHitboxArr(), to our ABasePlayer class. In BasePlayer.h:

// BasePlayer.h
// ...

UCLASS()
class MERCILESS_API ABasePlayer : public ACharacter {

public:

    ///@brief Called from within PlayerCharacterMovement.
    /// Adds an instance of an FSavedPosition to the SavedPositions array.
    virtual void PositionUpdated();

private: 

    TArray<FSavedHitbox> BuildSavedHitboxArr();

    TArray<FSavedPosition> SavedPositions;

    ///@brief Maximum time to hold onto SavedPositions.
    ///       300ms of Lag Compensation.
    const float MaxSavedPositionAge = 0.3f;


};

And we’ll implement these new functions within BasePlayer.cpp:

// BasePlayer.cpp
// ...

void ABasePlayer::PositionUpdated() {

    const FVector LocationToSave = GetActorLocation();
    const FRotator RotationToSave = GetViewRotation();
    const float WorldTime = GetWorld()->GetTimeSeconds();
    const TArray<FSavedHitbox> HitboxesToSave = BuildSavedHitboxArr();

    const FSavedPosition PositionToSave = FSavedPosition(
        LocationToSave,
        RotationToSave,
        WorldTime,
        HitboxesToSave
    );

    SavedPositions.Add(PositionToSave);

    // Clean up SavedPositions that have exceeded out Age limit.
    // However, we should keep a handle to at least one FSavedPosition that exceeds the max age
    // for interpolation.
    if (SavedPositions.Num() >= 2 && SavedPositions[1].Time < WorldTime - MaxSavedPositionAge) {
        SavedPositions.RemoveAt(0);
    }

}

TArray<FSavedHitbox> ABasePlayer::BuildSavedHitboxArr() {

    TArray<FSavedHitbox> SavedHitboxArr;

    if (HB_Head) {
        FSavedHitbox SH_Head;
        SH_Head.HitboxType = EHitboxType::Head;
        SH_Head.Position = HB_Head->GetComponentLocation();
        SH_Head.Rotation = HB_Head->GetComponentRotation();
        SavedHitboxArr.Add(SH_Head);
    }

    if (HB_UpperTorso) {
        FSavedHitbox SH_UpperTorso;
        SH_UpperTorso.HitboxType = EHitboxType::UpperTorso;
        SH_UpperTorso.Position = HB_UpperTorso->GetComponentLocation();
        SH_UpperTorso.Rotation = HB_UpperTorso->GetComponentRotation();
        SavedHitboxArr.Add(SH_UpperTorso);
    }

    // ...
    // Repeat for all player hitboxes
    // ...

    return SavedHitboxArr;

}

Next, we need to find a good place to call our new PositionUpdated function. ABasePlayer::Tick() is one option – however, we’d be storing lots of redundant history if the players are standing still for some time.

A better option is to call PositionUpdated during our Character’s UCharacterMovementComponent::PerformMovement() call. According to the UE4 Documentation, this function will be called after a move replicates and is played back. In order to hook into this event, we’ll need to create a class derived from UCharacterMovementComponent, UPlayerCharacterMovement.

In PlayerCharacterMovement.h:

// PlayerCharacterMovement.h

#pragma once

#include "GameFramework/CharacterMovementComponent.h"
#include "PlayerCharacterMovement.generated.h"

UCLASS()
class MERCILESS_API UPlayerCharacterMovement : public UCharacterMovementComponent {

    GENERATED_BODY()

public:

    virtual void PerformMovement(float DeltaSeconds) override;

};

And in PlayerCharacterMovement.cpp:

// PlayerCharacterMovement.cpp
// ...

#include "BasePlayer.h"
#include "PlayerCharacterMovement.h"

UPlayerCharacterMovement::UPlayerCharacterMovement(const FObjectInitializer& ObjectInitializer)
    :Super(ObjectInitializer) {

    // ...

}

void UPlayerCharacterMovement::PerformMovement(float DeltaSeconds) {
    
    if (!CharacterOwner) {
        return;
    }

    Super::PerformMovement(DeltaSeconds);

    ABasePlayer* BasePlayer = Cast<ABasePlayer>(CharacterOwner);
    if (BasePlayer) {
        BasePlayer->PositionUpdated();
    }

}

We then need to tie this custom component into our Character class. Add the following to BasePlayer.h:

// BasePlayer.h
// ...

#include "PlayerCharacterMovement.h"

UCLASS()
class MERCILESS_API ABasePlayer : public ACharacter {

    // ...

public:


    UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
        class UPlayerCharacterMovement* PlayerCharacterMovement;

    // ...

};

And modify the constructor in BasePlayer.cpp:

// BasePlayer.cpp
// ...

ABasePlayer::ABasePlayer(const FObjectInitializer& ObjectInitializer)
    :Super(ObjectInitializer.SetDefaultSubobjectClass<UPlayerCharacterMovement>(ACharacter::CharacterMovementComponentName)) {

    // ...

    PlayerCharacterMovement = Cast<UPlayerCharacterMovement>(GetCharacterMovement());

    // ...

}
 
Visualizing the Player’s Position History

While we technically have the history properly populating, it would be nice to be able to see the history being rendered in real-time. Next, let’s create some debug functions to allow us to visualize this data.

Let’s add a function, GetHitboxExtent, to return the extent data for a given hitbox. In BasePlayer.h:

// BasePlayer.h
// ...

UCLASS()
class MERCILESS_API ABasePlayer : public ACharacter {

    // ...

public:

    UBoxComponent* GetHitbox(EHitboxType HitboxType);

    FVector GetHitboxExtent(EHitboxType HitboxType);
    

};

…and its implementation in BasePlayer.cpp:

// BasePlayer.cpp
// ...

UBoxComponent* ABasePlayer::GetHitbox(EHitboxType HitboxType) {

    UBoxComponent* Hitbox = nullptr;

    switch (HitboxType) {

    case EHitboxType::Head:
        Hitbox = HB_Head;
        break;

    case EHitboxType::UpperTorso:
        Hitbox = HB_UpperTorso;
        break;

    // ...

    default:
        break;
    }

    return Hitbox;

}


FVector ABasePlayer::GetHitboxExtent(EHitboxType HitboxType) {

    UBoxComponent* Hitbox = GetHitbox(HitboxType);

    if (Hitbox) {
        return Hitbox->GetScaledBoxExtent();
    }

    return FVector(0.f);

}

We’ll be able to feed the extent vector returned by ABasePlayer::GetHitboxExtent into UE4’s DrawDebugBox. Let’s add a debug-only function, DrawSavedPositions, to our Character class to draw an array of SavedPositions to the screen. In BasePlayer.h:

// BasePlayer.h
// ...


UCLASS()
class MERCILESS_API ABasePlayer : public ACharacter {

    // ...

public:

    virtual void Tick(float DeltaTime) override;
    
    void DrawSavedPositions(const TArray<FSavedPosition> SavedPositions);

    // ...

};

In BasePlayer.cpp:

// BasePlayer.cpp
// ...
void ABasePlayer::DrawSavedPositions(const TArray<FSavedPosition> SavedPositions) {

    const FColor BoxColor = FColor::Green;
    const float BoxLifetime = 0.1f;
    const uint8 DepthPriority = 0;
    const float BoxThickness = 0.75f;
    const bool PersistentLines = true;

    for (FSavedPosition SavedPosition : SavedPositions) {
        for (FSavedHitbox SavedHitbox : SavedPosition.Hitboxes) {
            DrawDebugBox(
                GetWorld(),
                SavedHitbox.Position,
                GetHitboxExtent(SavedHitbox.HitboxType),
                SavedHitbox.Rotation.Quaternion(),
                BoxColor,
                PersistentLines,
                BoxLifetime,
                DepthPriority,
                PersistentLines
            );
        }
    }

}

To keep things simple, we’re not going to bother loading the server’s copy of our player’s SavedPositions. As we’re not short-circuiting PositionUpdated client-side, we should be able to view our player’s position history – so we can go ahead and call DrawSavedPositions within our ABasePlayer::Tick() function:

// BasePlayer.cpp
// ...

void ABasePlayer::Tick(float DeltaTime) {

    // ...

    if (!HasAuthority()) {
        DrawSavedPositions(SavedPositions);
    }

    // ...

}

Finally, we compile everything we’ve done so far, and load up our game. Circle-strafing around should now show our player’s position history in action!

In Part 2 of this series, we’ll be implementing Latency Tracking, as well as a function that will tell us how far back we’ll need to rewind for a given player.

One thought on “Lag Compensation in Unreal Engine 4 (C++) – Part 1: Hitbox Tracking

Leave a Reply

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