Server-Side Hit Detection That’s Too Good To Be True

Server-Side Hit Detection That’s Too Good To Be True

I believe what I’m about to describe in this article is classified as Snapshot Interpolation. This is not an in-depth tutorial, and I am oversimplifying some things I really struggled for multiple days with.

I’ve been improving the Server Rewind system taught in this course by Stephen Ulibarri. It was a great starting point for what I needed in the types of games I want to make. However, there were a few things required for it to properly work that I wanted to improve or just flat out avoid.

1. The server must tick animation for all players apart of the system. (Even players that are nowhere near danger)
2. A network synced clock (Which is rarely accurate and requires periodic RPC’s to keep somewhat in sync).
3. Semi authoritative client sided information (I shot X, My Muzzle Location was Y and my Orientation was Z)
4. Required you to use the default unreal networking model (Which my games are not)

All of these things have their up and down sides. This is a great video on the Unreal YouTube channel from one of the Devs that worked on Valorant, he goes into a little bit more detail for the system I ended up going with. I was able to do something similar to what they did without changing the base engine.

TWO BIRDS ONE STONE

Currently I’ve been diving into Mover & Network Prediction and have completely adopted it into my projects moving forward. It comes with some caveats which are annoying but once you get past them it's really a dream to work with. Especially when it comes to hit verification. Since time and movement updates are synced and running in a fixed step it affords you some guarantees. For instance, my client no longer sends the server any information about the shot itself. They instead just inform the server what type of trace was made, who they shot and the time they hit them. If I look back into time on the server, I can reconstruct the exact state (give or take a few frames offset) that the client and the target were in.

So just by switching my netcode over to Network Prediction I already solve two of my problems. First with the client potentially lying about the shot that occurred. Second with the network synced clock. Now don’t get it mistaken. The client is still sending the server their control rotation every frame but that is stored in the synchronized state that the server can refer back to when it receives the owners hit time.

ANIMATION TICKING ISSUE

My next question was how can I still get accurate hits without ticking the skeletal mesh on the server?

What I needed was a way to snapshot the current pose of the target that was hit and the instigator of the shot on a single frame. I tried looking into the animation instances’ FPoseSnapShot structure and it was promising. However, it still required the skeletal mesh and anim instances to tick every frame.

I was pissed, but I didn’t stop. I started to think out of the box. This really cool mechanic from Black Myth Wukong got me thinking what if I could use this to snapshot the animation state. I tried my best to replicate the mechanic myself. It was rough but IT WORKED! However, there was no way my solution was good enough. Luckily Unreal Engine has some really slick things built in. There is a component called UPoseableMeshComponent that can copy a pose from an input skeletal mesh component. This was it. I can now disable all skeletal mesh ticking for every pawn on the server and save those sweet CPU cycles. Leaving room for more important work.

THE SYSTEM IN A NUTSHELL

When pawns with the Server Rewind Component begin play, the authoritative pawn reaches out to the Server Rewind Manager Component on my game mode and from there a proxy is spawned for them. This proxy will fetch a predefined data container that has the relative transform and bone name that each hit box is attached to. That data is used to create instanced box components at runtime. There is also a second component that is used for the owner's active mesh which is dependent on the camera perspective the player is in.

SPAWN SERVER-SIDE PROXY FUNCTION

void UServerRewindManager::SpawnServerRewindProxyActor(AActor* Target)
{
if (Target->Implements<UServerRewindInterface>())
{
const auto HitBoxesNeeded = IServerRewindInterface::Execute_GetAllHitBoxInfo(Target);

FActorSpawnParameters SpawnParams;
SpawnParams.Owner = Target;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;

if (ProxyDataPool.Contains(Target))
{
auto ActorToDestroy = ProxyDataPool[Target];
ProxyDataPool.Remove(Target);
ActorToDestroy->Destroy();
}

if (auto SpawnedProxy = GetWorld()->SpawnActor<AServerRewindProxyActor>(AServerRewindProxyActor::StaticClass(), SpawnParams))
{
auto MeshComponent = IServerRewindInterface::Execute_GetTargetMeshComponent(Target);
if (!MeshComponent || !MeshComponent->GetSkinnedAsset()) return;
FTransform RelativeTransform = MeshComponent->GetRelativeTransform();

SpawnedProxy->Initialize(Target);

for (int i = 0; i < HitBoxesNeeded.Num(); ++i)
{
const auto& BoxInfo = HitBoxesNeeded[i];
auto NewHitBox = NewObject<UBoxComponent>(this);
NewHitBox->RegisterComponent();
NewHitBox->ComponentTags.Add(BoxInfo.HitBoxName);
SpawnedProxy->AddInstanceComponent(NewHitBox);
NewHitBox->SetCollisionEnabled(ECollisionEnabled::NoCollision);
NewHitBox->SetCollisionProfileName(FName("LagCompCollider"));
NewHitBox->SetHiddenInGame(false);
NewHitBox->AttachToComponent(SpawnedProxy->GetTargetPose(), FAttachmentTransformRules::SnapToTargetIncludingScale, BoxInfo.HitBoxName);
NewHitBox->SetRelativeTransform(BoxInfo.Transform);
}

ProxyDataPool.Add(Target, SpawnedProxy);
}


}
}

INITIALIZE FUNCTION

void AServerRewindProxyActor::Initialize(AActor* Target)
{

if (TargetPose)
{
if (Target->Implements<UServerRewindInterface>())
{
auto MeshComponent = IServerRewindInterface::Execute_GetTargetMeshComponent(Target);
if (!MeshComponent || !MeshComponent->GetSkinnedAsset()) return;
FTransform RelativeTransform = MeshComponent->GetRelativeTransform();

TargetPose->Mobility = EComponentMobility::Movable;
TargetPose->SetSkinnedAssetAndUpdate(MeshComponent->GetSkeletalMeshAsset());
TargetPose->SetRelativeTransform(RelativeTransform);
}
}

if (CameraComponent)
{
if (const AActionScriptPawn* TargetPawn = Cast<AActionScriptPawn>(Target))
{
auto CurrentCamera = TargetPawn->GetCameraComponent<UCameraComponent>();
if (!CurrentCamera) return;

CameraComponent->Mobility = EComponentMobility::Movable;
CameraComponent->SetRelativeTransform(CurrentCamera->GetRelativeTransform());
}
}

if (OwnerPose)
{
if (Target->Implements<UServerRewindInterface>())
{
auto MeshComponent = IServerRewindInterface::Execute_GetOwnerMeshComponent(Target);
if (!MeshComponent || !MeshComponent->GetSkinnedAsset()) return;

OwnerPose->Mobility = EComponentMobility::Movable;
OwnerPose->SetSkinnedAssetAndUpdate(MeshComponent->GetSkeletalMeshAsset());
OwnerPose->SetRelativeTransform(MeshComponent->GetRelativeTransform());
}
}
}

SNAPSHOT FUNCTION

void AServerRewindProxyActor::SnapShot(AActor* Target)
{
if (TargetPose)
{
if (Target->Implements<UServerRewindInterface>())
{
auto MeshComponent = IServerRewindInterface::Execute_GetTargetMeshComponent(Target);
if (!MeshComponent || !MeshComponent->GetSkinnedAsset()) return;
TargetPose->CopyPoseFromSkeletalComponent(MeshComponent);
}

TargetPose->TickComponent(UNetworkPredictionWorldManager::ActiveInstance->GetDeltaTime(), LEVELTICK_All, nullptr);
}

if (CameraComponent)
{
if (const AActionScriptPawn* TargetPawn = Cast<AActionScriptPawn>(Target))
{
auto CurrentCamera = TargetPawn->GetCameraComponent<UCameraComponent>();
if (!CurrentCamera) return;

CameraComponent->Mobility = EComponentMobility::Movable;
CameraComponent->SetRelativeTransform(CurrentCamera->GetRelativeTransform());
}
}

if (OwnerPose)
{
if (Target->Implements<UServerRewindInterface>())
{
auto MeshComponent = IServerRewindInterface::Execute_GetOwnerMeshComponent(Target);
if (!MeshComponent || !MeshComponent->GetSkinnedAsset()) return;
OwnerPose->CopyPoseFromSkeletalComponent(MeshComponent);
}

OwnerPose->TickComponent(UNetworkPredictionWorldManager::ActiveInstance->GetDeltaTime(), LEVELTICK_All, nullptr);
}
}

Every time the server processes a hit it initializes, snapshots the correct pose and moves the Proxy Actor into position for both the shooter and the target. The stage is set, and the server runs its trace from the owner’s proxy perspective against the target proxy. Since most of the data is deterministic (Unless a client cheats) As long as the server does the same trace the client did the hit will come out the same.

<iframe width="680" height="382" src="https://www.youtube.com/embed/Z2I3ZOz_vnQ" title="Server Rewind Result" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

IMPROVEMENTS

Currently I am spawning a proxy actor for each pawn that subscribes to the server rewind system. This can be costly in memory and could bloat the frame time at server start up time. I want to spawn a single target actor and a single shooter actor. I’m currently not sure if this will have ramifications. For example, 2 actors being damaged by 2 shooters. If I only have the single pair of proxy actors which damage takes priority.

There was also one thing I had to add in order to get things in the correct space. In the code above you can see I have to update a camera component’s location in the proxy actor. This is because my game is first person and in order to get things in the same space on the shooter the proxy needs to have the same hierarchy. This forces me to have to synchronize the camera location every frame with the server which opens up the window for exploits. Those can be mitigated with a distance check between the actor and the camera, but I would like them to be nonexistent. I could make a small performance improvement by converting from a UCameraComponent to a much simpler USceneComponent.

I also want to load test the system. As well as do more direct tests to find bugs or mistakes I’ve made.

SOCIALS

Feel free to join my Discord if you have any questions about the finer details.

I also have a Patreon if you want to support me monetarily.

A Subscribe is free and is greatly appreciated.

SHOUTOUTS

I made sure to link all of the source material I used to get to the point I’m at. But there’s one guy who has been helping me. He’s gotten his PR accepted by Epic which improved some things for both Mover & Network Prediction. Anytime I had a question about Network Prediction, he didn’t hesitate to answer it. So you guys make sure to thank Kai as well and Subscribe to his YouTube Channel.

Back to blog