Ghost Actor Reappearance: De Multiplayer Network Replication Desync Fix voor Destructible Grid Structures
Kort samengevat
Deze gids behandelt het oplossen van 'ghost builds' in multiplayer-games, een veelvoorkomende desynchronisatie waarbij vernietigde objecten tijdelijk herrijzen door out-of-order replication. Door een client-side prediction buffer in C++ te implementeren, kunnen developers binnenkomende server-property-updates tijdelijk onderdrukken totdat het channel closure-packet arriveert. De gids biedt tevens praktische best practices om de netcode voor destructible structures te optimaliseren en de belasting van de server-validatie te minimaliseren.
Ghost Actor Reappearance: De Multiplayer Network Replication Desync Fix voor Destructible Grid Structures
Je speler zwaait met een harvesting tool, een houten muur versplintert direct op hun scherm, maar 80 milliseconden later knippert deze terug in het bestaan met volledige health voordat hij 400 milliseconden later permanent verdwijnt. Deze visuele anomalie, in de volksmond bekend als een "ghost build", is een klassieke uiting van een client-side prediction mismatch en out-of-order replication. In snelle multiplayer-omgevingen verbreken deze korte state rollbacks de immersie van de speler en zorgen ze voor visuele ruis. Om dit op te lossen moeten developers een robuuste multiplayer network replication desync fix implementeren die onmiddellijke lokale feedback verzoent met authoritatieve servervalidatie.
Anatomie van een Ghost Build: Prediction vs. Replication
Bij het bouwen van netwerk-gameplay moeten creators een balans vinden tussen responsiveness en server authority. Om beweging en destructie direct te laten aanvoelen, voorspellen clients de uitkomsten van acties voordat ze een serverbevestiging ontvangen. Bijvoorbeeld, wanneer een speler een destructible structure aanvalt, voert de client-side game logic onmiddellijk de damage-berekening uit, schakelt de collision uit en triggert particle-effecten.
Onder de motorkap creëert dit een fractie van een seconde een divergentie waarbij de client-simulatie voorloopt op de server. In een standaard netcode-model wordt deze divergentie opgelost zodra de server de input-RPC van de client verwerkt en de nieuwe state terug repliceert. Echter, als een packet vertraging oploopt, of als de server tick rate (meestal 20Hz tot 30Hz) achterloopt op de client frame rate (60Hz tot 120Hz), treedt er een race condition op. De client-side prediction verwijdert de actor, maar de volgende replication-update van de server bevat nog steeds de oudere state van de actor (in leven met health).
Deze specifieke race condition is zeer zichtbaar bij houten structuren. Vergeleken met steen of metaal heeft hout een lagere health-drempel (bijv. 90 HP vs. 300 HP), wat betekent dat het in één klap wordt vernietigd. Dit maakt het tijdsvenster tussen de actie van de speler en de server-acknowledgment extreem smal. Elke replication-vertraging dwingt de network driver van de client om de state te reconciliëren, waardoor de actor opnieuw wordt opgebouwd omdat de server deze nog steeds als levend rapporteert.
De impact van Packet Loss en Tick Rates
Wanneer packet loss optreedt, blijft de voorspelde destructie van de client in een staat van limbo achter. Als een client een damage-packet verzendt dat wordt gedropt, verwerkt de server dit nooit, maar de client gaat ervan uit dat de damage is toegepast. De client vervolgt de simulatie onder de foutieve aanname dat de actor weg is. Wanneer de server de volgende state-update verzendt, wordt de mismatch zichtbaar, wat de client dwingt om de actor weer in de wereld te spawnen. Dit reconciliation-proces zorgt voor een schokkerige visuele pop, vooral bij 1,5% tot 3% packet loss waar deze drops frequent voorkomen.
Onder de motorkap van Actor Lifecycle en Channel Teardown
Unreal Engine en vergelijkbare moderne multiplayer-engines synchroniseren de aanwezigheid van actors via speciale network connection channels. Aan elke gerepliceerde actor wordt een actor channel toegewezen. Wanneer een actor op de server wordt vernietigd, sluit de server dit channel en stuurt een channel close control message (NetGUID retirement) naar de client.
Het kritieke probleem is dat property replication en channel-sluiting niet hetzelfde replication-pad delen. Property-updates (zoals updates van de Health-variabele van een structuur) worden geserialiseerd en verzonden als onderdeel van de reguliere replication-bundel van de actor. Als de server een damage-event verwerkt maar de actor nog niet heeft ge-garbage-collected, kan deze een laatste property-update serialiseren voordat de actor volledig is gemarkeerd voor destructie. Als het UDP-packet met de property-update arriveert vóór het packet met de channel-sluiting, updatet de client de health van de actor en overschrijft deze de lokale voorspelde destructie.
Dit gedrag is nauw verwant aan andere netcode-synchronisatieproblemen, zoals besproken in onze gids over multiplayer desyncs fixing the Unreal Engine RPC replication issue breaking your states. In die gids analyseren we hoe mismatches in de uitvoeringsvolgorde tussen RPC's en properties world states breken. Evenzo komen developers bij het afhandelen van de spelerpositionering vaak vergelijkbare discrepanties tegen, zoals beschreven in onze gids over how to fix player location desync in Uefn and Unreal Engine multiplayer.
Wanneer de client het out-of-order replication-packet verwerkt, ziet deze dat de actor levend is op de server en dwingt deze de actor terug in de active pool. De client is genoodzaakt te wachten tot het channel closure-packet eindelijk arriveert—vaak 0,4 seconden later—om de actor permanent te verwijderen.
Onder de motorkap zijn replication-packets beperkt door de Maximum Transmission Unit (MTU), die doorgaans 1400 bytes is. Als de verbindingssnelheid van je game wordt geknepen (bijvoorbeeld met MaxClientRate ingesteld op 15000 bytes/sec), worden updates in de wachtrij geplaatst en over meerdere UDP-packets verdeeld. Omdat het control-bericht voor channel closure betrouwbaar (reliable) wordt verzonden, moet dit worden bevestigd, terwijl property-updates vaak onbetrouwbaar (unreliable) worden verzonden. Als er netwerkcongestie of packet loss optreedt, kan het betrouwbare channel closure-bericht vertraging oplopen ten opzichte van oudere, onbetrouwbare property-packets, wat een mismatch veroorzaakt waarbij de client de actor reconstrueert.
Een Predictive State Buffer implementeren in C++
Om ghost builds op te lossen, moeten we binnenkomende replication-updates op de client onderscheppen voor actors waarvan is voorspeld dat ze zijn vernietigd. Door een client-side prediction buffer te implementeren, kunnen we property-reconciliation gedurende een specifiek tijdsbestek (bijv. 500ms) onderdrukken, waardoor het channel-closure packet van de server voldoende tijd heeft om aan te komen. Hieronder staat een volledige, werkende C++-implementatie van een predictive destructible actor. Deze overschrijft het replication-gedrag en gebruikt een lokale timestamp om te bepalen of replication moet worden onderdrukt.
// PredictedDestructibleActor.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PredictedDestructibleActor.generated.h"
UCLASS()
class MULTIPLAYERGAME_API APredictedDestructibleActor : public AActor
{
GENERATED_BODY()
public:
APredictedDestructibleActor();
protected:
virtual void BeginPlay() override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// Server-authoritative health variable
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Destruction")
float MaxHealth;
// Triggered when health changes on the client
UFUNCTION()
void OnRep_Health();
// Client-side prediction tracking flags
bool bClientPredictedDestroyed;
float ClientPredictionTime;
// Maximum time (in seconds) the client will suppress server updates
UPROPERTY(EditAnywhere, Category = "Networking")
float PredictionTimeout;
// Visual effect helper function
void TriggerDestructionEffects();
public:
// Called when the local player destroys the structure client-side
UFUNCTION(BlueprintCallable, Category = "Destruction")
void PredictDestruction();
// Resets the predicted state if the server rejects the destruction
void ResetPredictionState();
virtual void Tick(float DeltaTime) override;
};
Hier is het bijbehorende implementatiebestand, dat laat zien hoe binnenkomende server-states gefilterd moeten worden:
// PredictedDestructibleActor.cpp
#include "PredictedDestructibleActor.h"
#include "Net/UnrealNetwork.h"
#include "Kismet/GameplayStatics.h"
APredictedDestructibleActor::APredictedDestructibleActor()
{
PrimaryActorTick.bCanEverTick = true;
bReplicates = true;
// Set a moderate update frequency to balance bandwidth and responsiveness
NetUpdateFrequency = 33.0f;
MaxHealth = 100.0f;
Health = MaxHealth;
bClientPredictedDestroyed = false;
ClientPredictionTime = 0.0f;
PredictionTimeout = 0.5f; // 500ms safety window
}
void APredictedDestructibleActor::BeginPlay()
{
Super::BeginPlay();
}
void APredictedDestructibleActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(APredictedDestructibleActor, Health);
}
void APredictedDestructibleActor::OnRep_Health()
{
// If the client has predicted this actor's death, suppress server property updates
if (bClientPredictedDestroyed)
{
return;
}
if (Health <= 0.0f)
{
TriggerDestructionEffects();
}
}
void APredictedDestructibleActor::PredictDestruction()
{
// Prediction only runs on the client that simulated the event
if (GetNetMode() == NM_Client)
{
bClientPredictedDestroyed = true;
ClientPredictionTime = GetWorld()->GetTimeSeconds();
// Hide the actor and disable collision immediately for responsive local feedback
SetActorEnableCollision(false);
SetActorHiddenInGame(true);
// Spawn local particles and audio instantly
TriggerDestructionEffects();
}
}
void APredictedDestructibleActor::ResetPredictionState()
{
bClientPredictedDestroyed = false;
SetActorEnableCollision(true);
SetActorHiddenInGame(false);
}
void APredictedDestructibleActor::TriggerDestructionEffects()
{
// Spawn local visual effects (e.g. wood splinters, dust clouds)
// and play destruction audio.
}
void APredictedDestructibleActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (GetNetMode() == NM_Client && bClientPredictedDestroyed)
{
float CurrentTime = GetWorld()->GetTimeSeconds();
// If the timeout expires and the server hasn't torn down the channel,
// the server must have rejected the damage. We must roll back.
if (CurrentTime - ClientPredictionTime > PredictionTimeout)
{
ResetPredictionState();
}
}
}
Door deze predictive buffer te gebruiken, voorkomen we dat de binnenkomende OnRep_Health-callback de visuele zichtbaarheid van de actor reset. Dit houdt de client-side actor verborgen en collision-vrij totdat het channel close-packet arriveert. Als de server het niet eens is met de destructie (bijvoorbeeld door een anti-cheat validatie-mismatch), dwingt de timeout een rollback af, zodat de simulatie niet permanent gedesynchroniseerd raakt.
Rollbacks en Validation Rejection afhandelen
Een kritiek onderdeel van deze replication fix is het afhandelen van situaties waarin de server de actie van de speler afwijst. Als de validatielogica van de server bepaalt dat de speler de structuur niet had kunnen raken, wijst deze de damage af. In dit scenario moet de client de voorspelde destructie terugdraaien (roll back) om permanente desynchronisatie te voorkomen. Dit is de reden waarom de timeout de collision en zichtbaarheid van de actor herstelt als er binnen 500ms geen serverbevestiging binnenkomt.
De last van handmatige implementatie vs. Dedicated Backends
Hoewel de C++-oplossing hierboven individuele actor ghosting oplost, is het toepassen hiervan op de hele spelomgeving complex. Game-developers moeten handmatig prediction- en rollback-code schrijven voor elk destructible objecttype, actieve prediction buffers bijhouden, actor tick rates optimaliseren en network replication-prioriteiten beheren. Voor een indie-team kan het bouwen en testen van deze edge cases gemakkelijk 4 tot 6 weken aan dedicated network engineering kosten.
Dit zelf bouwen vereist het opzetten van load balancers, database sharding en complexe WebSockets/UDP-servers. Met horizOn zijn deze backend-services vooraf geconfigureerd, zodat je je game kunt releasen in plaats van netwerkinfrastructuur te moeten beheren. De real-time lobby-management en session orchestration van horizOn zorgen ervoor dat player states en match properties betrouwbaar synchroniseren met een latency van minder dan 50ms, wat de replication-vertragingen die ghost builds veroorzaken minimaliseert.
Praktische Best Practices voor een Multiplayer Network Replication Desync Fix
Volg deze richtlijnen bij het optimaliseren van je netcode voor destructible objects om je world state gesynchroniseerd te houden:
- Ontkoppel visuele assets van de Actor Lifecycle: Vertrouw niet op de onmiddellijke uitvoering van
AActor::Destroy()voor visuele feedback. Stel een booleaanse replication-flag in zoalsbIsDeaden trigger direct lokale particle-systemen. Hierdoor kun je collision op the client uitschakelen zonder te wachten op de cleanup-routines van de server. - Prioriteer Channel Destruction boven Property-updates: Stel
bOnlyRelevantToOwnerin of verhoogNetPriorityop destructibles om ervoor te zorgen dat destructie-updates prioriteit krijgen van de network driver. Dit zorgt ervoor dat ze geen vertraging oplopen door standaard ambient property replication. - Stel een actieve Prediction Timeout Window in: Laat een client-side prediction nooit voor onbepaalde tijd lopen. Implementeer altijd een safety timeout (meestal 1,5x tot 2x je maximaal acceptabele RTT, plus een marge voor server tick-variaties) om een client rollback te forceren als de server een actie afwijst. Dit voorkomt dat de actor permanent verborgen blijft als een packet wordt gedropt.
- Tune NetUpdateFrequency: Houd de update rates van je destructible structures laag (bijv. 10-15Hz) onder normale omstandigheden. Verhoog de update-frequentie dynamisch naar 33Hz wanneer ze damage oplopen, waardoor ongebruikte bandbreedte wordt verminderd terwijl de responsiveness behouden blijft. Dit balanceert het netwerkgebruik tijdens intensieve interacties van spelers.
- Optimaliseer Server Validation Pipelines: Zorg ervoor dat damage-validatie aan de serverzijde snel en lichtgewicht is. Als de server er langer dan 100ms over doet om een hit te valideren, zal de client prediction buffer waarschijnlijk een timeout krijgen, wat een zichtbare jitter veroorzaakt. Stroomlijn de verificatiecode om verwerkingsvertragingen te minimaliseren.
Samenvatting en volgende stappen
Het oplossen van replication desyncs vereist een diepgaand begrip van de netwerk-pipeline van je engine. Door server-property-updates te onderdrukken op actors waarvan op de client is voorspeld dat ze zijn vernield, kun je ghost builds elimineren en spelers een naadloze, responsieve ervaring bieden.
Klaar om je multiplayer backend op te schalen en synchronisatieproblemen te verminderen? Probeer horizOn gratis of bekijk de API docs om te leren hoe je low-latency sessiebeheer implementeert in je volgende project.
Bron: Ghost builds appear shortly after breaking wooden player build structures