Zurück zum Blog

Ghost Actor Reappearance: Der Multiplayer Network Replication Desync Fix für zerstörbare Grid-Strukturen

Veröffentlicht am 21. Juni 2026
Ghost Actor Reappearance: Der Multiplayer Network Replication Desync Fix für zerstörbare Grid-Strukturen

Kurz und knapp

Dieser Artikel befasst sich mit der Behebung von Ghost Builds bei zerstörbaren Strukturen in Multiplayer-Spielen durch die Lösung von Desynchronisationen bei der Network Replication. Es wird erklärt, wie Client-Side Prediction, Packet Loss und Out-of-Order-Replication zu visuellen Artefakten führen, bei denen zerstörte Objekte kurzzeitig wiederauftauchen. Die bereitgestellte C++-Lösung für Unreal Engine zeigt, wie ein clientseitiger Prediction-Buffer eingehende Property-Updates unterdrückt, bis das Channel-Closure-Packet des Servers eintrifft.

Ghost Actor Reappearance: Der Multiplayer Network Replication Desync Fix für zerstörbare Grid-Strukturen

Ihr Spieler schwingt ein Werkzeug zum Abbauen, eine Holzwand zersplittert sofort auf seinem Bildschirm, aber 80 Millisekunden später blinkt sie mit voller Gesundheit wieder auf, bevor sie 400 Millisekunden später dauerhaft verschwindet. Diese visuelle Anomalie, die allgemein als „Ghost Build“ bekannt ist, ist eine klassische Manifestation von Client-Side Prediction Mismatches und Out-of-Order-Replication. In schnellen Multiplayer-Umgebungen zerstören diese kurzen State Rollbacks die Immersion der Spieler und sorgen für visuelles Rauschen. Um dieses Problem zu lösen, müssen Entwickler einen robusten Multiplayer Network Replication Desync Fix implementieren, der das sofortige lokale Feedback mit der autoritativen Server-Validierung in Einklang bringt.

Anatomie eines Ghost Builds: Prediction vs. Replication

Bei der Entwicklung von Network-Gameplay müssen Entwickler die Balance zwischen Reaktionsschnelligkeit und Server-Authorität wahren. Damit sich Bewegungen und Zerstörungen unmittelbar anfühlen, treffen Clients Vorhersagen (Prediction) über die Ergebnisse von Aktionen, noch bevor sie die Bestätigung vom Server erhalten. Wenn beispielsweise ein Spieler eine zerstörbare Struktur angreift, führt die clientseitige Gamelogik sofort die Schadensberechnung aus, deaktiviert die Collision und triggert Partikeleffekte.

Unter der Haube entsteht dadurch eine minimale Abweichung, bei der die Client-Simulation dem Server voraus ist. In einem Standard-Netcode-Modell wird diese Abweichung aufgelöst, sobald der Server den Input-RPC des Clients verarbeitet und den neuen State zurückrepliziert. Wenn jedoch ein Packet verzögert wird oder die Server Tick Rate (normalerweise 20 Hz bis 30 Hz) hinter der Client Frame Rate (60 Hz bis 120 Hz) hinterherhinkt, kommt es zu einer Race Condition. Die Client-side Prediction entfernt den Actor, aber das nächste Replication-Update des Servers enthält immer noch den älteren Zustand des Actors (lebendig und mit vollem Health-Wert).

Diese spezifische Race Condition tritt besonders häufig bei Holzstrukturen auf. Im Vergleich zu Stein oder Metall hat Holz einen niedrigeren Health-Threshold (z. B. 90 HP vs. 300 HP), was bedeutet, dass es mit einem einzigen Treffer zerstört wird. Dadurch wird das Zeitfenster zwischen der Spieleraktion und der Bestätigung durch den Server extrem klein. Jede Verzögerung bei der Replication zwingt den Network Driver des Clients dazu, den State abzugleichen (Reconciliation), wodurch der Actor rekonstruiert wird, weil der Server ihn immer noch als lebendig meldet.

Die Auswirkungen von Packet Loss und Tick Rates

Wenn Packet Loss auftritt, verbleibt die vorhergesagte Zerstörung des Clients in einem Schwebezustand. Wenn ein Client ein Schadens-Packet sendet, das verloren geht (Dropped Packet), verarbeitet der Server dieses nie, aber der Client geht davon aus, dass der Schaden verursacht wurde. Der Client setzt die Simulation dann unter der falschen Annahme fort, dass der Actor verschwunden ist. Wenn der Server das nächste State-Update sendet, wird der Mismatch sichtbar, was den Client dazu zwingt, den Actor wieder in der Welt zu spawnen. Dieser Reconciliation-Prozess führt zu einem störenden visuellen Pop-Effekt, insbesondere bei 1,5 % bis 3 % Packet Loss, wo diese Drops häufig vorkommen.

Ein Blick unter die Haube: Actor-Lifecycle und Channel Teardown

Unreal Engine und ähnliche moderne Multiplayer-Engines synchronisieren die Präsenz von Actoren über dedizierte Network Connection Channels. Jedem replizierten Actor ist ein Actor Channel zugewiesen. Wenn ein Actor auf dem Server zerstört wird, schließt der Server diesen Channel und sendet eine Control Message zum Schließen des Channels (NetGUID Retirement) an den Client.

Das kritische Problem dabei ist, dass Property Replication und das Schließen des Channels nicht denselben Replication Path nutzen. Property-Updates (wie die Aktualisierung der Variable Health einer Struktur) werden serialisiert und als Teil des regulären Replication Bundles des Actors gesendet. Wenn der Server ein Schadensevent verarbeitet, der Actor aber noch nicht per Garbage Collection bereinigt wurde, sendet er möglicherweise ein letztes Property-Update, bevor der Actor vollständig für die Zerstörung markiert ist. Wenn das UDP-Packet mit dem Property-Update vor dem Packet mit dem Channel-Closure eintrifft, aktualisiert der Client die Gesundheit des Actors und überschreibt die lokal vorhergesagte Zerstörung (Predicted Destruction).

Dieses Verhalten ist eng mit anderen Synchronisationsproblemen im Netcode verwandt, wie sie beispielsweise in unserem Leitfaden über Multiplayer-Desyncs: Beheben des Unreal Engine RPC-Replication-Problems, das Ihre States beschädigt beschrieben werden. In diesem Guide analysieren wir, wie Mismatches bei der Ausführungsreihenfolge zwischen RPCs und Properties die World States beschädigen. Auch bei der Verarbeitung von Spielerpositionen treten häufig ähnliche Diskrepanzen auf, wie in unserem Guide Beheben von Player-Location-Desyncs in Uefn und Unreal Engine Multiplayer im Detail erklärt wird.

Wenn der Client das Out-of-Order Replication-Packet verarbeitet, stellt er fest, dass der Actor auf dem Server noch am Leben ist, und zwingt den Actor zurück in den aktiven Pool. Der Client muss dann warten, bis das Channel-Closure-Packet endlich eintrifft – oft 0,4 Sekunden später –, um den Actor dauerhaft zu löschen.

Unter der Haube sind Replication-Packets durch die Maximum Transmission Unit (MTU) begrenzt, die typischerweise bei 1400 Byte liegt. Wenn Ihre Game-Verbindungsrate gedrosselt ist (z. B. wenn MaxClientRate auf 15000 Byte/Sek. eingestellt ist), werden Updates in die Warteschlange gestellt und auf mehrere UDP-Packets aufgeteilt. Da die Control Message für das Schließen des Channels zuverlässig (Reliable) gesendet wird, muss sie bestätigt werden, während Property-Updates oft unzuverlässig (Unreliable) gesendet werden. Bei Netzwerküberlastung oder Packet Loss kann sich die Reliable Channel-Closure-Nachricht hinter älteren, Unreliable Property-Packets verzögern, was zu einem Mismatch führt, bei dem der Client den Actor rekonstruiert.

Implementierung eines Predictive State Buffers in C++

Um Ghost Builds zu beheben, müssen wir eingehende Replication-Updates auf dem Client für Actoren abfangen, deren Zerstörung vorhergesagt (Predicted Destroyed) wurde. Durch die Implementierung eines clientseitigen Prediction-Buffers können wir die Property-Reconciliation für ein bestimmtes Zeitfenster (z. B. 500 ms) unterdrücken, sodass das Channel-Closure-Packet des Servers genügend Zeit hat, um einzutreffen. Unten finden Sie eine vollständige, funktionierende C++-Implementierung eines prädiktiven, zerstörbaren Actors. Sie überschreibt das Replication-Verhalten und nutzt einen lokalen Zeitstempel, um zu bestimmen, ob die Replication unterdrückt werden soll.

// 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 ist die entsprechende Implementierungsdatei, die zeigt, wie eingehende Server-States gefiltert werden:

// 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();
        }
    }
}

Durch die Verwendung dieses Predictive Buffers verhindern wir, dass der eingehende OnRep_Health-Callback die visuelle Sichtbarkeit des Actors zurücksetzt. Dadurch bleibt der clientseitige Actor ausgeblendet und frei von Collision, bis das Channel-Close-Packet eintrifft. Wenn der Server der Zerstörung nicht zustimmt (z. B. aufgrund eines Mismatches bei der Anti-Cheat-Validierung), erzwingt der Timeout einen Rollback. So wird sichergestellt, dass die Simulation nicht dauerhaft desynchronisiert.

Umgang mit Rollbacks und abgelehnter Validierung

Eine kritische Komponente dieses Replication-Fixes ist die Behandlung von Fällen, in denen der Server die Aktion des Spielers ablehnt. Wenn die Validierungslogik des Servers feststellt, dass der Spieler die Struktur nicht hätte treffen können, lehnt er den Schaden ab. In diesem Szenario muss der Client die vorhergesagte Zerstörung rückgängig machen (Rollback), um eine dauerhafte Desynchronisation zu verhindern. Aus diesem Grund stellt der Timeout die Collision und Sichtbarkeit des Actors wieder her, wenn innerhalb von 500 ms keine Bestätigung vom Server eintrifft.

Manueller Implementierungsaufwand vs. dedizierte Backends

Während die obige C++-Lösung das Ghosting einzelner Actoren behebt, ist die Anwendung auf die gesamte Spielumgebung hochkomplex. Game-Entwickler müssen Prediction- und Rollback-Code für jeden zerstörbaren Objekttyp manuell schreiben, aktive Prediction-Buffer tracken, Actor-Tick-Rates optimieren und Prioritäten bei der Network Replication verwalten. Für ein Indie-Team kann das Entwickeln und Testen dieser Edge Cases leicht 4 bis 6 Wochen dedizierte Network-Engineering-Arbeit in Anspruch nehmen.

Dies selbst aufzubauen erfordert die Einrichtung von Load Balancern, Database Sharding und komplexen WebSockets-/UDP-Servern. Mit horizOn sind diese Backend-Services vorkonfiguriert, sodass Sie Ihr Spiel releasen können, anstatt die Netzwerkinfrastruktur zu verwalten. Das Echtzeit-Lobbymanagement und die Session-Orchestrierung von horizOn sorgen dafür, dass Player-States und Match-Properties zuverlässig mit einer Latenz von unter 50 ms synchronisiert werden, wodurch die Replication-Verzögerungen, die Ghost Builds verursachen, minimiert werden.

Praxisnahe Best Practices für einen Multiplayer Network Replication Desync Fix

Wenn Sie Ihren Netcode für zerstörbare Objekte optimieren, halten Sie sich an die folgenden Richtlinien, um den World-State synchron zu halten:

  1. Entkoppeln Sie visuelle Assets vom Actor-Lifecycle: Vermeiden Sie es, sich für visuelles Feedback auf die sofortige Ausführung von AActor::Destroy() zu verlassen. Setzen Sie ein Boolean-Replication-Flag wie bIsDead und triggern Sie lokale Partikelsysteme sofort. Dadurch können Sie die Collision auf dem Client deaktivieren, ohne auf die Cleanup-Routinen des Servers warten zu müssen.
  2. Priorisieren Sie die Zerstörung von Channels gegenüber Property-Updates: Setzen Sie bOnlyRelevantToOwner oder erhöhen Sie NetPriority für zerstörbare Objekte, um sicherzustellen, dass Zerstörungs-Updates vom Network Driver prioritär behandelt werden. Dies stellt sicher, dass sie nicht hinter der normalen Replication von Umgebungs-Properties verzögert werden.
  3. Richten Sie ein aktives Prediction-Timeout-Zeitfenster ein: Lassen Sie eine Client-side Prediction niemals unbegrenzt laufen. Implementieren Sie immer ein Sicherheits-Timeout (normalerweise das 1,5- bis 2-fache Ihrer maximal akzeptablen RTT, plus eine Marge für Server-Tick-Abweichungen), um einen Client-Rollback zu erzwingen, falls der Server eine Aktion ablehnt. Dies verhindert, dass der Actor dauerhaft ausgeblendet bleibt, wenn ein Packet verloren geht.
  4. Optimieren Sie die NetUpdateFrequency: Halten Sie die Update-Raten Ihrer zerstörbaren Strukturen unter normalen Bedingungen niedrig (z. B. 10–15 Hz). Erhöhen Sie die Update-Frequenz dynamisch nur dann auf 33 Hz, wenn sie Schaden erleiden. Das spart Idle-Bandbreite und erhält gleichzeitig die Reaktionsschnelligkeit. So balancieren Sie die Netzwerkauslastung bei intensiven Interaktionen der Spieler.
  5. Optimieren Sie Server-Validierungs-Pipelines: Stellen Sie sicher, dass die serverseitige Schadensvalidierung schnell und ressourcenschonend ist. Wenn der Server mehr als 100 ms benötigt, um einen Treffer zu validieren, läuft der clientseitige Prediction-Buffer wahrscheinlich ab, was zu einem sichtbaren Ruckeln (Jitter) führt. Optimieren Sie den Validierungs-Code, um Verarbeitungsverzögerungen zu minimieren.

Zusammenfassung und nächste Schritte

Das Beheben von Replication-Desyncs erfordert ein tiefes Verständnis der Network-Pipeline Ihrer Engine. Indem Sie serverseitige Property-Updates für clientseitig als zerstört vorhergesagte Actoren unterdrücken, können Sie Ghost Builds eliminieren und den Spielern eine nahtlose, reaktionsschnelle Experience bieten.

Sind Sie bereit, Ihr Multiplayer-Backend zu skalieren und Synchronisationsprobleme zu reduzieren? Testen Sie horizOn kostenlos oder werfen Sie einen Blick in die API docs, um zu erfahren, wie Sie ein Low-Latency-Session-Management in Ihrem nächsten Projekt implementieren können.


Quelle: Ghost builds appear shortly after breaking wooden player build structures