Ghost Actor Reappearance: The Multiplayer Network Replication Desync Fix for Destructible Grid Structures
In a nutshell
Implement a multiplayer network replication desync fix for ghost builds and client-side prediction mismatch with this hands-on code walkthrough.
Ghost Actor Reappearance: The Multiplayer Network Replication Desync Fix for Destructible Grid Structures
Your player swings a harvesting tool, a wooden wall shatters instantly on their screen, but 80 milliseconds later it blinks back into existence at full health before vanishing permanently 400 milliseconds later. This visual anomaly, commonly known as a "ghost build," is a classic manifestation of a client-side prediction mismatch and out-of-order replication. In fast-paced multiplayer environments, these brief state rollbacks break player immersion and introduce visual clutter. To address this, developers must implement a robust multiplayer network replication desync fix that reconciles immediate local feedback with authoritative server validation.
Anatomy of a Ghost Build: Prediction vs. Replication
When building network gameplay, creators must balance responsiveness with server authority. To make movement and destruction feel instantaneous, clients predict the outcomes of actions before receiving server confirmation. For instance, when a player attacks a destructible structure, the client-side game logic immediately runs the damage calculation, disables collision, and triggers particle effects.
Under the hood, this creates a split-second divergence where the client simulation is ahead of the server. In a standard netcode model, this divergence is resolved once the server processes the client's input RPC and replicates the new state back. However, if a packet is delayed, or if the server tick rate (typically 20Hz to 30Hz) lags behind the client frame rate (60Hz to 120Hz), a race condition occurs. The client-side prediction removes the actor, but the server's next replication update still contains the actor's older state (alive with health).
This specific race condition is highly visible on wooden structures. Compared to stone or metal, wood has a lower health threshold (e.g., 90 HP vs. 300 HP), meaning it is destroyed in a single hit. This makes the time window between player action and server acknowledgment extremely narrow. Any replication delay forces the client's network driver to reconcile the state, reconstructing the actor because the server still reports it as alive.
The Impact of Packet Loss and Tick Rates
When packet loss occurs, the client's predicted destruction is left in a state of limbo. If a client transmits a damage packet that is dropped, the server never processes it, but the client assumes the damage was applied. The client then continues simulation under the false assumption that the actor is gone. When the server transmits the next state update, the mismatch becomes apparent, forcing the client to spawn the actor back into the world. This reconciliation process creates a jarring visual pop, especially under 1.5% to 3% packet loss where these drops occur frequently.
Under the Hood of Actor Lifecycle and Channel Teardown
Unreal Engine and similar modern multiplayer engines synchronize actor presence using dedicated network connection channels. Each replicated actor is assigned an actor channel. When an actor is destroyed on the server, the server closes this channel, sending a channel close control message (NetGUID retirement) to the client.
The critical issue is that property replication and channel closure do not share the same replication path. Property updates (such as updates to a structure's Health variable) are serialized and sent as part of the actor's regular replication bundle. If the server processes a damage event but has not yet garbage collected the actor, it might serialize a final property update before the actor is fully marked for destruction. If the UDP packet containing the property update arrives before the packet containing the channel closure, the client updates the actor's health and overrides the local predicted destruction.
This behavior is closely related to other netcode synchronization issues, such as those discussed in our guide on multiplayer desyncs fixing the Unreal Engine RPC replication issue breaking your states. In that guide, we analyze how execution order mismatches between RPCs and properties break world states. Similarly, when handling player positioning, developers often encounter similar discrepancies, as detailed in our guide on how to fix player location desync in Uefn and Unreal Engine multiplayer.
When the client processes the out-of-order replication packet, it sees that the actor is alive on the server and forces the actor back into the active pool. The client is forced to wait until the channel closure packet finally arrives—often 0.4 seconds later—to permanently delete the actor.
Under the hood, replication packets are limited by the Maximum Transmission Unit (MTU), which is typically 1400 bytes. If your game connection rate is throttled (for example, with MaxClientRate set to 15000 bytes/sec), updates are queued and split across multiple UDP packets. Since the control message for channel closure is sent reliably, it must be acknowledged, whereas property updates are often sent unreliably. If network congestion or packet loss occurs, the reliable channel closure message can be delayed behind older, unreliable property packets, causing a mismatch where the client reconstructs the actor.
Implementing a Predictive State Buffer in C++
To fix ghost builds, we must intercept incoming replication updates on the client for actors that have been predicted destroyed. By implementing a client-side prediction buffer, we can suppress property reconciliation for a specific window of time (e.g., 500ms), giving the server's channel-closure packet enough time to arrive. Below is a complete, working C++ implementation of a predictive destructible actor. It overrides the replication behavior and uses a local timestamp to determine if replication should be suppressed.
// 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;
};
Here is the corresponding implementation file, showing how to filter incoming server states:
// 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();
}
}
}
By using this predictive buffer, we prevent the incoming OnRep_Health callback from resetting the visual visibility of the actor. This keeps the client-side actor hidden and collision-free until the channel close packet arrives. If the server does not agree with the destruction (e.g., due to an anti-cheat validation mismatch), the timeout forces a rollback, ensuring the simulation does not permanently desynchronize.
Handling Rollbacks and Validation Rejection
A critical component of this replication fix is handling cases where the server rejects the player's action. If the server's validation logic determines that the player could not have hit the structure, it rejects the damage. In this scenario, the client must roll back the predicted destruction to prevent permanent desynchronization, which is why the timeout restores the actor's collision and visibility if no server confirmation arrives within 500ms.
The Manual Implementation Burden vs. Dedicated Backends
While the C++ solution above fixes individual actor ghosting, applying this across an entire game's environment is complex. Game developers must manually write prediction and rollback code for every destructible object type, track active prediction buffers, optimize actor tick rates, and manage network replication priorities. For an indie team, building and testing these edge cases can easily take 4 to 6 weeks of dedicated network engineering.
Building this yourself requires setting up load balancers, database sharding, and complex WebSockets/UDP servers. With horizOn, these backend services come pre-configured, letting you ship your game instead of managing network infrastructure. horizOn's real-time lobby management and session orchestration ensure player states and match properties sync reliably with sub-50ms latency, mitigating the replication delays that cause ghost builds.
Actionable Best Practices for a Multiplayer Network Replication Desync Fix
When optimizing your netcode for destructible objects, follow these guidelines to keep your world state synchronized:
- Decouple Visual Assets from Actor Lifecycle: Avoid relying on immediate
AActor::Destroy()execution for visual feedback. Set a boolean replication flag likebIsDeadand trigger local particle systems immediately. This allows you to disable collision on the client without waiting for the server's cleanup routines. - Prioritize Channel Destruction over Property Updates: Set
bOnlyRelevantToOwneror increaseNetPriorityon destructibles to ensure that destruction updates are prioritized by the network driver. This ensures they are not delayed behind standard ambient property replication. - Set an Active Prediction Timeout Window: Never let a client-side prediction run indefinitely. Always implement a safety timeout (typically 1.5x to 2x your maximum acceptable RTT, plus a margin for server tick variation) to force a client rollback if the server rejects an action. This prevents the actor from remaining permanently hidden if a packet is dropped.
- Tune NetUpdateFrequency: Keep your destructible structures' update rates low (e.g., 10-15Hz) under normal conditions. Dynamically increase the update frequency to 33Hz only when they take damage, reducing idle bandwidth while preserving responsiveness. This balances network usage during busy player interactions.
- Optimize Server Validation Pipelines: Ensure that server-side damage validation is fast and lightweight. If the server takes more than 100ms to validate a hit, the client prediction buffer will likely timeout, triggering a visible jitter. Streamline verification code to minimize processing delays.
Summary and Next Steps
Resolving replication desyncs requires a deep understanding of your engine's network pipeline. By suppressing server property updates on client-predicted destroyed actors, you can eliminate ghost builds and provide a seamless, responsive experience for players.
Ready to scale your multiplayer backend and reduce synchronization issues? Try horizOn for free or check out the API docs to learn how to implement low-latency session management in your next project.
Source: Ghost builds appear shortly after breaking wooden player build structures