Назад к блогу

Баг Multiplayer Sync в Unreal Engine, ломающий World States (и как его исправить)

Опубликовано 23 февраля 2026 г.
Баг Multiplayer Sync в Unreal Engine, ломающий World States (и как его исправить)

Вы тратите месяцы на создание масштабной кинематографичной трансформации мира. В синглплеере все работает безупречно. Старый дом с привидениями уходит под землю, а новая, чистая версия поднимается из глубин точно по расписанию. Но как только второй игрок подключается к серверу, ваш шедевр превращается в неиграбельный кошмар из накладывающихся друг на друга объектов. Дома сливаются. Collision ломается. Игроки застревают в геометрическом чистилище.

Каждый инди-разработчик Multiplayer рано или поздно упирается в стену, где визуальная логика на стороне клиента жестко сталкивается с реальностью Server-Authoritative. Если вы пытаетесь перемещать сотни ассетов с помощью Cinematic Sequence Device или анимаций timeline, запускаемых локальными событиями игрока, вы буквально напрашиваетесь на Unreal Engine Multiplayer Sync Bug.

В этом туториале мы разберем, почему перемещение огромного количества акторов вызывает катастрофические десинхроны, почему Cinematic Sequences подводят Late-joiners, и как спроектировать надежный Server-Authoritative World State Manager, используя C++ и Data Layers в Unreal Engine 5.

Анатомия десинхрона: почему ваши дома сливаются

Чтобы решить проблему, сначала нужно понять математику того, почему система Replication в Unreal Engine задыхается от вашей последовательности трансформации.

Допустим, ваша последовательность перемещает около 450 отдельных ассетов (стены, пропсы, освещение), чтобы заменить «Дом 1» на «Дом 2». Когда вы перемещаете реплицируемый актор, Unreal Engine использует структуру FRepMovement для синхронизации его местоположения, вращения и скорости по сети.

Стандартное сжатое обновление движения стоит примерно от 40 до 50 байт на актор.

Если 450 акторов движутся одновременно в течение 5-секундной кинематографической последовательности с частотой обновления 30 раз в секунду, математика выглядит так: 450 акторов × 50 байт × 30 обновлений/сек = 675 000 байт в секунду (675 КБ/с).

Стандартный MaxClientRate в Unreal Engine (максимальная пропускная способность, которую серверу разрешено отправлять одному клиенту) обычно ограничен пределом от 15 000 до 100 000 байт в секунду.

Ваша последовательность требует почти в 7 раз больше доступной пропускной способности. Сетевой канал мгновенно насыщается. Сервер начинает агрессивно ограничивать обновления, отбрасывать пакеты и приоритизировать других акторов на основе NetPriority. В результате половина ассетов Дома 1 замирает на полпути под землей, а половина ассетов Дома 2 так и не добирается до поверхности. Вы остаетесь с навсегда слипшимся десинхронизированным месивом.

Более того, если вы запускаете эту последовательность локально через клиентское событие (например, игрок наступает в trigger box), игрок, присоединившийся к серверу через 10 минут, никогда не увидит выполнение этой последовательности. Он увидит состояние карты по умолчанию, в то время как первый игрок видит трансформированное состояние.

Шаг 1: Откажитесь от манипуляций с Transform в пользу Data Layers

Перемещение 450 акторов — это метод грубой силы, который тратит циклы CPU и пропускную способность сети. В Unreal Engine 5 правильным архитектурным подходом для масштабных изменений мира являются Data Layers (эволюция Level Streaming).

Вместо того чтобы перемещать «Дом 1» под землю, вы назначаете все ассеты Дома 1 в House1_DataLayer, а все ассеты Дома 2 — в House2_DataLayer. Когда наступает момент смены, вы просто выгружаете первый слой и загружаете второй.

Это полностью устраняет проблему пропускной способности. Вместо стриминга 675 КБ/с непрерывных данных о движении сервер отправляет одно крошечное обновление состояния: «Data Layer 2 теперь активен». Локальный движок клиента бесшовно обрабатывает загрузку с диска.

Шаг 2: Проектирование Server-Authoritative State Manager

Чтобы гарантировать, что каждый игрок (включая тех, кто зашел позже) видит один и тот же World State, нам нужен центральный источник истины. Мы создадим актор WorldStateManager на C++, который использует переменную RepNotify для отслеживания текущей «эры» дома.

Заголовочный файл (WorldStateManager.h)

Нам нужен Enum для определения состояний и Replicated переменная с условием 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);
};

Файл реализации (WorldStateManager.cpp)

Здесь происходит магия. Обратите внимание, как мы используем DOREPLIFETIME для регистрации переменной и как функция OnRep гарантирует, что визуальное состояние соответствует логическому.

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

Шаг 3: Решение проблемы Late-Joiner

Самая большая ошибка, которую совершают разработчики, пытаясь исправить Unreal Engine Multiplayer Sync Bug, — это использование Multicast RPCs (Remote Procedure Calls) для запуска мировых событий.

Если вы используете Multicast RPC типа Multicast_PlayHouseTransformation(), он выполнится только на тех клиентах, которые подключены к серверу именно в эту миллисекунду. Если у игрока вылетела игра и он переподключился через 30 секунд, он пропустил RPC. Он загрузится на карту и увидит Дом 1, в то время как все остальные видят Дом 2.

Используя UPROPERTY(ReplicatedUsing = OnRep_CurrentEra), мы решаем проблему Late-joiner автоматически. Когда новый игрок подключается, сервер отправляет ему текущее значение CurrentEra. Поскольку полученное значение (Future_House2) отличается от значения по умолчанию (Past_House1), Unreal Engine автоматически вызывает OnRep_CurrentEra() для этого конкретного клиента в момент загрузки. Он мгновенно загружает нужный Data Layer. Никакой кастомной логики подключения не требуется.

Если вы создаете небольшие сессионные прототипы, ознакомьтесь с нашим руководством How To Architect A Local Co Op Shooter Prototype In Unreal Engine Step By Step.

Сохранение World States вне игровой сессии

Решение на C++ выше идеально подходит для одного запущенного экземпляра сервера. Но что, если сервер упадет? Или если вы строите персистентный Survival Horror, где «Эра» должна сохраняться неделями, даже когда все игроки вышли, а сервер отключился?

Здесь одной только Replication в памяти Unreal Engine недостаточно. Для сохранения глобальных World States вам нужна база данных Backend.

Создание этого самостоятельно требует настройки баз данных PostgreSQL, написания REST APIs для сериализации состояния, управления аутентификацией сервера и настройки инфраструктуры Auto-scaling — это минимум 4–6 недель нудной работы над Backend.

С horizOn эти Backend-сервисы уже настроены. Вы можете отправлять изменения World State напрямую в управляемую базу данных Game State через наш SDK. Когда ваш выделенный сервер запускается, он просто запрашивает Backend horizOn, получает {"CurrentEra": "Future_House2"}, инициализирует WorldStateManager, и ваши игроки продолжают ровно с того места, где остановились. Вы фокусируетесь на дизайне хоррора, а не на написании миграций БД.

Если вашей игре требуется мгновенная двусторонняя связь с Backend (например, для запуска Live-ops событий), вам также стоит прочитать наш разбор Ditch Http Polling An Unreal Engine Websockets Tutorial For Real Time Backends.

5 Best Practices для Multiplayer State Synchronization

Чтобы больше никогда не сталкиваться с катастрофическим Unreal Engine Multiplayer Sync Bug, внедрите эти правила в свою архитектуру:

  1. Никогда не используйте Sequences для логического состояния: Cinematic Sequence Devices и Timelines должны использоваться строго для визуала (VFX, тряска камеры, локальный UI). Никогда не полагайтесь на завершение Timeline для установки переменной, влияющей на геймплей.
  2. RPC для событий, RepNotifies для состояния: Используйте Multicast RPC для кратковременных событий (взрыв гранаты, проигрывание звука). Используйте Replicated переменные с RepNotifies для длительных состояний (дверь открыта, дом трансформирован, генератор запущен).
  3. Соблюдайте лимит пропускной способности: Следите за сетевым профайлером (Stat Net). Если вы реплицируете Transform для более чем 50–100 акторов одновременно, вы, скорее всего, перегружаете канал. Используйте Network Dormancy (ENetDormancy::DORM_Initial) для пропсов, которые редко двигаются.
  4. Тщательно настраивайте bAlwaysRelevant: Для глобальных менеджеров состояния (как наш AWorldStateManager) убедитесь, что bAlwaysRelevant = true. Если этот актор выпадет из Network Cull Distance игрока, он перестанет получать обновления, что приведет к локальным десинхронам.
  5. Server Authority абсолютна: Клиенты должны только отправлять «запросы» на сервер (например, Server_RequestInteract()). Сервер проверяет запрос, обновляет Replicated переменную и позволяет системе Replication распространить визуальные изменения всем клиентам.

Перестаньте бороться с движком

Разработка Multiplayer-игр печально известна своей сложностью, но 90% багов синхронизации возникают из-за попыток заставить клиентские инструменты выполнять серверную работу. Перейдя от грубой манипуляции Transform к Data Layers и используя RepNotifies вместо локальных триггеров, вы приводите свою игру в соответствие с сетевой архитектурой Unreal Engine.

Готовы масштабировать свой Multiplayer Backend и сохранять World States без головной боли с инфраструктурой? Попробуйте horizOn бесплатно или изучите API docs.


Источник: Houses Merged Weirdly HELPPPP