Ghost Actor Reappearance: Solusi Desync Network Replication Multiplayer untuk Destructible Grid Structures
Ringkasnya
Artikel ini membahas solusi mengatasi masalah desync berupa 'ghost build' pada game multiplayer yang menggunakan destructible grid structures. Pembahasan mencakup analisis bentrokan antara client-side prediction dan server replication akibat delay paket UDP serta perbedaan tick rate. Penulis juga memberikan contoh implementasi client-side prediction buffer berbasis C++ di Unreal Engine untuk menahan pembaruan status dari server sementara waktu. Panduan ini diakhiri dengan best practice optimasi netcode seperti decoupling visual asset dan tuning NetUpdateFrequency.
Ghost Actor Reappearance: Solusi Desync Network Replication Multiplayer untuk Destructible Grid Structures
Pemain Anda mengayunkan alat pemanen, sebuah dinding kayu langsung hancur di layar mereka, namun 80 milidetik kemudian dinding tersebut berkedip kembali muncul dengan health penuh sebelum akhirnya lenyap secara permanen 400 milidetik kemudian. Anomali visual ini, yang biasa dikenal sebagai "ghost build", merupakan manifestasi klasik dari ketidakcocokan (mismatch) client-side prediction dan out-of-order replication. Dalam lingkungan multiplayer yang serba cepat, rollback state singkat seperti ini merusak imersifitas pemain dan menimbulkan gangguan visual (visual clutter). Untuk mengatasinya, developer harus menerapkan perbaikan desync network replication multiplayer yang tangguh untuk merekonsiliasi feedback lokal instan dengan validasi server yang otoritatif (authoritative server validation).
Anatomi Ghost Build: Prediction vs. Replication
Saat membangun gameplay berbasis network, kreator harus menyeimbangkan antara responsivitas dengan otoritas server (server authority). Agar pergerakan dan penghancuran terasa instan, client akan memprediksi hasil dari aksi sebelum menerima konfirmasi dari server. Sebagai contoh, ketika pemain menyerang struktur yang dapat dihancurkan (destructible structure), game logic di sisi client (client-side) akan langsung menjalankan kalkulasi damage, menonaktifkan collision, dan memicu particle effects.
Di balik layar, hal ini menciptakan divergensi sepersekian detik di mana simulasi client berada lebih maju daripada server. Dalam model netcode standar, divergensi ini diselesaikan setelah server memproses input RPC dari client dan melakukan replikasi state baru kembali ke client. Namun, jika paket mengalami delay, atau jika tick rate server (biasanya 20Hz hingga 30Hz) tertinggal dari frame rate client (60Hz hingga 120Hz), maka race condition akan terjadi. Prediction di sisi client menghapus actor tersebut, namun update replikasi server berikutnya masih berisi state lama dari actor tersebut (hidup dengan health).
Race condition spesifik ini sangat terlihat pada struktur kayu. Dibandingkan dengan batu atau logam, kayu memiliki threshold health yang lebih rendah (misalnya, 90 HP vs. 300 HP), yang berarti dapat hancur dalam sekali pukul. Hal ini membuat time window antara aksi pemain dan acknowledgment server menjadi sangat sempit. Setiap delay replikasi akan memaksa network driver di sisi client untuk melakukan rekonsiliasi state, membangun kembali actor tersebut karena server masih melaporkannya sebagai actor yang hidup.
Dampak Packet Loss dan Tick Rate
Ketika terjadi packet loss, prediksi penghancuran oleh client akan tertahan dalam kondisi mengambang (limbo). Jika client mengirimkan paket damage yang kemudian drop, server tidak akan pernah memprosesnya, tetapi client berasumsi damage tersebut telah diterapkan. Client kemudian melanjutkan simulasi dengan asumsi keliru bahwa actor tersebut sudah hilang. Saat server mengirimkan update state berikutnya, ketidakcocokan akan terlihat jelas, memaksa client untuk memunculkan (spawn) kembali actor tersebut ke dalam world. Proses rekonsiliasi ini menciptakan efek visual pop-in yang mengganggu, terutama di bawah kondisi packet loss 1,5% hingga 3% di mana drop paket ini sering terjadi.
Di Balik Layar: Actor Lifecycle dan Channel Teardown
Unreal Engine dan engine multiplayer modern sejenisnya menyinkronkan keberadaan actor menggunakan network connection channel khusus. Setiap actor yang direplikasi akan diberikan sebuah actor channel. Ketika sebuah actor dihancurkan di server, server akan menutup channel ini, serta mengirimkan pesan kontrol penutupan channel (NetGUID retirement) ke client.
Masalah kritisnya adalah property replication dan channel closure tidak berbagi jalur replikasi yang sama. Update property (seperti update pada variabel Health dari struktur) diserialisasi dan dikirim sebagai bagian dari bundle replikasi reguler milik actor tersebut. Jika server memproses event damage tetapi belum melakukan garbage collection pada actor tersebut, server mungkin akan melakukan serialisasi update property terakhir sebelum actor tersebut sepenuhnya ditandai untuk dihancurkan. Jika paket UDP yang berisi update property tiba sebelum paket yang berisi channel closure, client akan memperbarui health actor tersebut dan menimpa (override) prediksi penghancuran lokal.
Perilaku ini sangat berkaitan erat dengan masalah sinkronisasi netcode lainnya, seperti yang dibahas dalam panduan kami tentang multiplayer desyncs fixing the Unreal Engine RPC replication issue breaking your states. Dalam panduan tersebut, kami menganalisis bagaimana ketidakcocokan urutan eksekusi antara RPC dan property merusak world state. Demikian pula, saat menangani positioning pemain, developer sering menemui diskrepansi serupa, seperti yang dijelaskan dalam panduan kami mengenai how to fix player location desync in Uefn and Unreal Engine multiplayer.
Ketika client memproses paket replikasi yang out-of-order tersebut, ia melihat bahwa actor masih hidup di server dan memaksa actor tersebut kembali ke active pool. Client terpaksa menunggu sampai paket channel closure akhirnya tiba—sering kali 0,4 detik kemudian—untuk menghapus actor secara permanen.
Di balik layar, paket replikasi dibatasi oleh Maximum Transmission Unit (MTU), yang biasanya berukuran 1400 byte. Jika connection rate game Anda dibatasi (misalnya, dengan MaxClientRate diatur ke 15000 bytes/sec), update akan masuk antrean (queued) dan dibagi ke beberapa paket UDP. Karena control message untuk channel closure dikirim secara reliable, pesan tersebut harus mendapatkan acknowledgment (ACK), sedangkan update property sering kali dikirim secara unreliable. Jika terjadi kemacetan jaringan (network congestion) atau packet loss, pesan channel closure yang bersifat reliable tersebut dapat tertunda di belakang paket property yang unreliable dan lebih lama, menyebabkan mismatch di mana client merekonstruksi kembali actor tersebut.
Mengimplementasikan Predictive State Buffer di C++
Untuk memperbaiki ghost build, kita harus mengintersepsi update replikasi yang masuk pada client untuk actor yang telah diprediksi hancur. Dengan mengimplementasikan sebuah client-side prediction buffer, kita dapat menekan (suppress) rekonsiliasi property untuk time window tertentu (misalnya, 500ms), memberikan cukup waktu bagi paket channel-closure dari server untuk tiba. Di bawah ini adalah implementasi C++ lengkap yang berfungsi untuk predictive destructible actor. Kode ini meng-override perilaku replikasi dan menggunakan timestamp lokal untuk menentukan apakah replikasi harus ditekan.
// 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;
};
Berikut adalah file implementasi yang sesuai, yang menunjukkan cara memfilter state server yang masuk:
// 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();
}
}
}
Dengan menggunakan predictive buffer ini, kita mencegah callback OnRep_Health yang masuk untuk me-reset visibilitas visual dari actor. Hal ini menjaga actor di sisi client tetap tersembunyi dan bebas collision sampai paket channel close tiba. Jika server tidak menyetujui penghancuran tersebut (misalnya, karena adanya mismatch validasi Anti-Cheat), timeout akan memaksa dilakukannya rollback, memastikan simulasi tidak mengalami desync secara permanen.
Menangani Rollback dan Penolakan Validasi
Komponen kritis dari perbaikan replikasi ini adalah menangani kasus di mana server menolak aksi pemain. Jika logika validasi server mendeteksi bahwa pemain tidak mungkin mengenai struktur tersebut, server akan menolak damage-nya. Dalam skenario ini, client harus melakukan rollback terhadap prediksi penghancuran untuk mencegah desinkronisasi permanen, itulah sebabnya timeout akan mengembalikan collision dan visibilitas actor jika tidak ada konfirmasi server yang tiba dalam waktu 500ms.
Beban Implementasi Manual vs. Dedicated Backend
Meskipun solusi C++ di atas mengatasi masalah ghosting pada masing-masing actor, menerapkannya ke seluruh environment game sangatlah kompleks. Developer game harus menulis kode prediction dan rollback secara manual untuk setiap tipe destructible object, melacak active prediction buffers, mengoptimalkan tick rate actor, dan mengelola prioritas network replication. Bagi tim indie, membangun dan menguji kasus-kasus edge case seperti ini dapat dengan mudah memakan waktu 4 hingga 6 minggu kerja network engineering khusus.
Membangun sistem ini sendiri mengharuskan Anda mengatur load balancer, database sharding, serta server WebSockets/UDP yang kompleks. Dengan horizOn, layanan backend ini sudah terkonfigurasi sebelumnya (pre-configured), memungkinkan Anda merilis game alih-alih mengelola infrastruktur jaringan. Fitur real-time lobby management dan session orchestration dari horizOn memastikan state pemain dan match properties tersinkronisasi secara andal dengan latensi di bawah 50ms (sub-50ms latency), meminimalkan delay replikasi yang menyebabkan terjadinya ghost build.
Best Practice Praktis untuk Perbaikan Desync Network Replication Multiplayer
Saat mengoptimalkan netcode Anda untuk objek yang dapat dihancurkan, ikuti panduan berikut agar world state Anda tetap sinkron:
- Dekopling Visual Asset dari Actor Lifecycle: Hindari bergantung pada eksekusi instan
AActor::Destroy()untuk feedback visual. Gunakan boolean replication flag sepertibIsDeaddan picu local particle system secara instan. Langkah ini memungkinkan Anda menonaktifkan collision pada client tanpa harus menunggu rutinitas cleanup dari server. - Prioritaskan Channel Destruction dibanding Property Update: Atur
bOnlyRelevantToOwneratau tingkatkanNetPrioritypada objek destructible untuk memastikan pembaruan penghancuran diprioritaskan oleh network driver. Hal ini memastikan pembaruan tersebut tidak tertunda di belakang replikasi property ambient standar. - Atur Time Window untuk Active Prediction Timeout: Jangan pernah membiarkan client-side prediction berjalan tanpa batas. Selalu terapkan safety timeout (biasanya 1,5x hingga 2x dari RTT maksimum yang dapat diterima, ditambah margin untuk variasi tick server) untuk memaksa client melakukan rollback jika server menolak aksi tersebut. Hal ini mencegah actor tetap tersembunyi secara permanen jika paket drop.
- Atur NetUpdateFrequency: Jaga agar rate update struktur destructible Anda tetap rendah (misalnya, 10-15Hz) dalam kondisi normal. Tingkatkan frekuensi update secara dinamis menjadi 33Hz hanya ketika struktur menerima damage, guna mengurangi bandwidth idle sembari tetap menjaga responsivitas. Ini menyeimbangkan penggunaan bandwidth jaringan selama interaksi pemain yang intens.
- Optimalkan Pipeline Validasi Server: Pastikan validasi damage di sisi server berjalan cepat dan ringan. Jika server membutuhkan waktu lebih dari 100ms untuk memvalidasi hit, prediction buffer di sisi client kemungkinan besar akan mengalami timeout dan memicu jitter visual. Sederhanakan kode verifikasi Anda untuk meminimalkan delay pemrosesan.
Ringkasan dan Langkah Selanjutnya
Mengatasi desync pada replikasi membutuhkan pemahaman mendalam tentang network pipeline dari engine Anda. Dengan menekan update property server pada actor yang diprediksi hancur oleh client, Anda dapat menghilangkan ghost build dan memberikan pengalaman bermain yang mulus serta responsif bagi pemain.
Siap menskalakan multiplayer backend Anda dan mengurangi masalah sinkronisasi? Coba horizOn secara gratis atau pelajari API docs untuk mengetahui cara mengimplementasikan manajemen sesi berlatensi rendah pada proyek Anda berikutnya.
Source: Ghost builds appear shortly after breaking wooden player build structures