Mastering Unreal Multiplayer: Reconfiguring for the Global Stage

Multiplayer development is often treated as “Game Dev Hard Mode.” But the real reason most projects fail isn’t the complex math—it’s that they weren’t designed for The Reality of Latency.

If your character moves perfectly on your machine but “rubber-bands” on a server, you haven’t accounted for the round-trip time between a player’s home and the data center. This guide covers how to bridge that gap using EOS and advanced replication logic.

EOS Crossplay Multiplayer C++

EOS Kurulumu ve Ayarları

At minimum, crossplay requires:

  • Enable EOS/EOSPlus plugins.
  • Configure DefaultEngine.ini for your target platforms (PC/Steam, console, etc.).
  • Decide whether you want the EOS overlay and what identity flow you use (EAS vs EOS Connect).

EOS Kullanımı

The practical workflow is: initialize the Online Subsystem -> create/find/join sessions -> travel the client to the host via connection string.

The Infrastructure: EOS vs. Steam

To build a modern game, you need a social layer (friends, invites, lobbies). We leverage Epic Online Services (EOS) and the Steam Online Subsystem (OSS) to provide a cross-platform foundation.

Why EOSPlus?

By setting your NativePlatformService to Steam and DefaultPlatformService to EOSPlus, you get the best of both worlds: the Steam overlay for PC players and the robust backend of Epic for cross-play.

Integrated Service Setup: EOS & Steam Configuration

Setting up your DefaultEngine.ini correctly is the first step in ensuring your game can talk to both Steam and Epic’s backends.

[/Script/OnlineSubsystemEOS.EOSSettings]
bEnableOverlay=True
bUseEAS=True
bUseEOSConnect=True
bUseEOSSession=True

[OnlineSubsystem]
DefaultPlatformService=EOSPlus
NativePlatformService=Steam

[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480
bInitServerOnClient=true

Session Management: Host, Find, and Join

Implementing basic session logic in your GameInstance allows players to interact with the Online Subsystem. This is how you create “rooms” and let others find them.

void UYourGameInstance::CreateSession() {
    if (SessionInterface.IsValid()) {
        FOnlineSessionSettings SessionSettings;
        SessionSettings.bShouldAdvertise = true;
        SessionSettings.bUsesPresence = true;
        SessionSettings.NumPublicConnections = 5;
        SessionSettings.bUseLobbiesIfAvailable = true;

        SessionInterface->OnCreateSessionCompleteDelegates.AddUObject(this, &UYourGameInstance::OnCreateSessionComplete);
        SessionInterface->CreateSession(0, SESSION_NAME, SessionSettings);
    }
}

Joining a Session

When a player finds a session, the connection string is retrieved to perform a “Client Travel.” This is the magical moment where the player leaves their menu and enters the host’s world.

void UYourGameInstance::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type JoinResult) {
    FString ConnectionInfo;
    if (SessionInterface->GetResolvedConnectString(SessionName, ConnectionInfo)) {
        APlayerController* PC = GetFirstLocalPlayerController();
        PC->ClientTravel(ConnectionInfo, ETravelType::TRAVEL_Absolute);
    }
}

Replication Roles: The Hierarchy of Truth

In a multiplayer game, everyone is looking at a different version of reality.

  • The Server (Authority): The only version of the game that actually matters. If the server says you didn’t hit that shot, you didn’t hit it.
  • The Player (Autonomous Proxy): A local version that “predicts” movement to keep the game feeling responsive.
  • The Crowd (Simulated Proxy): Other players watching you. They see a slightly delayed, “interpolated” version of your path.

Combatting Lag: Prediction is Key

Unreal’s CharacterMovementComponent is a masterpiece because it handles Local Prediction. When you press W, your character moves locally immediately. The server validates that move later. If the server disagrees, it sends a “Correction”—this is the smooth realignment of your position that prevents “snapping.”


Multiplayer Replication - LAG - Package Loss

When movement feels “wrong” online, the root cause is usually one of these:

  • Latency (ping): the time for an input to reach the server and the correction to come back.
  • Jitter: inconsistent latency (worse than constant delay).
  • Packet loss: missing state updates (causes stutter, warps, desync).

Helpful dev console commands (use carefully in PIE):

  • Net PktLag=150 (simulate latency)
  • Net PktLoss=5 (simulate packet loss)

Replication Actor Roles

Open role diagrams

Replication roles diagram (1)

Replication roles diagram (2)

Replication roles diagram (3)

What is the packet loss?

Open packet loss diagrams

Packet loss diagram (1)

Packet loss diagram (2)

Packet loss diagram (3)

What is the LAG?

Open lag diagrams

Lag diagram (1)

Lag diagram (2)

Let’s visualize the lag

Open lag visualization

Lag visualization (1)

Lag visualization (2)

Fix the LAG Methods

Open fix methods

Lag fix methods

Solutions (overview)

Let's start (setup)

Replication Refactor for movement bindings

Open refactor diagrams

Replication refactor

Correct smooth movement graph

Smooth movement graph (1)

Smooth movement graph (2)

Smooth movement graph (3)

Smooth movement graph (4)

Smooth movement graph (5)

Simulate and make smooth movement

Open smoothing simulation

Smoothing simulation (1)

Smoothing simulation (2)

Smoothing simulation (3)

Puzzle Platform

To replicate a moving platform, you must enable replication in the constructor and ensure the movement logic only runs on the Server (Authority).

MovingPlatform.h

public:
    AMovingPlatform();

    void MovementActive();
    void MovementDeactive();
 
private:
    void Movement(float DeltaTime);
    
    // MakeEditWidget command is working for the set location with visual (gizmo)
    UPROPERTY(EditAnywhere, Category= "Movement Settings", meta = (MakeEditWidget = true))
    FVector TargetLocation;
   
    FVector GlobalTargetLocation;
    FVector GlobalStartLocation;
   
    UPROPERTY(EditAnywhere, Category= "Movement Settings")
    float Speed = 20.0f;
   
    UPROPERTY(EditAnywhere)
    int ActivatePlatforms = 1;
 
protected:
    virtual void Tick(float DeltaTime) override;
    virtual void BeginPlay() override;

MovingPlatform.cpp

AMovingPlatform::AMovingPlatform() {
    PrimaryActorTick.bCanEverTick = true;
    SetMobility(EComponentMobility::Movable);
  
    // Core Replication Settings
    bReplicates = true;
    SetReplicateMovement(true);
}

void AMovingPlatform::Tick(float DeltaTime) {
    Super::Tick(DeltaTime);

    // Only the server calculates movement
    if (HasAuthority()) {
        if (ActivatePlatforms > 0)
        {
            Movement(DeltaTime);
        }
    }
}

void AMovingPlatform::Movement(float DeltaTime)
{
    FVector Location = GetActorLocation();
    if ((GlobalTargetLocation - Location).IsUnit(Speed * DeltaTime))
    {
        Swap(GlobalTargetLocation, GlobalStartLocation);
    }
    FVector Direction = (GlobalTargetLocation - GlobalStartLocation).GetSafeNormal();
    Location += Speed * DeltaTime * Direction;
    SetActorLocation(Location);
}

void AMovingPlatform::MovementActive()
{
    ActivatePlatforms++;
}

void AMovingPlatform::MovementDeactive()
{
    if (ActivatePlatforms > 0)
    {
        ActivatePlatforms--;
    }
}

Platform Trigger

AActor that activates the platform when overlapped.

PlatformTrigger.h

public:
    APlatformTrigger();
 
private:
    UPROPERTY(EditAnywhere)
    class UBoxComponent* Trigger;
    
    UFUNCTION()
    void OnOverlapBegin(class UPrimitiveComponent* OverlappedComp, class AActor* OtherActor, class UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
    
    UFUNCTION()
    void OnOverlapEnd(class UPrimitiveComponent* OverlappedComp, class AActor* OtherActor, class UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
    
    // find all platform on scene and select on editor
    UPROPERTY(EditAnywhere)
    TArray<class AMovingPlatform*> TriggerToPlatform;
 
protected:
    virtual void BeginPlay() override;
};

PlatformTrigger.cpp

APlatformTrigger::APlatformTrigger()
{
    PrimaryActorTick.bCanEverTick = true;
    Trigger = CreateDefaultSubobject<UBoxComponent>(TEXT("Trigger"));
    if (!ensure(Trigger != nullptr)) return;
    RootComponent = Trigger;
 
    Trigger->OnComponentBeginOverlap.AddDynamic(this, &APlatformTrigger::OnOverlapBegin);
    Trigger->OnComponentEndOverlap.AddDynamic(this, &APlatformTrigger::OnOverlapEnd);
}

void APlatformTrigger::OnOverlapBegin(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, ...)
{
    for (AMovingPlatform* Platform : TriggerToPlatform)
    {
        Platform->MovementActive();
    }
}

void APlatformTrigger::OnOverlapEnd(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, ...)
{
    for (AMovingPlatform* Platform : TriggerToPlatform)
    {
        Platform->MovementDeactive();
    }
}

Multiplayer Server Type Selection

Comparison charts and notes for Unreal multiplayer server types:

Server type selection comparison (1)

Server type selection comparison (2)

Server type selection comparison (3)

Server type selection comparison (4)

Server type selection (section breakdown 1)

Server type selection (section breakdown 2)

  • Listen Server: One player acts as the host. Good for co-op but dependent on the host’s connection.
  • Dedicated Server: A headless instance running on a server. Provides the most fair and stable competitive environment.

Dedicated Server Launch Command

-server -NOSTEAM -log -AUTH_LOGIN=127.0.0.1:8081 -AUTH_PASSWORD=MyCred -AUTH_TYPE=developer

Server Type Check

In C++ gameplay code, HasAuthority() is the fastest litmus test:

// If true: server (authority). Else: client.
if (HasAuthority())
{
    FVector Location = GetActorLocation();
    Location += FVector(5 * DeltaTime, 0, 0);
    SetActorLocation(Location);
}

Game Start with CMD

Launching from command line is an underrated debugging tool because it makes logs/crashes reproducible.

Common flags:

  • -game: runs the game directly (instead of opening the full editor UI)
  • -log: opens a live log window
  • -server: starts a server instance

Example (edit paths for your machine):

UnrealEditor.exe "C:\\Path\\To\\Project.uproject" -game -log

To start on a specific map, provide it before -game:

UnrealEditor.exe "C:\\Path\\To\\Project.uproject" /Game/MyGame/Maps/MapName -game -log

Steam Lobby System C++ (GameDevTV Course)

Steam lobbies typically use the Online Subsystem Session Interface. Start by verifying which subsystem is active:

IOnlineSubsystem* SubSystem = IOnlineSubsystem::Get();
if (SubSystem != nullptr)
{
    UE_LOG(LogTemp, Warning, TEXT("Found subsystem %s"), *SubSystem->GetSubsystemName().ToString());
}
else
{
    UE_LOG(LogTemp, Warning, TEXT("Found no subsystem"));
}

Set DefaultPlatformService=NULL during early development/testing:

[OnlineSubsystem]
DefaultPlatformService=NULL

Steam subsystem configuration screenshot

Session flow (high level):

HostSession -> FindSession -> JoinSession -> GetResolvedConnectString -> ClientTravel

LAN vs Steam test switch:

if (IOnlineSubsystem::Get()->GetSubsystemName() == "NULL")
{
    SessionSettings.bIsLANMatch = true;
}
else
{
    SessionSettings.bIsLANMatch = false;
}

Session settings LAN vs Steam snippet

Travel notes:

  • Non-seamless travel disconnects clients, loads map, reconnects (visible freeze).
  • Seamless travel uses a transition map to reduce perceived stalls.
bUseSeamlessTravel = true;

Seamless travel notes


Multiplayer Lobby System (C++ & UMG)

A complete lobby system requires a MenuInterface to handle communication between the UI (Widgets) and the Game Instance.

1. The Interface

Define the contract for hosting and joining.

MenuInterface.h

public:
    virtual void Host() = 0;
    virtual void Join(const FString& Adress) = 0;
    virtual void LoadMainMenu() = 0;

2. The Base Menu Widget

Handles input mode (mouse cursor) and teardown when levels change.

MenuWidget.h

public:
    void Setup();
    void SetMenuInterface(IMenuInterface* MenuInterface);
protected:
    IMenuInterface* MainMenuInterface = nullptr;
    
    // Clean up when level changes
    virtual void OnLevelRemovedFromWorld(ULevel* InLevel, UWorld* InWorld) override; 

MenuWidget.cpp

void UMenuWidget::Setup()
{
    this->AddToViewport();
    UWorld* World = GetWorld();
    if (!World) return;

    APlayerController* PlayerController = World->GetFirstPlayerController();
    if (!PlayerController) return;

    FInputModeUIOnly InputModeData;
    InputModeData.SetWidgetToFocus(this->TakeWidget());
    InputModeData.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);

    PlayerController->SetInputMode(InputModeData);
    PlayerController->bShowMouseCursor = true;
}

void UMenuWidget::OnLevelRemovedFromWorld(ULevel* InLevel, UWorld* InWorld)
{
    this->RemoveFromViewport();
    UWorld* World = GetWorld();
    if (!World) return;
    APlayerController* PlayerController = World->GetFirstPlayerController();
    
    // Restore Game Input
    FInputModeGameOnly InputModeGameOnly;
    PlayerController->SetInputMode(InputModeGameOnly);
    PlayerController->bShowMouseCursor = false;
}

3. The Main Menu

Handles button clicks and IP entry.

MainMenu.h

private:
    UPROPERTY(meta = (BindWidget))
    class UButton* Host; 
    UPROPERTY(meta = (BindWidget))
    class UButton* Join; 
    UPROPERTY(meta = (BindWidget))
    class UEditableTextBox* IPWriteBox;
    UPROPERTY(meta = (BindWidget))
    class UWidgetSwitcher* WidgetSwitch;
    
    UFUNCTION()
    void HostServer();
    UFUNCTION()
    void JoinServer();

MainMenu.cpp

bool UMainMenu::Initialize()
{
    bool Success = Super::Initialize();
    if (!Success) return false;
    
    if (!ensure(Host != nullptr)) return false;
    Host->OnClicked.AddDynamic(this, &UMainMenu::HostServer);
    
    if (!ensure(Join != nullptr)) return false;
    Join->OnClicked.AddDynamic(this, &UMainMenu::OpenJoinMenu);
    
    return true;
}

void UMainMenu::HostServer()
{
    if (MainMenuInterface)
    {
        MainMenuInterface->Host();
    }
}

void UMainMenu::JoinServer()
{
    if(MainMenuInterface && IPWriteBox)
    {
        MainMenuInterface->Join(IPWriteBox->GetText().ToString());
    }
}

4. Game Instance Implementation

The Game Instance survives level transitions, making it the perfect place to hold session logic and widgets.

GameInstance.h

UCLASS()
class YOURGAME_API UYourGameInstance : public UGameInstance, public IMenuInterface
{
    GENERATED_BODY()
public:
    UYourGameInstance(const FObjectInitializer& ObjectInitializer);
    virtual void Init() override;

    UFUNCTION(BlueprintCallable)
    void LoadMenu();

    UFUNCTION(Exec)
    virtual void Host() override;
 
    UFUNCTION(Exec)
    virtual void Join(const FString& Adress) override;
 
private:
    TSubclassOf<class UUserWidget> MenuClass = nullptr;
    class UMainMenu* Menu = nullptr;
};

GameInstance.cpp

UYourGameInstance::UYourGameInstance(const FObjectInitializer& ObjectInitializer)
{
    static ConstructorHelpers::FClassFinder<UUserWidget> MenuBPClass(TEXT("/Game/Blueprints/Widgets/WBP_MainMenu"));
    if (MenuBPClass.Class != nullptr)
    {
        MenuClass = MenuBPClass.Class;
    }
}

void UYourGameInstance::LoadMenu()
{
    if (!MenuClass) return;
    Menu = CreateWidget<UMainMenu>(this, MenuClass);
    if (!Menu) return;
    
    Menu->Setup();
    Menu->SetMenuInterface(this);
}

void UYourGameInstance::Host()
{
    UWorld* World = GetWorld();
    if (World)
    {
        World->ServerTravel(TEXT("/Game/Maps/Level2?listen"));
    }
}

void UYourGameInstance::Join(const FString& Adress)
{
    APlayerController* PlayerController = GetFirstLocalPlayerController();
    if (PlayerController)
    {
        PlayerController->ClientTravel(Adress, ETravelType::TRAVEL_Absolute);
    }
}

Multiplayer lobby (Multiplayer with hamachi or any IP address)

For early multiplayer testing (LAN/VPN like Hamachi), you can ship a simple UI loop:

  1. Main Menu: Host / Join entry point
  2. Join Menu: IP text box + connect button
  3. In-Game Menu: pause menu with Quit/Return controls

UMG: main menu widget layout

UMG: join menu inside a WidgetSwitcher

UMG: in-game pause menu widget