ظهور الـ Ghost Actor مجدداً: حل مشكلة الـ Multiplayer Network Replication Desync للهياكل الشبكية القابلة للتدمير (Destructible Grid Structures)
باختصار
يناقش هذا المقال كيفية حل مشكلة الـ Ghost Actor Reappearance والـ Ghost Builds في ألعاب الـ Multiplayer الناتجة عن عدم تطابق الـ client-side prediction وتأخر الـ replication. ويشرح المقال تفاصيل الـ Actor Lifecycle والـ Channel Teardown، مقدماً حلاً برمجياً عملياً بلغة C++ باستخدام Predictive State Buffer لتصفية تحديثات الخصائص غير المرتبة. وأخيراً، يستعرض المقال أفضل الممارسات البرمجية لتحسين الـ Netcode ويوضح كيف تسهم الحلول الجاهزة مثل [horizOn](https://horizon.pm) في تقليل أعباء المزامنة وإدارة السيرفرات.
ظهور الـ Ghost Actor مجدداً: حل مشكلة الـ Multiplayer Network Replication Desync للهياكل الشبكية القابلة للتدمير (Destructible Grid Structures)
يقوم لاعبك بأرجحة أداة الحصاد، فيتحطم جدار خشبي فوراً على شاشته، ولكن بعد مرور 80 مللي ثانية (milliseconds) يظهر الجدار فجأة مجدداً بنقاط صحة كاملة (full health) قبل أن يختفي نهائياً بعد 400 مللي ثانية. هذا الخلل البصري، المعروف باسم "ghost build"، هو تجسيد كلاسيكي لعدم التطابق في الـ client-side prediction والـ out-of-order replication. في بيئات الـ multiplayer سريعة الوتيرة، تؤدي هذه الارتدادات القصيرة في الحالة (state rollbacks) إلى إفساد تجربة اندماج اللاعب (player immersion) وإحداث تشويش بصري. لمعالجة ذلك، يتعين على المطورين تطبيق حل قوي لمشكلة الـ multiplayer network replication desync يربط بين الاستجابة المحلية الفورية (immediate local feedback) والتحقق المعتمد من السيرفر (authoritative server validation).
تشريح الـ Ghost Build: الـ Prediction مقابل الـ Replication
عند بناء أسلوب اللعب عبر الشبكة (network gameplay)، يجب على المطورين الموازنة بين سرعة الاستجابة وصلاحية السيرفر (server authority). ولجعل الحركة والتدمير يبدوان فوريين، تقوم الـ clients بالتنبؤ بنتائج الإجراءات (predict the outcomes) قبل تلقي تأكيد السيرفر. على سبيل المثال، عندما يهاجم لاعب هيكلاً قابلاً للتدمير، تقوم منطق اللعبة (game logic) على الـ client-side فوراً بحساب الضرر (damage calculation)، وتعطيل الـ collision، وتشغيل تأثيرات الجزيئات (particle effects).
تحت الغطاء (Under the hood)، يخلق هذا تباعداً في جزء من الثانية حيث تكون محاكاة الـ client متقدمة على السيرفر. في نموذج الـ netcode القياسي، يتم حل هذا التباعد بمجرد أن يعالج السيرفر الـ input RPC الخاص بالـ client ويقوم بعمل replicate للحالة الجديدة مرة أخرى. ومع ذلك، إذا تأخرت حزمة بيانات (packet)، أو إذا كان الـ server tick rate (عادةً من 20Hz إلى 30Hz) يتأخر عن الـ client frame rate (من 60Hz إلى 120Hz)، تحدث حالة تسابق (race condition). يقوم الـ client-side prediction بإزالة الـ actor، ولكن تحديث الـ replication التالي من السيرفر لا يزال يحتوي على الحالة القديمة للـ actor (حي بنقاط صحته).
تكون حالة التسابق (race condition) هذه واضحة جداً على الهياكل الخشبية. فمقارنة بالحجر أو المعدن، يمتلك الخشب حداً أدنى من نقاط الصحة (مثلاً 90 HP مقابل 300 HP)، مما يعني أنه يُدمر بضربة واحدة. هذا يجعل النافذة الزمنية بين إجراء اللاعب واستجابة السيرفر ضيقة للغاية. أي تأخير في الـ replication يجبر الـ network driver للـ client على تسوية الحالة (reconcile the state)، مما يعيد بناء الـ actor لأن السيرفر لا يزال يبلّغ بأنه حي.
تأثير الـ Packet Loss والـ Tick Rates
عند حدوث packet loss، يظل تدمير الـ client المتوقع معلقاً في حالة غير مستقرة. فإذا أرسل الـ client حزمة ضرر (damage packet) وتم إسقاطها (dropped)، فلن يعالجها السيرفر أبداً، لكن الـ client يفترض أنه تم تطبيق الضرر، ويواصل المحاكاة بناءً على الافتراض الخاطئ بأن الـ actor قد اختفى. وعندما يرسل السيرفر تحديث الحالة التالي، يصبح عدم التطابق واضحاً، مما يجبر الـ client على إعادة إنشاء (spawn) الـ actor في اللعبة مجدداً. عملية التسوية (reconciliation) هذه تخلق قفزة بصرية مزعجة (visual pop)، خاصة في ظل معدل packet loss يتراوح بين 1.5% إلى 3% حيث تحدث حالات الإسقاط هذه بشكل متكرر.
نظرة عميقة على الـ Actor Lifecycle والـ Channel Teardown
تقوم Unreal Engine ومحركات الـ multiplayer الحديثة المشابهة بمزامنة وجود الـ actor باستخدام قنوات اتصال شبكية مخصصة (dedicated network connection channels). حيث يتم تخصيص actor channel لكل replicated actor. وعندما يتم تدمير الـ actor على السيرفر، يغلق السيرفر هذه القناة، ويرسل رسالة تحكم بإغلاق القناة (NetGUID retirement) إلى الـ client.
المشكلة الحرجة تكمن في أن الـ property replication وإغلاق القناة لا يتشاركان نفس مسار الـ replication. فتحديثات الخصائص (property updates) - مثل تحديثات متغير الـ Health للهيكل - يتم تحويلها إلى تسلسل (serialized) وإرسالها كجزء من حزمة الـ replication العادية للـ actor. وإذا عالج السيرفر حدث ضرر (damage event) ولكنه لم يقم بعد بعمل garbage collected للـ actor، فقد يرسل تحديثاً نهائياً للخصائص بشكل تسلسلي قبل وسم الـ actor تماماً للتدمير. وإذا وصلت حزمة UDP التي تحتوي على تحديث الخصائص قبل الحزمة التي تحتوي على إغلاق القناة، فسيقوم الـ client بتحديث نقاط صحة الـ actor وتجاوز التدمير المحلي المتوقع.
يرتبط هذا السلوك ارتباطاً وثيقاً بمشكلات مزامنة الـ netcode الأخرى، مثل تلك التي تمت مناقشتها في دليلنا حول multiplayer desyncs fixing the Unreal Engine RPC replication issue breaking your states. في ذلك الدليل، نقوم بتحليل كيف يؤدي عدم تطابق ترتيب التنفيذ بين الـ RPCs والخصائص إلى إفساد حالات اللعبة (world states). وبالمثل، عند التعامل مع تحديد مواقع اللاعبين، غالباً ما يواجه المطورون تعارضات مشابهة، كما هو مفصل في دليلنا حول how to fix player location desync in Uefn and Unreal Engine multiplayer.
عندما يعالج الـ client حزمة الـ replication الواردة خارج الترتيب (out-of-order)، فإنه يرى أن الـ actor لا يزال حياً على السيرفر ويجبر الـ actor على العودة إلى مجموعة العناصر النشطة (active pool). ويضطر الـ client إلى الانتظار حتى تصل حزمة إغلاق القناة في النهاية - غالباً بعد 0.4 ثانية - لحذف الـ actor بشكل نهائي.
تحت الغطاء، تكون حزم الـ replication محدودة بـ Maximum Transmission Unit (MTU)، والتي تبلغ عادةً 1400 بايت (bytes). وإذا تم تقييد معدل اتصال اللعبة (على سبيل المثال، مع ضبط MaxClientRate على 15000 بايت/ثانية)، فسيتم وضع التحديثات في قائمة الانتظار وتقسيمها عبر حزم UDP متعددة. وبما أن رسالة التحكم لإغلاق القناة يتم إرسالها بشكل موثوق (reliably)، فيجب تأكيد استلامها، في حين أن تحديثات الخصائص يتم إرسالها غالباً بشكل غير موثوق (unreliably). وإذا حدث ازدحام في الشبكة أو packet loss، فقد تتأخر رسالة إغلاق القناة الموثوقة خلف حزم الخصائص الأقدم وغير الموثوقة، مما يتسبب في عدم تطابق يؤدي إلى إعادة بناء الـ actor بواسطة الـ client.
تطبيق Predictive State Buffer في C++
لحل مشكلة الـ ghost builds، يجب علينا اعتراض تحديثات الـ replication الواردة على الـ client للـ actors الذين تم التنبؤ بتدميرهم. ومن خلال تطبيق client-side prediction buffer، يمكننا منع تسوية الخصائص (suppress property reconciliation) لفترة زمنية محددة (مثل 500 مللي ثانية)، مما يمنح حزمة إغلاق القناة من السيرفر وقتاً كافياً للوصول. وفيما يلي كود C++ كامل وعامل للـ predictive destructible actor. حيث يقوم بتجاوز سلوك الـ replication ويستخدم طابعاً زمنياً محلياً (local timestamp) لتحديد ما إذا كان ينبغي منع الـ replication.
// 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;
};
إليك ملف التطبيق المقابل، والذي يوضح كيفية تصفية حالات السيرفر الواردة (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();
}
}
}
من خلال استخدام هذا الـ predictive buffer، نمنع الـ callback الخاص بـ OnRep_Health الوارد من إعادة تعيين الرؤية البصرية (visual visibility) للـ actor. هذا يحافظ على إخفاء الـ actor على الـ client-side وخلوه من الـ collision حتى تصل حزمة إغلاق القناة (channel close packet). وإذا لم يوافق السيرفر على التدمير (على سبيل المثال، بسبب عدم تطابق في التحقق من الـ Anti-Cheat)، فإن انتهاء المهلة (timeout) يجبر على إجراء rollback، مما يضمن عدم حدوث desynchronization دائم للمحاكاة.
التعامل مع الـ Rollbacks ورفض التحقق (Validation Rejection)
أحد المكونات الحاسمة في حل الـ replication هذا هو معالجة الحالات التي يرفض فيها السيرفر إجراء اللاعب. فإذا حدد منطق التحقق (validation logic) الخاص بالسيرفر أن اللاعب لم يكن بإمكانه إصابة الهيكل، فإنه يرفض الضرر. وفي هذا السيناريو، يجب على الـ client التراجع (roll back) عن التدمير المتوقع لمنع حدوث desynchronization دائم، وهذا هو السبب في أن انتهاء المهلة (timeout) يستعيد الـ collision وظهور الـ actor إذا لم يصل أي تأكيد من السيرفر في غضون 500 مللي ثانية.
عبء التطبيق اليدوي مقابل الـ Dedicated Backends
على الرغم من أن حل C++ أعلاه يعالج مشكلة الـ ghosting للـ actors الفردية، إلا أن تطبيق هذا على بيئة اللعبة بأكملها يعد أمراً معقداً. يجب على مطوري الألعاب كتابة كود الـ prediction والـ rollback يدوياً لكل نوع من الكائنات القابلة للتدمير، وتتبع الـ prediction buffers النشطة، وتحسين الـ tick rates للـ actors، وإدارة أولويات الـ network replication. بالنسبة لفريق مستقل (indie team)، يمكن أن يستغرق بناء واختبار هذه الحالات الاستثنائية (edge cases) بسهولة ما بين 4 إلى 6 أسابيع من هندسة الشبكات المخصصة.
يتطلب بناء هذا بنفسك إعداد الـ load balancers، والـ database sharding، وخوادم WebSockets/UDP المعقدة. ومع horizOn، تأتي خدمات الـ backend هذه معدة مسبقاً، مما يتيح لك شحن لعبتك بدلاً من إدارة البنية التحتية للشبكة. تضمن إدارة اللوبي في الوقت الفعلي (real-time lobby management) وتنسيق الجلسات (session orchestration) من horizOn مزامنة حالات اللاعبين وخصائص المباراة بشكل موثوق بزمن انتقال (latency) يقل عن 50 مللي ثانية، مما يقلل من تأخيرات الـ replication التي تسبب الـ ghost builds.
أفضل الممارسات القابلة للتطبيق لحل مشكلة الـ Multiplayer Network Replication Desync
عند تحسين الـ netcode الخاص بك للكائنات القابلة للتدمير، اتبع هذه الإرشادات للحفاظ على مزامنة حالة العالم (world state):
- افصل الأصول البصرية (Visual Assets) عن الـ Actor Lifecycle: تجنب الاعتماد على التنفيذ الفوري لـ
AActor::Destroy()للحصول على التغذية الراجعة البصرية. قم بضبط علم replication من النوع boolean مثلbIsDeadوقم بتشغيل أنظمة الجزيئات (particle systems) المحلية فوراً. يتيح لك هذا تعطيل الـ collision على الـ client دون انتظار إجراءات التنظيف الخاصة بالسيرفر. - أَعطِ الأولوية لتدمير القناة (Channel Destruction) على تحديثات الخصائص (Property Updates): قم بضبط
bOnlyRelevantToOwnerأو زيادة الـNetPriorityعلى العناصر القابلة للتدمير لضمان إعطاء الأولوية لتحديثات التدمير بواسطة الـ network driver. يضمن هذا عدم تأخرها خلف الـ replication الافتراضي للخصائص المحيطة. - اضبط نافذة مهلة زمنية نشطة للتنبؤ (Prediction Timeout Window): لا تدع الـ client-side prediction يعمل إلى أجل غير مسمى أبداً. قم دائماً بتطبيق مهلة أمان (عادةً 1.5x إلى 2x من أقصى RTT مقبول لديك، بالإضافة إلى هامش لاختلاف الـ tick الخاص بالسيرفر) لفرض تراجع الـ client (client rollback) إذا رفض السيرفر الإجراء. هذا يمنع الـ actor من البقاء مخفياً بشكل دائم إذا تم إسقاط حزمة بيانات (packet).
- اضبط الـ NetUpdateFrequency: حافظ على انخفاض معدلات تحديث الهياكل القابلة للتدمير (مثلاً 10-15Hz) في الظروف العادية. وقم بزيادة تردد التحديث ديناميكياً إلى 33Hz فقط عندما تتلقى ضرراً، مما يقلل من استخدام الباندويث الخامل (idle bandwidth) مع الحفاظ على سرعة الاستجابة. هذا يوازن استخدام الشبكة أثناء تفاعلات اللاعبين النشطة.
- حسّن أنابيب التحقق الخاصة بالسيرفر (Server Validation Pipelines): تأكد من أن التحقق من الضرر على جانب السيرفر (server-side damage validation) سريع وخفيف الوزن. إذا استغرق السيرفر أكثر من 100 مللي ثانية للتحقق من الإصابة، فمن المحتمل أن تنتهي مهلة الـ prediction buffer للـ client، مما يؤدي إلى حدوث اهتزاز بصري مرئي (visible jitter). قم بتبسيط كود التحقق لتقليل تأخيرات المعالجة.
ملخص وخطوات تالية
يتطلب حل مشكلات الـ replication desyncs فهماً عميقاً لخط أنابيب الشبكة (network pipeline) الخاص بمحركك. فمن خلال منع تحديثات الخصائص الواردة من السيرفر على الـ actors الذين تم التنبؤ بتدميرهم على الـ client، يمكنك التخلص من الـ ghost builds وتقديم تجربة سلسة وسريعة الاستجابة للاعبين.
هل أنت مستعد لتوسيع نطاق الـ multiplayer backend الخاص بك وتقليل مشكلات المزامنة؟ جرب horizOn مجاناً أو اطلع على API docs لمعرفة كيفية تنفيذ إدارة جلسات منخفضة زمن الانتقال (low-latency session management) في مشروعك القادم.
المصدر: Ghost builds appear shortly after breaking wooden player build structures