Retour au Blog

Réapparition de Ghost Actor : Résoudre la désynchronisation de Network Replication Multiplayer pour les structures destructibles en grille

Publié le 21 juin 2026
Réapparition de Ghost Actor : Résoudre la désynchronisation de Network Replication Multiplayer pour les structures destructibles en grille

En bref

Cet article analyse l'origine des anomalies visuelles de « ghost build » dans les jeux multiplayer et propose des solutions techniques concrètes. En examinant les conflits entre la client-side prediction et la network replication, il met en lumière les désynchronisations causées par la latence et les pertes de paquets. Une implémentation C++ basée sur un buffer de prédiction est présentée pour stabiliser l'affichage local en attendant la confirmation du serveur. Enfin, il aborde l'intérêt d'utiliser des architectures backend optimisées comme horizOn pour simplifier la synchronisation en temps réel.

Réapparition de Ghost Actor : Résoudre la désynchronisation de Network Replication Multiplayer pour les structures destructibles en grille

Votre joueur brandit un outil de collecte, un mur en bois se brise instantanément sur son écran, mais 80 millisecondes plus tard, il réapparaît avec toute sa vie avant de disparaître définitivement 400 millisecondes après. Cette anomalie visuelle, communément appelée « ghost build », est une manifestation classique d'un décalage de client-side prediction et d'une replication désordonnée (out-of-order replication). Dans les environnements multiplayer rapides, ces brefs rollbacks d'état rompent l'immersion du joueur et créent un encombrement visuel. Pour y remédier, les développeurs doivent implémenter un fix de désynchronisation de network replication multiplayer robuste qui réconcilie le feedback local immédiat et la validation par un serveur autoritaire.

Anatomie d'un Ghost Build : Prediction vs Replication

Lors du développement de gameplay réseau, les créateurs doivent équilibrer la réactivité et l'autorité du serveur. Pour que les mouvements et la destruction semblent instantanés, les clients prédisent les résultats des actions avant de recevoir la confirmation du serveur. Par exemple, lorsqu'un joueur attaque une structure destructible, la logique de jeu client-side lance immédiatement le calcul des dégâts, désactive les collisions et déclenche des effets de particules.

Sous le capot, cela crée une divergence d'une fraction de seconde où la simulation du client devance celle du serveur. Dans un modèle de netcode standard, cette divergence est résolue une fois que le serveur traite l'RPC d'input du client et renvoie le nouvel état répliqué. Cependant, si un paquet est retardé, ou si le tick rate du serveur (généralement 20 Hz à 30 Hz) est à la traîne par rapport au frame rate du client (60 Hz à 120 Hz), une race condition se produit. La client-side prediction supprime l'actor, mais la mise à jour de replication suivante du serveur contient toujours l'ancien état de l'actor (en vie avec ses points de vie).

Cette race condition spécifique est particulièrement visible sur les structures en bois. Par rapport à la pierre ou au métal, le bois a un seuil de points de vie plus bas (par exemple, 90 HP contre 300 HP), ce qui signifie qu'il est détruit en un seul coup. Cela rend la fenêtre de temps entre l'action du joueur et l'acquittement du serveur extrêmement étroite. Tout retard de replication oblige le network driver du client à réconcilier l'état, ce qui reconstruit l'actor car le serveur le signale toujours comme étant en vie.

L'impact du Packet Loss et des Tick Rates

En cas de packet loss, la destruction prédite par le client se retrouve dans un état d'incertitude. Si un client transmet un paquet de dégâts qui est perdu, le serveur ne le traite jamais, mais le client suppose que les dégâts ont été appliqués. Le client continue alors sa simulation en partant du principe erroné que l'actor a disparu. Lorsque le serveur transmet la mise à jour d'état suivante, l'écart devient apparent, forçant le client à faire réapparaître l'actor dans le monde. Ce processus de réconciliation crée un sursaut visuel (pop) désagréable, en particulier avec un taux de packet loss de 1,5 % à 3 % où ces pertes surviennent fréquemment.

Sous le capot du cycle de vie des Actors et du Channel Teardown

Unreal Engine et les moteurs multiplayer modernes similaires synchronisent la présence des actors à l'aide de canaux de connexion réseau dédiés. À chaque actor répliqué est attribué un actor channel. Lorsqu'un actor est détruit sur le serveur, ce dernier ferme ce canal en envoyant un message de contrôle de fermeture de canal (NetGUID retirement) au client.

Le problème critique réside dans le fait que la replication des propriétés et la fermeture du canal ne partagent pas le même chemin de replication. Les mises à jour de propriétés (comme les modifications de la variable Health d'une structure) sont sérialisées et envoyées dans le cadre du bundle de replication standard de l'actor. Si le serveur traite un événement de dégâts mais n'a pas encore appliqué de Garbage Collection à l'actor, il peut sérialiser une dernière mise à jour de propriété avant que l'actor ne soit complètement marqué pour destruction. Si le paquet UDP contenant la mise à jour de propriété arrive avant celui contenant la fermeture du canal, le client met à jour la santé de l'actor et écrase la destruction locale prédite.

Ce comportement est étroitement lié à d'autres problèmes de synchronisation de netcode, comme ceux abordés dans notre guide sur les désynchronisations multiplayer : corriger le problème de replication d'RPC Unreal Engine qui corrompt vos états. Dans ce guide, nous analysons comment les écarts d'ordre d'exécution entre les RPC et les propriétés cassent les états du monde. De même, lors de la gestion du positionnement des joueurs, les développeurs rencontrent souvent des écarts similaires, comme détaillé dans notre guide sur comment corriger les désynchronisations de localisation de joueur dans le multiplayer Uefn et Unreal Engine.

Lorsque the client traite le paquet de replication désordonné, il constate que l'actor est en vie sur le serveur et force son retour dans le pool actif. Le client est alors contraint d'attendre que le paquet de fermeture de canal arrive enfin — souvent 0,4 seconde plus tard — pour supprimer définitivement l'actor.

Sous le capot, les paquets de replication sont limités par la Maximum Transmission Unit (MTU), qui est généralement de 1400 octets. Si le taux de connexion de votre jeu est limité (par exemple, avec MaxClientRate défini sur 15000 octets/sec), les mises à jour sont mises en file d'attente et réparties sur plusieurs paquets UDP. Comme le message de contrôle pour la fermeture du canal est envoyé de manière fiable (reliable), il doit faire l'objet d'un accusé de réception, tandis que les mises à jour de propriétés sont souvent envoyées de manière non fiable (unreliable). En cas de congestion réseau ou de packet loss, le message fiable de fermeture de canal peut être retardé derrière des paquets de propriétés non fiables plus anciens, provoquant une désynchronisation où le client reconstruit l'actor.

Implémentation d'un Predictive State Buffer en C++

Pour corriger les ghost builds, nous devons intercepter sur le client les mises à jour de replication entrantes pour les actors dont la destruction a été prédite. En implémentant un buffer de client-side prediction, nous pouvons supprimer la réconciliation des propriétés pendant une fenêtre de temps spécifique (par exemple, 500 ms), donnant au paquet de fermeture de canal du serveur le temps nécessaire pour arriver. Vous trouverez ci-dessous une implémentation C++ complète et fonctionnelle d'un actor destructible prédictif. Elle surcharge (override) le comportement de replication et utilise un timestamp local pour déterminer si la replication doit être suspendue.

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

Voici le fichier d'implémentation correspondant, montrant comment filtrer les états serveurs entrants :

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

En utilisant ce buffer prédictif, nous empêchons le callback OnRep_Health entrant de réinitialiser la visibilité visuelle de l'actor. Cela maintient l'actor caché et sans collision côté client jusqu'à l'arrivée du paquet de fermeture de canal. Si le serveur ne valide pas la destruction (par exemple, en raison d'un écart de validation d'Anti-Cheat), le timeout force un rollback, garantissant ainsi que la simulation ne se désynchronise pas de manière permanente.

Gestion des Rollbacks et du rejet de validation

Une composante essentielle de ce fix de replication est la gestion des cas où le serveur rejette l'action du joueur. Si la logique de validation du serveur détermine que le joueur ne pouvait pas toucher la structure, elle rejette les dégâts. Dans ce scénario, le client doit effectuer un rollback de la destruction prédite pour éviter une désynchronisation permanente ; c'est pourquoi le timeout rétablit la collision et la visibilité de l'actor si aucune confirmation du serveur n'arrive dans les 500 ms.

Le fardeau de l'implémentation manuelle vs les Backends dédiés

Bien que la solution C++ ci-dessus résolve le ghosting d'actors individuels, l'appliquer à l'ensemble de l'environnement d'un jeu s'avère complexe. Les développeurs de jeux doivent écrire manuellement du code de prediction et de rollback pour chaque type d'objet destructible, suivre les buffers de prediction actifs, optimiser les tick rates des actors et gérer les priorités de network replication. Pour une équipe indépendante, le développement et le test de ces cas particuliers (edge cases) peuvent facilement nécessiter 4 à 6 semaines d'ingénierie réseau dédiée.

Développer cela vous-même requiert la mise en place de load balancers, de sharding de base de données et de serveurs WebSockets/UDP complexes. Avec horizOn, ces services backend sont préconfigurés, vous permettant de lancer votre jeu au lieu de gérer l'infrastructure réseau. La gestion des lobbies en temps réel et l'orchestration des sessions de horizOn garantissent que les états des joueurs et les propriétés des matchs se synchronisent de manière fiable avec une latence inférieure à 50 ms, atténuant les retards de replication à l'origine des ghost builds.

Bonnes pratiques concrètes pour un correctif de désynchronisation de Network Replication Multiplayer

Lors de l'optimisation de votre netcode pour les objets destructibles, suivez ces directives pour maintenir l'état de votre monde synchronisé :

  1. Découpler les assets visuels du cycle de vie des Actors : Évitez de vous appuyer sur l'exécution immédiate de AActor::Destroy() pour le retour visuel. Définissez un flag de replication booléen comme bIsDead et déclenchez immédiatement les systèmes de particules locaux. Cela vous permet de désactiver les collisions sur le client sans attendre les routines de nettoyage du serveur.
  2. Prioriser la destruction de canal par rapport aux mises à jour de propriétés : Définissez bOnlyRelevantToOwner ou augmentez la NetPriority sur les objets destructibles pour garantir que les mises à jour de destruction soient prioritaires pour le network driver. Cela évite qu'elles ne soient retardées derrière la replication standard des propriétés ambiantes.
  3. Définir une fenêtre de Timeout de Prediction active : Ne laissez jamais une client-side prediction s'exécuter indéfiniment. Implémentez toujours un timeout de sécurité (généralement 1,5 à 2 fois votre RTT maximum acceptable, plus une marge pour les variations de tick du serveur) afin de forcer un rollback côté client si le serveur rejette une action. Cela évite que l'actor ne reste masqué indéfiniment si un paquet est perdu.
  4. Ajuster la NetUpdateFrequency : Maintenez les taux de rafraîchissement des structures destructibles bas (par exemple, 10-15 Hz) en conditions normales. Augmentez dynamiquement la fréquence de mise à jour à 33 Hz uniquement lorsqu'elles subissent des dégâts, réduisant ainsi la bande passante au repos tout en préservant la réactivité. Cela équilibre l'utilisation du réseau lors des phases d'action intenses des joueurs.
  5. Optimiser les pipelines de validation serveur : Veillez à ce que la validation des dégâts côté serveur soit rapide et légère. Si le serveur met plus de 100 ms à valider un impact, le buffer de prediction du client expirera probablement, ce qui provoquera un jitter (saccade) visible. Rationalisez le code de vérification pour minimiser les délais de traitement.

Résumé et prochaines étapes

Résoudre les désynchronisations de replication nécessite une compréhension approfondie du pipeline réseau de votre moteur de jeu. En suspendant les mises à jour des propriétés serveur sur les actors dont la destruction a été prédite par le client, vous pouvez élimindre les ghost builds et offrir une expérience fluide et réactive aux joueurs.

Prêt à faire passer votre backend multiplayer à l'échelle supérieure et à réduire les problèmes de synchronisation ? Essayez horizOn gratuitement ou consultez la documentation API pour apprendre à implémenter une gestion de session à faible latence dans votre prochain projet.


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