Retour au Blog

Le Multiplayer Sync Bug d'Unreal Engine qui ruine vos World States (et comment le corriger)

Publié le 23 février 2026
Le Multiplayer Sync Bug d'Unreal Engine qui ruine vos World States (et comment le corriger)

Vous passez des mois à construire une transformation de monde massive et cinématique. En single-player, elle s'exécute sans faille. La vieille maison hantée s'enfonce sous le terrain, et la nouvelle version immaculée surgit des profondeurs parfaitement au bon moment. Mais dès qu'un second joueur se connecte au serveur, votre chef-d'œuvre se transforme en un cauchemar injouable d'objets qui se chevauchent. Les maisons fusionnent. La Collision se brise. Vos joueurs sont coincés dans le purgatoire de la géométrie.

Chaque développeur indie de multiplayer finit par se heurter à un mur où la logique visuelle côté client entre violemment en collision avec la réalité Server-Authoritative. Si vous essayez de déplacer des centaines d'assets à l'aide d'un Cinematic Sequence Device ou d'animations de timeline déclenchées par des événements locaux, vous vous exposez directement à un Unreal Engine Multiplayer Sync Bug.

Dans ce tutoriel, nous allons analyser exactement pourquoi le déplacement de quantités massives d'actors provoque des desyncs catastrophiques, pourquoi les Cinematic Sequences échouent pour les late-joiners, et comment architecturer un World State Manager Server-Authoritative robuste en utilisant le C++ et les Data Layers d'Unreal Engine 5.

L'anatomie de la Desync : Pourquoi vos maisons fusionnent

Pour résoudre le problème, vous devez d'abord comprendre les calculs qui expliquent pourquoi le système de Replication d'Unreal Engine sature lors de votre séquence de transformation.

Supposons que votre séquence déplace environ 450 assets individuels (murs, props, éclairage) pour échanger la « Maison 1 » avec la « Maison 2 ». Lorsque vous déplacez un actor répliqué, Unreal Engine utilise la structure FRepMovement pour synchroniser sa position, sa rotation et sa vélocité sur le réseau.

Une mise à jour de mouvement compressée standard coûte environ 40 à 50 octets par actor.

Si 450 actors se déplacent simultanément pendant une séquence cinématique de 5 secondes, avec une mise à jour modeste de 30 fois par seconde, le calcul est le suivant : 450 actors × 50 octets × 30 mises à jour/sec = 675 000 octets par seconde (675 Ko/s).

Le MaxClientRate par défaut d'Unreal Engine (la bande passante maximale que le serveur est autorisé à envoyer à un seul client) est généralement plafonné entre 15 000 et 100 000 octets par seconde.

Votre séquence exige près de 7 fois la bande passante disponible. Le canal réseau est instantanément saturé. Le serveur commence à restreindre agressivement les mises à jour, à abandonner des paquets et à prioriser d'autres actors en fonction de la NetPriority. Résultat : la moitié des assets de votre Maison 1 s'arrêtent à mi-chemin sous terre, et la moitié des assets de votre Maison 2 n'atteignent jamais la surface. Vous vous retrouvez avec un désordre fusionné et désynchronisé de façon permanente.

De plus, si vous déclenchez cette séquence localement via un événement côté client (comme un joueur marchant dans une trigger box), un joueur qui rejoint le serveur 10 minutes plus tard n'exécutera jamais la séquence. Il verra l'état par défaut de la map, tandis que le premier joueur verra l'état transformé.

Étape 1 : Abandonnez la manipulation de Transform pour les Data Layers

Déplacer 450 actors est une approche de force brute qui gaspille des cycles CPU et de la bande passante réseau. Dans Unreal Engine 5, la bonne approche architecturale pour les changements massifs de monde est l'utilisation des Data Layers (l'évolution du Level Streaming).

Au lieu de déplacer la « Maison 1 » sous terre, vous assignez tous les assets de la Maison 1 à une House1_DataLayer et tous les assets de la Maison 2 à une House2_DataLayer. Lorsque la timeline change, vous déchargez simplement la première couche et chargez la seconde.

Cela élimine complètement le goulot d'étranglement de la bande passante. Au lieu de streamer 675 Ko/s de données de mouvement continu, le serveur envoie une seule petite mise à jour d'état : « La Data Layer 2 est maintenant active. » Le moteur local du client gère le chargement de manière transparente depuis le disque.

Étape 2 : Architecturer le World State Manager Server-Authoritative

Pour garantir que chaque joueur — y compris ceux qui arrivent en retard — voit exactement le même World State, nous avons besoin d'une source unique de vérité. Nous allons créer un actor WorldStateManager en C++ qui utilise une variable RepNotify pour suivre l'ère actuelle de la maison.

Le fichier Header (WorldStateManager.h)

Nous avons besoin d'un Enum pour définir nos états, et d'une variable Replicated avec une condition ReplicatedUsing.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Info.h"
#include "WorldDataLayers/WorldDataLayers.h"
#include "WorldStateManager.generated."

UENUM(BlueprintType)
enum class EWorldEraState : uint8
{
    Past_House1 UMETA(DisplayName = "Past (House 1)"),
    Future_House2 UMETA(DisplayName = "Future (House 2)")
};

UCLASS()
class MYGAME_API AWorldStateManager : public AInfo
{
    GENERATED_BODY()

public:
    AWorldStateManager();

    // The server-side function to trigger the transformation
    UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = "World State")
    void AdvanceWorldEra();

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

    // The replicated variable tracking our current state
    UPROPERTY(ReplicatedUsing = OnRep_CurrentEra, Transient)
    EWorldEraState CurrentEra;

    // The RepNotify function that fires on clients when CurrentEra changes
    UFUNCTION()
    void OnRep_CurrentEra();

    // Helper to toggle Data Layers
    void UpdateDataLayers(EWorldEraState NewState);
};

Le fichier d'implémentation (WorldStateManager.cpp)

C'est ici que la magie opère. Notez comment nous utilisons DOREPLIFETIME pour enregistrer la variable, et comment la fonction OnRep garantit que l'état visuel correspond à l'état logique.

#include "WorldStateManager.h"
#include "Net/UnrealNetwork.h"
#include "Engine/World.h"
#include "WorldPartition/DataLayer/DataLayerSubsystem.h"

AWorldStateManager::AWorldStateManager()
{
    bReplicates = true;
    bAlwaysRelevant = true; // Ensure all players always receive updates for this actor
    CurrentEra = EWorldEraState::Past_House1;
}

void AWorldStateManager::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    
    // Replicate to all clients
    DOREPLIFETIME(AWorldStateManager, CurrentEra);
}

void AWorldStateManager::BeginPlay()
{
    Super::BeginPlay();
    
    // Ensure the initial state is set correctly on the server
    if (HasAuthority())
    { 
        UpdateDataLayers(CurrentEra);
    }
}

void AWorldStateManager::AdvanceWorldEra()
{
    // Only the server can change the era
    if (!HasAuthority()) return;

    CurrentEra = EWorldEraState::Future_House2;
    
    // The server updates its own local Data Layers immediately
    UpdateDataLayers(CurrentEra);
}

// This fires automatically on clients when the server changes CurrentEra
void AWorldStateManager::OnRep_CurrentEra()
{
    UpdateDataLayers(CurrentEra);
}

void AWorldStateManager::UpdateDataLayers(EWorldEraState NewState)
{
    UWorld* World = GetWorld();
    if (!World) return;

    UDataLayerSubsystem* DataLayerSubsystem = World->GetSubsystem<UDataLayerSubsystem>();
    if (!DataLayerSubsystem) return;

    // Pseudocode for Data Layer toggling - replace with your specific Data Layer Asset references
    if (NewState == EWorldEraState::Past_House1)
    {
        // Load House 1, Unload House 2
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House1Layer, EDataLayerRuntimeState::Activated);
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House2Layer, EDataLayerRuntimeState::Unloaded);
    }
    else if (NewState == EWorldEraState::Future_House2)
    {
        // Load House 2, Unload House 1
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House2Layer, EDataLayerRuntimeState::Activated);
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House1Layer, EDataLayerRuntimeState::Unloaded);
    }
}

Étape 3 : Résoudre le problème des Late-Joiners

La plus grande erreur que font les développeurs en essayant de corriger un Unreal Engine Multiplayer Sync Bug est d'utiliser des Multicast RPCs (Remote Procedure Calls) pour déclencher des événements mondiaux.

Si vous utilisez un Multicast RPC pour dire Multicast_PlayHouseTransformation(), il ne s'exécutera que sur les clients qui sont actuellement connectés au serveur à cette milliseconde précise. Si un joueur crash et se reconnecte 30 secondes plus tard, il a manqué le RPC. Il chargera la map et verra la Maison 1, alors que tous les autres voient la Maison 2.

En utilisant une UPROPERTY(ReplicatedUsing = OnRep_CurrentEra), nous résolvons automatiquement le problème des late-joiners. Lorsqu'un nouveau joueur se connecte, le serveur lui envoie la valeur actuelle de CurrentEra. Comme la valeur qu'il reçoit (Future_House2) diffère de sa valeur initialisée par défaut (Past_House1), Unreal Engine déclenche automatiquement OnRep_CurrentEra() pour ce client spécifique au moment où il charge. Il charge instantanément la bonne Data Layer. Aucune logique de connexion personnalisée n'est requise.

Si vous construisez des prototypes plus petits basés sur des sessions, consultez notre guide sur How To Architect A Local Co Op Shooter Prototype In Unreal Engine Step By Step.

Persistance des World States au-delà de la session de jeu

La solution C++ ci-dessus est parfaite pour une instance de serveur unique en cours d'exécution. Mais que se passe-t-il si votre serveur crash ? Ou si vous construisez un jeu de survival horror persistant où l'« Ère » doit rester sauvegardée sur des semaines de gameplay, même lorsque tous les joueurs se déconnectent et que le serveur s'arrête ?

C'est là que le fait de compter uniquement sur la Replication en mémoire d'Unreal Engine montre ses limites. Pour persister des World States globaux, vous avez besoin d'une base de données backend.

Construire cela vous-même nécessite de configurer des bases de données PostgreSQL, d'écrire des REST APIs pour gérer la sérialisation d'état, de gérer l'authentification du serveur et de configurer une infrastructure d'auto-scaling — facilement 4 à 6 semaines de plomberie backend fastidieuse.

Avec horizOn, ces services backend sont pré-configurés. Vous pouvez pousser vos changements de World State directement vers une base de données Game State gérée via notre SDK. Lorsque votre serveur dédié démarre, il interroge simplement le backend horizOn, récupère {"CurrentEra": "Future_House2"}, initialise le WorldStateManager, et vos joueurs continuent exactement là où ils s'étaient arrêtés. Vous pouvez vous concentrer sur la conception de votre jeu d'horreur au lieu d'écrire des migrations de base de données.

Si votre jeu nécessite une communication bidirectionnelle instantanée avec un backend (par exemple, pour déclencher des événements live-ops), vous devriez également lire notre analyse sur comment Ditch Http Polling An Unreal Engine Websockets Tutorial For Real Time Backends.

5 Bonnes Pratiques pour la Multiplayer State Synchronization

Pour vous assurer de ne plus jamais être confronté à un Unreal Engine Multiplayer Sync Bug catastrophique, intégrez ces règles dans votre architecture :

  1. N'utilisez jamais de Sequences pour le Logical State : Les Cinematic Sequence Devices et les Timelines doivent être strictement utilisés pour l'aspect visuel (VFX, camera shakes, UI locale). Ne comptez jamais sur la fin d'une timeline pour définir une variable qui impacte le gameplay.
  2. RPCs pour les événements, RepNotifies pour l'état : Utilisez des Multicast RPCs pour les événements transitoires et temporaires (une grenade qui explose, un son). Utilisez des variables Replicated avec RepNotifies pour les états persistants et durables (une porte ouverte, une maison transformée, un générateur alimenté).
  3. Respectez la limite de bande passante : Surveillez votre network profiler (Stat Net). Si vous répliquez des transforms pour plus de 50-100 actors simultanément, vous saturez probablement le canal. Utilisez la Network Dormancy (ENetDormancy::DORM_Initial) pour les props qui bougent rarement.
  4. Réglez bAlwaysRelevant scrupuleusement : Pour les gestionnaires d'état globaux (comme notre AWorldStateManager), assurez-vous que bAlwaysRelevant = true. Si cet actor tombe en dehors de la network cull distance d'un joueur, il cessera de recevoir des mises à jour, entraînant des desyncs localisées.
  5. La Server Authority est absolue : Les clients ne devraient envoyer que des « Requests » au serveur (ex: Server_RequestInteract()). Le serveur valide la requête, met à jour la variable Replicated, et laisse le système de Replication propager les changements visuels à tous les clients.

Arrêtez de lutter contre le moteur

Le développement de jeux multiplayer est notoirement difficile, mais 90 % des sync bugs proviennent du fait d'essayer de forcer des outils côté client à faire un travail côté serveur. En passant de la manipulation de transform par force brute aux Data Layers, et en utilisant des RepNotifies au lieu de triggers locaux, vous alignez votre jeu sur l'architecture réseau prévue par Unreal Engine.

Prêt à scaler votre backend multiplayer et à persister vos World States sans les maux de tête liés à l'infrastructure ? Essayez horizOn gratuitement ou consultez la API docs.


Source : Houses Merged Weirdly HELPPPP