Powrót do Bloga

Ghost Actor Reappearance: Desync Fix w Network Replication dla destructible grid structures w grach Multiplayer

Opublikowano 21 czerwca 2026
Ghost Actor Reappearance: Desync Fix w Network Replication dla destructible grid structures w grach Multiplayer

W skrócie

Artykuł szczegółowo opisuje problem powstawania tzw. „ghost builds” w grach sieciowych, będący wynikiem desynchronizacji Network Replication i opóźnień w client-side prediction. Przedstawiono w nim mechanizm powstawania tego zjawiska oraz zaprezentowano rozwiązanie w języku C++ dla silnika Unreal Engine oparte na Predictive State Buffer. Wskazano również na korzyści płynące z wykorzystania gotowych usług Backend, takich jak horizOn, w celu uniknięcia ręcznego wdrażania skomplikowanej logiki synchronizacji sieciowej. Na koniec podano zestaw praktycznych wskazówek pozwalających zoptymalizować pipeline replikacji dla obiektów niszczalnych.

Ghost Actor Reappearance: Desync Fix w Network Replication dla destructible grid structures w grach Multiplayer

Gracz wykonuje zamach narzędziem do zbierania zasobów, drewniana ściana natychmiast rozpada się na jego ekranie, ale 80 milisekund później pojawia się z powrotem z pełnym poziomem zdrowia, by ostatecznie zniknąć na stałe po kolejnych 400 milisekundach. Ta anomalia wizualna, powszechnie znana jako „ghost build”, to klasyczny objaw rozbieżności w client-side prediction oraz replikacji poza kolejnością (out-of-order replication). W dynamicznych środowiskach Multiplayer te krótkie state rollbacks niszczą immersję i wprowadzają wizualny chaos. Aby temu zaradzić, deweloperzy muszą zaimplementować solidny desync fix dla Network Replication w Multiplayer, który pogodzi natychmiastowy lokalny feedback z autorytatywną walidacją po stronie serwera (authoritative server validation).

Anatomia zjawiska ghost build: Prediction vs. Replication

Podczas tworzenia rozgrywki sieciowej twórcy muszą zbalansować responsywność gry z autorytatywnością serwera (server authority). Aby poruszanie się i destrukcja były odczuwane jako natychmiastowe, klient przewiduje wyniki działań (client-side prediction), zanim otrzyma potwierdzenie od serwera. Na przykład, gdy gracz atakuje niszczalną strukturę, logika gry po stronie klienta natychmiast wykonuje kalkulację obrażeń, wyłącza kolizję i uruchamia efekty cząsteczkowe.

„Pod maską” tworzy to ułamkowosekundową rozbieżność, w której symulacja po stronie klienta wyprzedza serwer. W standardowym modelu Netcode ta rozbieżność jest rozwiązywana, gdy serwer przetworzy wejściowy RPC od klienta i zreplikuje nowy stan z powrotem. Jednak jeśli pakiet się opóźni lub tick rate serwera (zazwyczaj od 20 Hz do 30 Hz) nie nadąża za framerate'em klienta (od 60 Hz do 120 Hz), dochodzi do wyścigu (race condition). Mechanizm client-side prediction usuwa actora, ale kolejna aktualizacja replikacji z serwera wciąż zawiera stary stan actora (żywy, z określonym poziomem zdrowia).

Ten konkretny race condition jest szczególnie widoczny na drewnianych strukturach. W porównaniu z kamieniem lub metalem drewno ma niższy próg zdrowia (np. 90 HP vs 300 HP), co oznacza, że ulega zniszczeniu od jednego uderzenia. To sprawia, że okno czasowe między akcją gracza a potwierdzeniem serwera jest niezwykle wąskie. Każde opóźnienie w replikacji zmusza driver sieciowy klienta do uzgodnienia stanu (reconciliation), co skutkuje zrekonstruowaniem actora, ponieważ serwer wciąż raportuje, że ten żyje.

Wpływ Packet Loss i Tick Rates

W przypadku wystąpienia packet loss, przewidziana przez klienta destrukcja pozostaje w zawieszeniu. Jeśli klient wyśle pakiet z obrażeniami, który zostanie odrzucony (dropped), serwer nigdy go nie przetworzy, podczas gdy klient założy, że obrażenia zostały zadane. Klient kontynuuje wtedy symulację przy błędnym założeniu, że actor zniknął. Kiedy serwer prześle kolejną aktualizację stanu, rozbieżność staje się widoczna, zmuszając klienta do ponownego zespawnowania actora w świecie gry. Ten proces uzgadniania stanu (reconciliation) powoduje irytujące wizualne „mignięcie” (pop), szczególnie przy packet loss rzędu 1,5% do 3%, gdzie utrata pakietów występuje regularnie.

Pod maską: Actor Lifecycle i Channel Teardown

Unreal Engine oraz inne nowoczesne silniki gier Multiplayer synchronizują obecność actorów za pomocą dedykowanych kanałów połączeń sieciowych. Każdemu replikowanemu actorowi przypisywany jest osobny kanał (actor channel). Gdy actor zostaje zniszczony na serwerze, serwer zamyka ten kanał, wysyłając do klienta komunikat kontrolny o zamknięciu kanału (NetGUID retirement).

Kluczowym problemem jest to, że replikacja właściwości (property replication) i zamknięcie kanału nie korzystają z tej samej ścieżki replikacji. Aktualizacje właściwości (takie jak zmiana zmiennej Health struktury) są serializowane i wysyłane jako część standardowego pakietu replikacyjnego actora. Jeśli serwer przetworzy zdarzenie obrażeń, ale nie przeprowadził jeszcze Garbage Collection na tym actorze, może zserializować końcową aktualizację właściwości, zanim actor zostanie w pełni oznaczony do usunięcia. Jeśli pakiet UDP zawierający aktualizację właściwości dotrze przed pakietem z informacją o zamknięciu kanału, klient zaktualizuje zdrowie actora i nadpisze lokalną przewidywaną destrukcję (predicted destruction).

To zachowanie jest ściśle powiązane z innymi problemami synchronizacji Netcode, takimi jak te omówione w naszym poradniku multiplayer desyncs fixing the Unreal Engine RPC replication issue breaking your states. Analizujemy w nim, jak niezgodności w kolejności wykonywania RPC i właściwości (properties) psują stan świata gry. Podobnie przy obsłudze pozycjonowania gracza deweloperzy często napotykają analogiczne rozbieżności, co szczegółowo opisaliśmy w artykule how to fix player location desync in Uefn and Unreal Engine multiplayer.

Kiedy klient przetwarza pakiet replikacji docierający poza kolejnością (out-of-order replication), widzi, że actor na serwerze wciąż żyje i wymusza jego przywrócenie do aktywnej puli (active pool). Klient jest zmuszony czekać, aż pakiet zamykający kanał ostatecznie dotrze – często z opóźnieniem rzędu 0,4 sekundy – aby trwale usunąć actora.

Od strony technicznej pakiety replikacyjne są ograniczone przez Maximum Transmission Unit (MTU), który wynosi zazwyczaj 1400 bajtów. Jeśli prędkość połączenia w grze jest ograniczona (na przykład przez MaxClientRate ustawione na 15000 bajtów/s), aktualizacje są kolejkowane i dzielone na wiele pakietów UDP. Ponieważ komunikat kontrolny dotyczący zamknięcia kanału jest wysyłany w trybie reliable (niezawodnym), wymaga on potwierdzenia odbioru, podczas gdy aktualizacje właściwości (property updates) są często wysyłane jako unreliable. W przypadku zatłoczenia sieci (network congestion) lub packet loss, niezawodny komunikat o zamknięciu kanału może zostać opóźniony względem starszych, niepewnych (unreliable) pakietów właściwości, co prowadzi do rozbieżności, w której klient rekonstruuje actora.

Implementacja Predictive State Buffer w C++

Aby wyeliminować problem ghost builds, musimy przechwytywać nadchodzące aktualizacje replikacji na kliencie dla actorów, których zniszczenie zostało przewidziane lokalnie (predicted destroyed). Implementując bufor po stronie klienta (client-side prediction buffer), możemy zablokować uzgadnianie właściwości (property reconciliation) na określony czas (np. 500 ms), dając pakietowi zamykającemu kanał z serwera wystarczająco dużo czasu na dotarcie. Poniżej znajduje się kompletna, działająca implementacja C++ przewidującego niszczalnego actora. Nadpisuje ona zachowanie replikacji i używa lokalnego znacznika czasu (timestamp) do decydowania, czy replikacja powinna zostać zablokowana.

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

Dzięki zastosowaniu tego bufora predykcyjnego zapobiegamy sytuacji, w której wywołanie zwrotne OnRep_Health przywraca widoczność actora. Sprawia to, że actor po stronie klienta pozostaje ukryty i pozbawiony kolizji aż do momentu nadejścia pakietu zamykającego kanał (channel close). Jeśli serwer nie zatwierdzi destrukcji (np. z powodu rozbieżności w walidacji Anti-Cheat), timeout wymusi rollback, chroniąc symulację przed permanentną desynchronizacją.

Obsługa Rollbacks i Validation Rejection

Kluczowym elementem tego replication fix jest obsługa sytuacji, w których serwer odrzuca akcję gracza. Jeśli logika walidacji serwera określi, że gracz nie mógł trafić w strukturę, obrażenia zostaną odrzucone. W takim scenariuszu klient musi wycofać przewidywaną destrukcję (roll back), aby zapobiec trwałej desynchronizacji. Dlatego właśnie timeout przywraca kolizję i widoczność actora, jeśli w ciągu 500 ms nie nadejdzie potwierdzenie z serwera.

Ręczna implementacja vs. dedykowane rozwiązania Backend

Choć powyższe rozwiązanie w C++ eliminuje ghosting pojedynczego actora, wdrożenie go dla całego środowiska gry bywa skomplikowane. Deweloperzy gier muszą ręcznie pisać kod dla prediction i rollback dla każdego typu niszczalnego obiektu, śledzić aktywne bufory prediction, optymalizować tick rate'y actorów oraz zarządzać priorytetami replikacji sieciowej. Dla niezależnego zespołu (indie team) tworzenie i testowanie takich przypadków brzegowych (edge cases) może z łatwością zająć od 4 do 6 tygodni pracy dedykowanego inżyniera sieciowego.

Samodzielna budowa takiego systemu wymaga skonfigurowania Load Balancing, shardingu baz danych oraz złożonych serwerów WebSockets/UDP. Dzięki platformie horizOn te usługi Backend są dostarczane w postaci w pełni skonfigurowanej, co pozwala Ci skupić się na wydaniu gry zamiast na zarządzaniu infrastrukturą sieciową. Rozwiązania horizOn w zakresie zarządzania lobby w czasie rzeczywistym i orkiestracji sesji gwarantują, że stany graczy i właściwości meczu synchronizują się niezawodnie z opóźnieniem poniżej 50 ms, co eliminuje opóźnienia replikacji leżące u podstaw problemów z ghost builds.

Praktyczne wskazówki dotyczące Multiplayer Network Replication Desync Fix

Wszelkie optymalizacje Netcode dla obiektów niszczalnych powinny opierać się na poniższych wytycznych, aby utrzymać właściwy stan synchronizacji świata gry:

  1. Oddziel aktywa wizualne (Visual Assets) od Actor Lifecycle: Unikaj polegania na natychmiastowym wykonaniu AActor::Destroy() w celu uzyskania wizualnego sprzężenia zwrotnego. Ustaw replikowaną flagę typu boolean, np. bIsDead, i natychmiast uruchom lokalne systemy cząsteczkowe. Pozwoli to na wyłączenie kolizji po stronie klienta bez konieczności czekania na procedury oczyszczania po stronie serwera.
  2. Priorytetyzuj niszczenie kanałów (Channel Destruction) nad aktualizacjami właściwości (Property Updates): Ustaw bOnlyRelevantToOwner lub zwiększ NetPriority dla obiektów niszczalnych, aby upewnić się, że aktualizacje dotyczące destrukcji są traktowane priorytetowo przez network driver. Zapobiegnie to ich opóźnieniu przez standardową replikację właściwości otoczenia (ambient property replication).
  3. Skonfiguruj aktywne okno czasowe (Prediction Timeout Window): Nigdy nie pozwól, aby proces client-side prediction działał w nieskończoność. Zawsze implementuj bezpieczny timeout (zazwyczaj od 1,5x do 2x maksymalnego akceptowalnego czasu RTT plus margines na wahania tick rate serwera), aby wymusić rollback na kliencie, jeśli serwer odrzuci akcję. Zapobiegnie to sytuacji, w której actor pozostanie trwale ukryty w przypadku zgubienia pakietu.
  4. Dostrój NetUpdateFrequency: W normalnych warunkach utrzymuj niską częstotliwość aktualizacji struktur niszczalnych (np. 10-15 Hz). Zwiększaj częstotliwość dynamicznie do 33 Hz tylko wtedy, gdy otrzymują one obrażenia, co pozwala zredukować niepotrzebne zużycie pasma przy jednoczesnym zachowaniu responsywności. Zapewnia to równowagę w obciążeniu sieci podczas intensywnych interakcji między graczami.
  5. Zoptymalizuj potoki walidacji serwera (Server Validation Pipelines): Upewnij się, że walidacja obrażeń po stronie serwera przebiega szybko i wydajnie. Jeśli serwer potrzebuje więcej niż 100 ms na zatwierdzenie trafienia, bufor prediction klienta prawdopodobnie napotka timeout, co wywoła widoczny efekt szarpania (jitter). Uprość kod weryfikacyjny, aby zminimalizować opóźnienia przetwarzania.

Podsumowanie i kolejne kroki

Rozwiązanie problemu desynchronizacji replikacji wymaga dogłębnego zrozumienia potoku sieciowego (network pipeline) Twojego silnika. Poprzez blokowanie aktualizacji właściwości serwera dla actorów, których zniszczenie zostało przewidziane po stronie klienta, możesz całkowicie wyeliminować ghost builds i zapewnić graczom płynną, wysoce responsywną rozgrywkę.

Chcesz skalować swój Multiplayer Backend i zredukować problemy z synchronizacją? Wypróbuj horizOn za darmo lub zapoznaj się z API docs, aby dowiedzieć się, jak zaimplementować zarządzanie sesjami o niskim opóźnieniu w swoim kolejnym projekcie.


Źródło: Ghost builds appear shortly after breaking wooden player build structures