Zurück zum Blog

Der Unreal Engine Multiplayer Sync Bug, der deine World States ruiniert (und wie man ihn behebt)

Veröffentlicht am 23. Februar 2026
Der Unreal Engine Multiplayer Sync Bug, der deine World States ruiniert (und wie man ihn behebt)

Du verbringst Monate damit, eine massive, cineastische Welt-Transformation zu bauen. Im Singleplayer funktioniert sie tadellos. Das alte Geisterhaus versinkt im Boden, und die neue, makellose Version steigt perfekt getimt aus der Tiefe empor. Doch in dem Moment, in dem sich ein zweiter Spieler mit dem Server verbindet, verwandelt sich dein Meisterwerk in einen unspielbaren, überlappenden Albtraum. Häuser verschmelzen. Die Collision bricht zusammen. Deine Spieler stecken im Geometrie-Fegefeuer fest.

Jeder Multiplayer-Indie-Entwickler stößt irgendwann an eine Wand, an der clientseitige visuelle Logik heftig mit der Server-Authoritative-Realität kollidiert. Wenn du versuchst, hunderte von Assets mit einem Cinematic Sequence Device oder Timeline-Animationen zu bewegen, die durch lokale Player-Events ausgelöst werden, bettelst du förmlich um einen Unreal Engine Multiplayer Sync Bug.

In diesem Tutorial werden wir genau analysieren, warum das Bewegen massiver Mengen von Actorn zu katastrophalen Desyncs führt, warum Cinematic Sequences bei Late-Joinern versagen und wie man einen kugelsicheren, Server-Authoritative World State Manager mit C++ und den Data Layers von Unreal Engine 5 entwirft.

Die Anatomie des Desyncs: Warum deine Häuser verschmelzen

Um das Problem zu lösen, musst du zuerst die Mathematik dahinter verstehen, warum das Replication-System der Unreal Engine bei deiner Transformationssequenz kapituliert.

Nehmen wir an, deine Sequenz bewegt etwa 450 einzelne Assets (Wände, Props, Beleuchtung), um "Haus 1" gegen "Haus 2" auszutauschen. Wenn du einen replicated Actor bewegst, verwendet die Unreal Engine das FRepMovement-Struct, um Location, Rotation und Velocity über das Netzwerk zu synchronisieren.

Ein standardmäßiges komprimiertes Movement-Update kostet etwa 40 bis 50 Bytes pro Actor.

Wenn 450 Actor gleichzeitig während einer 5-sekündigen Cinematic Sequence bewegt werden und mit bescheidenen 30 Updates pro Sekunde aktualisiert werden, sieht die Rechnung so aus: 450 Actor × 50 Bytes × 30 Updates/Sek = 675.000 Bytes pro Sekunde (675 KB/s).

Die standardmäßige MaxClientRate der Unreal Engine (die maximale Bandbreite, die der Server an einen einzelnen Client senden darf) ist normalerweise zwischen 15.000 und 100.000 Bytes pro Sekunde begrenzt.

Deine Sequenz benötigt fast das 7-fache der verfügbaren Bandbreite. Der Network Channel ist sofort gesättigt. Der Server beginnt aggressiv, Updates zu drosseln, Pakete zu verwerfen und andere Actor basierend auf der NetPriority zu priorisieren. Das Ergebnis: Die Hälfte deiner Haus-1-Assets bleibt auf halbem Weg im Boden stecken, und die Hälfte deiner Haus-2-Assets erreicht nie die Oberfläche. Zurück bleibt ein permanent verschmolzenes, desynchronisiertes Chaos.

Darüber hinaus: Wenn du diese Sequenz lokal über ein clientseitiges Event auslöst (z. B. ein Spieler betritt eine Trigger Box), wird ein Spieler, der dem Server 10 Minuten später beitritt, die Sequenz niemals ausführen. Er sieht den Standard-Map-State, während der erste Spieler den transformierten Zustand sieht.

Schritt 1: Ersetze Transform-Manipulation durch Data Layers

Das Bewegen von 450 Actorn ist ein Brute-Force-Ansatz, der CPU-Zyklen und Netzwerkbandbreite verschwendet. In Unreal Engine 5 ist der richtige architektonische Ansatz für massive Weltveränderungen Data Layers (die Weiterentwicklung von Level Streaming).

Anstatt "Haus 1" unter die Erde zu schieben, weist du alle Haus-1-Assets einem House1_DataLayer und alle Haus-2-Assets einem House2_DataLayer zu. Wenn sich die Timeline verschiebt, entlädst du einfach den ersten Layer und lädst den zweiten.

Dies eliminiert den Bandbreiten-Engpass vollständig. Anstatt 675 KB/s an kontinuierlichen Movement-Daten zu streamen, sendet der Server ein einziges, winziges State-Update: "Data Layer 2 ist jetzt aktiv." Die lokale Engine des Clients übernimmt das Laden nahtlos von der Festplatte.

Schritt 2: Architektur des Server-Authoritative State Managers

Um sicherzustellen, dass jeder Spieler – auch diejenigen, die später beitreten – genau denselben World State sieht, benötigen wir eine zentrale Source of Truth. Wir erstellen einen WorldStateManager-Actor in C++, der eine RepNotify-Variable verwendet, um die aktuelle Ära des Hauses zu verfolgen.

Die Header-Datei (WorldStateManager.h)

Wir benötigen ein Enum, um unsere Zustände zu definieren, und eine Replicated-Variable mit einer ReplicatedUsing-Bedingung.

#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);
};

Die Implementierungsdatei (WorldStateManager.cpp)

Hier passiert die Magie. Beachte, wie wir DOREPLIFETIME verwenden, um die Variable zu registrieren, und wie die OnRep-Funktion garantiert, dass der visuelle Zustand mit dem logischen Zustand übereinstimmt.

#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);
    }
}

Schritt 3: Das Late-Joiner-Problem lösen

Der größte Fehler, den Entwickler bei der Behebung eines Unreal Engine Multiplayer Sync Bugs machen, ist die Verwendung von Multicast RPCs (Remote Procedure Calls), um World-Events auszulösen.

Wenn du einen Multicast RPC wie Multicast_PlayHouseTransformation() verwendest, wird dieser nur auf Clients ausgeführt, die genau in dieser Millisekunde mit dem Server verbunden sind. Wenn ein Spieler einen Crash hat und sich 30 Sekunden später neu verbindet, hat er den RPC verpasst. Er lädt die Map und sieht Haus 1, während alle anderen Haus 2 sehen.

Durch die Verwendung einer UPROPERTY(ReplicatedUsing = OnRep_CurrentEra) lösen wir das Late-Joiner-Problem automatisch. Wenn ein neuer Spieler beitritt, sendet ihm der Server den aktuellen Wert von CurrentEra. Da sich der empfangene Wert (Future_House2) von seinem standardmäßig initialisierten Wert (Past_House1) unterscheidet, feuert die Unreal Engine automatisch OnRep_CurrentEra() für diesen spezifischen Client in dem Moment, in dem er lädt. Er lädt sofort den korrekten Data Layer. Keine benutzerdefinierte Join-Logik erforderlich.

Wenn du kleinere session-basierte Prototypen baust, schau dir unseren Guide an: How To Architect A Local Co Op Shooter Prototype In Unreal Engine Step By Step.

Persistente World States über die Game Session hinaus

Die obige C++ Lösung ist perfekt für eine einzelne, laufende Server-Instanz. Aber was passiert, wenn dein Server abstürzt? Oder wenn du ein persistentes Survival-Horror-Spiel baust, bei dem die "Ära" über Wochen hinweg gespeichert bleiben muss, selbst wenn alle Spieler offline gehen und der Server herunterfährt?

Hier stößt die reine In-Memory-Replication der Unreal Engine an ihre Grenzen. Um globale World States zu persistieren, benötigst du eine Backend-Datenbank.

Dies selbst zu bauen, erfordert das Aufsetzen von PostgreSQL-Datenbanken, das Schreiben von REST APIs für die State-Serialisierung, das Verwalten der Server-Authentifizierung und das Konfigurieren einer Auto-Scaling-Infrastruktur – locker 4-6 Wochen mühsame Backend-Arbeit.

Mit horizOn sind diese Backend-Services vorkonfiguriert. Du kannst deine World State Änderungen über unser SDK direkt in eine verwaltete Game State Datenbank pushen. Wenn dein Dedicated Server hochfährt, fragt er einfach das horizOn-Backend ab, ruft {"CurrentEra": "Future_House2"} ab, initialisiert den WorldStateManager, und deine Spieler machen nahtlos genau dort weiter, wo sie aufgehört haben. Du kannst dich auf das Design deines Horrorspiels konzentrieren, anstatt Datenbank-Migrationen zu schreiben.

Wenn dein Spiel eine sofortige, bidirektionale Kommunikation mit einem Backend erfordert (z. B. für Live-Ops-Events), solltest du auch unseren Artikel lesen: Ditch Http Polling An Unreal Engine Websockets Tutorial For Real Time Backends.

5 Best Practices für Multiplayer State Synchronization

Um sicherzustellen, dass du nie wieder mit einem katastrophalen Unreal Engine Multiplayer Sync Bug konfrontiert wirst, verankere diese Regeln in deiner Architektur:

  1. Nutze niemals Sequences für den Logical State: Cinematic Sequence Devices und Timelines sollten strikt für visuelle Effekte (VFX, Camera Shakes, lokales UI) reserviert sein. Verlasse dich niemals darauf, dass eine Timeline endet, um eine Variable zu setzen, die das Gameplay beeinflusst.
  2. RPCs für Events, RepNotifies für den State: Nutze Multicast RPCs für flüchtige, temporäre Ereignisse (eine Granate explodiert, ein Sound spielt ab). Nutze Replicated-Variablen mit RepNotifies für persistente, dauerhafte Zustände (eine Tür ist offen, ein Haus ist transformiert, ein Generator hat Strom).
  3. Respektiere das Bandbreitenlimit: Überwache deinen Network Profiler (Stat Net). Wenn du Transforms für mehr als 50-100 Actor gleichzeitig replizierst, sättigst du wahrscheinlich den Kanal. Nutze Network Dormancy (ENetDormancy::DORM_Initial) für Props, die sich selten bewegen.
  4. Setze bAlwaysRelevant gewissenhaft: Für globale State Manager (wie unseren AWorldStateManager) stelle sicher, dass bAlwaysRelevant = true ist. Wenn dieser Actor aus der Network Cull Distance eines Spielers fällt, erhält er keine Updates mehr, was zu lokalen Desyncs führt.
  5. Server Authority ist absolut: Clients sollten nur jemals "Requests" an den Server senden (z. B. Server_RequestInteract()). Der Server validiert die Anfrage, aktualisiert die Replicated-Variable und lässt das Replication-System die visuellen Änderungen an alle Clients propagieren.

Hör auf, gegen die Engine zu kämpfen

Multiplayer-Entwicklung ist berüchtigt schwierig, aber 90 % der Sync-Bugs entstehen durch den Versuch, clientseitige Tools für serverseitige Aufgaben zu missbrauchen. Durch den Wechsel von Brute-Force-Transform-Manipulation zu Data Layers und die Nutzung von RepNotifies anstelle von lokalen Triggern richtest du dein Spiel an der vorgesehenen Netzwerkarchitektur der Unreal Engine aus.

Bereit, dein Multiplayer-Backend zu skalieren und deine World States ohne Infrastruktur-Kopfschmerzen zu persistieren? Teste horizOn kostenlos oder schau dir die API docs an.


Quelle: Houses Merged Weirdly HELPPPP