Riapparizione dei Ghost Actor: Il fix per il desync di network replication multiplayer per le strutture distruttibili a griglia
In breve
Questo articolo esamina il problema delle "ghost build" nei giochi multiplayer, causato da conflitti tra client-side prediction e replication fuori ordine. Viene presentata una soluzione in C++ basata su un buffer predittivo temporizzato per sopprimere gli aggiornamenti di stato del server non sincronizzati. Infine, si analizzano le best practice di netcode per ottimizzare la gestione della distruzione degli oggetti e ridurre il carico di sviluppo manuale tramite l'uso di backend dedicati.
Riapparizione dei Ghost Actor: Il fix per il desync di network replication multiplayer per le strutture distruttibili a griglia
Il giocatore sferra un colpo con uno strumento di raccolta, una parete di legno va in frantumi all'istante sul suo schermo, ma 80 millisecondi dopo riappare per un attimo al massimo della salute prima di svanire definitivamente altri 400 millisecondi più tardi. Questa anomalia visiva, comunemente nota come "ghost build", è una classica manifestazione di un disallineamento della client-side prediction e di una replication non sequenziale (out-of-order replication). Nei contesti multiplayer dal ritmo serrato, questi brevi rollback di stato spezzano l'immersione del giocatore e introducono disturbi visivi. Per risolvere il problema, gli sviluppatori devono implementare un solido fix per il desync di network replication multiplayer che riconcili il feedback locale immediato con la validazione autorevole del server.
Anatomia di una Ghost Build: Prediction vs. Replication
Durante lo sviluppo del gameplay di rete, i creatori devono bilanciare la reattività del gioco con l'autorità del server. Per fare in modo che il movimento e la distruzione sembrino istantanei, i client predicono gli esiti delle azioni prima di ricevere la conferma dal server. Ad esempio, quando un giocatore attacca una struttura distruttibile, la logica di gioco client-side esegue immediatamente il calcolo dei danni, disabilita le collisioni e attiva gli effetti particellari.
Dietro le quinte, questo crea una divergenza di una frazione di secondo in cui la simulazione del client è in anticipo rispetto a quella del server. In un modello di netcode standard, questa divergenza viene risolta non appena il server elabora la RPC di input del client e ne replica lo stato aggiornato. Tuttavia, se un pacchetto subisce un ritardo, o se il tick rate del server (in genere da 20Hz a 30Hz) è inferiore al frame rate del client (da 60Hz a 120Hz), si verifica una race condition. La client-side prediction rimuove l'actor, ma il successivo aggiornamento di replication del server contiene ancora il vecchio stato dell'actor (in vita e con la relativa salute).
Questa specifica race condition è particolarmente visibile sulle strutture di legno. Rispetto alla pietra o al metallo, il legno ha una soglia di salute inferiore (ad esempio, 90 HP contro 300 HP), il che significa che viene distrutto con un singolo colpo. Di conseguenza, la finestra temporale tra l'azione del giocatore e l'acknowledgment del server diventa estremamente stretta. Qualsiasi ritardo di replication costringe il network driver del client a riconciliare lo stato, ricostruendo l'actor poiché il server lo segnala ancora come in vita.
L'impatto di Packet Loss e Tick Rate
Quando si verifica un packet loss, la distruzione predetta del client viene lasciata in uno stato di limbo. Se un client trasmette un pacchetto di danno che viene perso (dropped), il server non lo elabora mai, ma il client presume che il danno sia stato applicato. Il client continua quindi la simulazione partendo dal presupposto errato che l'actor sia scomparso. Quando il server trasmette il successivo aggiornamento di stato, il disallineamento diventa evidente, costringendo il client a spawnare nuovamente l'actor nel mondo di gioco. Questo processo di riconciliazione crea un pop visivo, in particolare con un packet loss compreso tra l'1,5% e il 3%, dove queste perdite si verificano frequentemente.
Dietro le quinte del ciclo di vita degli Actor e del Channel Teardown
Unreal Engine e motori multiplayer moderni simili sincronizzano la presenza degli actor utilizzando canali di connessione di rete dedicati. A ciascun actor replicato viene assegnato un actor channel. Quando un actor viene distrutto sul server, il server chiude questo canale, inviando al client un messaggio di controllo di chiusura del canale (NetGUID retirement).
Il problema critico risiede nel fatto che la replication delle proprietà e la chiusura del canale non condividono lo stesso percorso di replication. Gli aggiornamenti delle proprietà (come le variazioni della variabile Health di una struttura) vengono serializzati e inviati come parte del normale pacchetto di replication dell'actor. Se il server elabora un evento di danno ma non ha ancora eseguito la garbage collection sull'actor, potrebbe serializzare un ultimo aggiornamento delle proprietà prima che l'actor venga contrassegnato definitivamente per la distruzione. Se il pacchetto UDP contenente l'aggiornamento della proprietà arriva prima del pacchetto contenente la chiusura del canale, il client aggiorna la salute dell'actor e sovrascrive la distruzione predetta localmente.
Questo comportamento è strettamente legato ad altri problemi di sincronizzazione del netcode, come quelli discussi nella nostra guida su risoluzione dei desync multiplayer legati alla replication delle RPC in Unreal Engine che corrompono gli stati. In quella guida analizziamo come i disallineamenti nell'ordine di esecuzione tra RPC e proprietà danneggino gli stati del mondo di gioco. Analogamente, nella gestione del posizionamento dei giocatori, gli sviluppatori riscontrano spesso discrepanze simili, come descritto in dettaglio nella nostra guida su come correggere il desync della posizione dei giocatori nel multiplayer di Uefn e Unreal Engine.
Quando il client elabora il pacchetto di replication fuori ordine (out-of-order), rileva che l'actor è attivo sul server e lo reinserisce forzatamente nel pool attivo. Il client è così costretto ad attendere l'arrivo effettivo del pacchetto di chiusura del canale — spesso 0,4 secondi dopo — per eliminare definitivamente l'actor.
Dietro le quinte, i pacchetti di replication sono limitati dalla Maximum Transmission Unit (MTU), che in genere è di 1400 byte. Se la velocità di connessione del gioco è limitata (ad esempio, con MaxClientRate impostato su 15000 byte/s), gli aggiornamenti vengono accodati e suddivisi su più pacchetti UDP. Poiché il messaggio di controllo per la chiusura del canale viene inviato in modalità reliable, richiede un acknowledgment, mentre gli aggiornamenti delle proprietà sono spesso inviati in modalità unreliable. In caso di congestione di rete o packet loss, il messaggio reliable di chiusura del canale può subire ritardi rispetto a pacchetti unreliable di proprietà più vecchi, causando un disallineamento per cui il client ricostruisce l'actor.
Implementare un Predictive State Buffer in C++
Per risolvere le ghost build, dobbiamo intercettare sul client gli aggiornamenti di replication in arrivo per gli actor la cui distruzione è stata predetta (predicted destroyed). Implementando un buffer di client-side prediction, possiamo sopprimere la riconciliazione delle proprietà per una specifica finestra temporale (ad esempio, 500 ms), concedendo al pacchetto di chiusura del canale del server il tempo necessario per arrivare. Di seguito è riportata un'implementazione C++ completa e funzionante di un actor distruttibile predittivo. Questa soluzione sovrascrive il comportamento di replication e utilizza un timestamp locale per determinare se la replication debba essere soppressa.
// 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;
};
Ecco il file di implementazione corrispondente, che mostra come filtrare gli stati del server in arrivo:
// 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();
}
}
}
Utilizzando questo buffer predittivo, impediamo alla callback OnRep_Health in arrivo di ripristinare la visibilità dell'actor. Questo mantiene l'actor lato client nascosto e privo di collisioni fino all'arrivo del pacchetto di chiusura del canale. Se il server non convalida la distruzione (ad esempio, a causa di un problema di allineamento dovuto all'anti-cheat), il timeout forza un rollback, assicurando che la simulazione non rimanga permanentemente desincronizzata.
Gestione dei Rollback e del Rifiuto della Validazione
Una componente critica di questo fix di replication consiste nel gestire i casi in cui il server rifiuta l'azione del giocatore. Se la logica di validazione del server determina che il giocatore non avrebbe potuto colpire la struttura, respinge il danno. In questo scenario, il client deve eseguire il rollback della distruzione predetta per evitare una desincronizzazione permanente; per questo motivo, il timeout ripristina la collisione e la visibilità dell'actor qualora non giunga alcuna conferma dal server entro 500 ms.
L'onere dell'implementazione manuale vs. Backend dedicati
Sebbene la soluzione C++ sopra descritta risolva il ghosting dei singoli actor, applicare questo sistema all'intero ambiente di gioco è complesso. Gli sviluppatori di videogiochi devono scrivere manualmente il codice di prediction e rollback per ogni tipo di oggetto distruttibile, tracciare i buffer di prediction attivi, ottimizzare i tick rate degli actor e gestire le priorità di network replication. Per un team indipendente, lo sviluppo e il testing di questi casi limite possono richiedere facilmente dalle 4 alle 6 settimane di lavoro dedicato da parte di un programmatore di rete.
Sviluppare autonomamente una simile infrastruttura richiede la configurazione di load balancer, database sharding e complessi server basati su WebSockets/UDP. Con horizOn, questi servizi backend sono preconfigurati, permettendoti di lanciare il tuo gioco invece di gestire l'infrastruttura di rete. La gestione delle lobby in tempo reale e l'orchestrazione delle sessioni di horizOn assicurano che gli stati dei giocatori e le proprietà del match si sincronizzino in modo affidabile con una latenza inferiore ai 50 ms, attenuando i ritardi di replication che causano le ghost build.
Best Practice attuabili per un Fix del Desync di Network Replication Multiplayer
Quando si ottimizza il netcode per gli oggetti distruttibili, si consiglia di seguire queste linee guida per mantenere sincronizzato lo stato del mondo:
- Disaccoppiare gli asset visivi dal ciclo di vita dell'actor: Evitare di fare affidamento sull'esecuzione immediata di
AActor::Destroy()per il feedback visivo. Impostare un flag booleano di replication comebIsDeade attivare immediatamente i sistemi particellari locali. Questo consente di disabilitare la collisione sul client senza attendere le routine di pulizia (cleanup) del server. - Dare priorità alla distruzione del canale rispetto agli aggiornamenti delle proprietà: Impostare
bOnlyRelevantToOwnero aumentare laNetPrioritysugli oggetti distruttibili per garantire che gli aggiornamenti di distruzione abbiano la priorità per il network driver. In questo modo si evita che vengano ritardati a causa della normale replication delle proprietà ambientali. - Definire una finestra di timeout attiva per la prediction: Non lasciare mai che una client-side prediction venga eseguita a tempo indeterminato. Implementare sempre un timeout di sicurezza (in genere pari a 1,5 o 2 volte il RTT massimo accettabile, più un margine per la variazione del tick del server) per forzare un rollback del client se il server rifiuta un'azione. Questo impedisce all'actor di rimanere permanentemente nascosto nel caso in cui un pacchetto venga perso (dropped).
- Ottimizzare la NetUpdateFrequency: Mantenere bassi i rate di aggiornamento delle strutture distruttibili (ad esempio, 10-15Hz) in condizioni normali. Aumentare dinamicamente la frequenza di aggiornamento a 33Hz solo quando subiscono danni, riducendo la larghezza di banda inattiva pur preservando la reattività. Questo consente di bilanciare l'utilizzo della rete durante le interazioni più intense dei giocatori.
- Ottimizzare le pipeline di validazione del server: Assicurarsi che la validazione del danno lato server sia rapida e leggera. Se il server impiega più di 100 ms per convalidare un colpo, il buffer di prediction del client andrà probabilmente in timeout, causando un jitter visibile. Semplificare il codice di verifica per ridurre al minimo i ritardi di elaborazione.
Riepilogo e prossimi passi
Sei pronto a scalare il tuo backend multiplayer e a ridurre i problemi di sincronizzazione? Prova horizOn gratuitamente o consulta la documentazione delle API per scoprire come implementare una gestione delle sessioni a bassa latenza nel tuo prossimo progetto.
Fonte: Ghost builds appear shortly after breaking wooden player build structures