UNREAL ENGINE 5
NETWORK ARCHITECTURE
& MULTIPLAYER COMBAT ENGINEERING
A Definitive Technical Textbook
From Absolute Beginner to 100-Player Dedicated Server Architect
Version 1.0 · Targeting Unreal Engine 5.7
Preface: How to Use This Book
This textbook is organized as a progressive five-phase curriculum. Each phase builds
directly on the last. You cannot skip phases — the concepts introduced in Phase 1 are
the foundation upon which Phase 5's Iris Replication System is built. If you try to
implement lag compensation before you understand Actor roles, you will fail.
Each chapter contains four distinct block types:
• THEORY blocks explain the why — the mental model you need before writing a
single line of code.
• BLUEPRINT LOGIC blocks provide node-graph descriptions for tasks that benefit
from visual scripting.
• C++ IMPLEMENTATION blocks provide production-grade source code with
inline commentary.
• OPTIMIZATION RULES blocks provide actionable constraints — the rules you
must not violate when building for 100 simultaneous players.
The end goal is replication of the mechanical standard set by fast-paced action games:
frame-accurate hit detection, zero-latency-feel character control, and a combat system
that feels identical on a 20ms LAN and a 120ms WAN connection. Every architectural
decision in this book serves that goal.
PHASE 1: The Engine & Architecture Foundation
Chapter 1 — Installing Unreal Engine 5.7 from Source for Dedicated
Servers
▶ THEORY
The Epic Games Launcher provides a pre-compiled binary of the engine. For dedicated
server work, this is insufficient.
A dedicated server build strips the renderer, audio system, and all visual subsystems.
You cannot produce a Dedicated Server Target from a binary installation — you must
compile the engine from source code so the build system can create a headless server
executable.
Source builds also allow you to step through engine networking code in a debugger,
which is essential when diagnosing replication bugs.
1.1 Prerequisites
Before cloning the repository, ensure the following software is installed and correctly
configured on your development machine:
Requirement Version / Notes
Windows OS Windows 10 22H2 or Windows 11 (23H2 or
later)
Visual Studio 2022 Community or higher — workloads:
Game Dev with UE, Desktop Dev with C++
Git 2.40+ — must be added to system PATH
GitHub Account Linked to your Epic Games account at
[Link]
Disk Space Minimum 250 GB free — 150 GB source,
60 GB compiled, remainder for projects
.NET SDK 6.0 or later — required by the
UnrealBuildTool
1.2 Linking Your GitHub and Epic Games Accounts
Navigate to [Link] and sign in. Under Account Settings →
Connections, link your GitHub username. Epic will send you an invitation to the
EpicGames GitHub organization. Accept it. Without this step, the Unreal Engine
repository will return a 404 error even if you clone the correct URL.
1.3 Cloning and Running Setup
◈ Terminal: Source Setup
# Open Git Bash or PowerShell as Administrator
# Clone the UE5 repository (this will take 20-40 minutes on a fast connection)
git clone --depth=1 -b 5.7 [Link] C:\UE5Source
# Navigate into the source directory
cd C:\UE5Source
# Run Epic's setup script — downloads binary dependencies (~20 GB)
[Link]
# Generate Visual Studio project files
[Link]
After [Link] completes, open [Link] in Visual Studio 2022. In the
Solution Configuration dropdown, ensure you can see the following configurations:
• Development Editor — the editor you will work in daily
• Development Server — a windowed server build with logging
• Shipping Server — the production headless build with no logging overhead
• Development Client — client-only build for shipping
1.4 Compiling the Editor (First Build)
◈ Visual Studio: First Compile
// In Visual Studio 2022:
// Solution Explorer → Right-click UE5 → Set as Startup Project
// Configuration: Development Editor | Platform: Win64
// Build → Build Solution (Ctrl+Shift+B)
// EXPECTED BUILD TIME: 45-90 minutes on first compile
// Subsequent builds with incremental changes: 30 seconds to 5 minutes
// Tip: Enable 'Enable Parallel Project Builds' under Tools → Options → Projects and
Solutions
1.5 Creating Your Project with Server Targets
When your editor opens for the first time, create a new project using the Third Person
template. Name it something like MultiplayerCombat. Once the project is open, you
must add server and client Target files manually:
⚙ C++ IMPLEMENTATION — [Link] — Client Target
// Source/[Link]
using UnrealBuildTool;
public class MultiplayerCombatTarget : TargetRules
{
public MultiplayerCombatTarget(TargetInfo Target) : base(Target)
{
Type = [Link];
DefaultBuildSettings = BuildSettingsVersion.V4;
IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_4;
[Link]("MultiplayerCombat");
}
}
⚙ C++ IMPLEMENTATION — [Link] — Server Target
// Source/[Link]
using UnrealBuildTool;
public class MultiplayerCombatServerTarget : TargetRules
{
public MultiplayerCombatServerTarget(TargetInfo Target) : base(Target)
{
// [Link] strips the renderer and all visual subsystems.
// This binary can run on a cloud VM with no GPU.
Type = [Link];
DefaultBuildSettings = BuildSettingsVersion.V4;
IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_4;
[Link]("MultiplayerCombat");
}
}
⚡ OPTIMIZATION RULES
RULE 1: Always use [Link] for dedicated server binaries. Never use
[Link] as your server — it includes the renderer, wasting 150-300 MB of
RAM per instance.
RULE 2: Compile Shipping Server for production. Development Server logs are verbose
and add ~3-5% CPU overhead per player connection.
RULE 3: Enable Unity Builds (bUseUnityBuild = true) in [Link] for
faster incremental compile times during development.
Chapter 2 — The Three Server Modes: A Networking Lens
▶ THEORY
Unreal Engine supports three distinct play configurations. Understanding the authority
model of each is non-negotiable before writing any networked code.
STANDALONE: One process, no networking. The single process has authority over
everything. Used for single-player games. Irrelevant to this curriculum except as a
debugging baseline.
LISTEN SERVER: One player's game process acts as both the server and a client. This
player has zero latency because they ARE the server. All other players are remote clients
experiencing lag. This is common in indie co-op games but catastrophic for competitive
combat — the host has a permanent unfair advantage and can cheat trivially.
DEDICATED SERVER: A separate, headless process runs on a server machine. It has no
human player and no renderer. All players connect to it as remote clients. The server
has absolute authority over game state. This is the ONLY acceptable architecture for
competitive, fair, 100-player gameplay.
2.1 The Authority Hierarchy
Every networked Actor in Unreal Engine exists in one of three roles. These roles are
not set by the developer directly — they are assigned automatically by the engine
based on whether you are the server or a client:
Role Location Meaning
ROLE_Authority Dedicated Server This machine owns this
Actor. It can modify
replicated properties. Its
word is law.
ROLE_AutonomousProxy Owning Client The client that controls this
Actor (e.g., your own
PlayerCharacter). It can run
prediction.
ROLE_SimulatedProxy All Other Clients A 'ghost' copy of an Actor
owned by someone else.
Read-only. Interpolated by
the engine.
▶ THEORY
A critical mental model: there is no single 'game world.' There are N+1 copies of the
world — one on the server and one on each of N clients. The server's copy is the truth.
All other copies are approximations that the replication system continuously corrects.
When your character runs forward, your client copy moves instantly (prediction). The
server verifies this movement. If the server disagrees, it sends a correction and your
client snaps back. This entire pipeline is what Phase 3 covers in depth.
2.2 Starting a Dedicated Server in the Editor (Testing Workflow)
◈ Server Launch Commands
# Method 1: Two separate editor instances
# In Editor → Play dropdown → Change 'Number of Players' to 2
# Set 'Net Mode' to 'Play As Listen Server' (for quick tests)
# For true dedicated server testing:
# Method 2: Command line (RECOMMENDED for accurate testing)
# Terminal 1 — Start the dedicated server:
[Link] /Game/Maps/TestMap -server -log -port=7777
# Terminal 2 — Connect a client:
[Link] [Link]:7777 -game -log
# The -log flag is essential during development — it shows server output in a window.
# Remove -log in Shipping builds for performance.
Chapter 3 — The Gameplay Framework Through a Networking Lens
▶ THEORY
The Unreal Gameplay Framework is a hierarchy of classes that define how a game is
structured. Every class in this hierarchy has a different existence on the server vs
clients. Getting this wrong is the source of 80% of beginner networking bugs.
The single most important question you must ask about any piece of logic or data is:
'Who needs to know about this?' The answer determines where the code lives and how it
replicates.
The following table defines every major framework class, its network existence, its
replication behavior, and the typical data it owns in a multiplayer combat game:
Class Exists On Replicates Owns (Combat
Context)
AGameMode Server ONLY Never Match rules, spawn
logic, round timers,
win conditions
AGameState Server + All Clients Yes — to all clients Global match state:
score, time
remaining, player
list
APlayerController Server + Owning Yes — to owning Input handling,
Client client only camera, UI
commands, client
RPC receiver
APlayerState Server + All Clients Yes — to all clients Player name, kill
count, team, ping,
persistent stats
APawn / ACharacter Server + All Clients Yes — with Transform, health,
conditions current ability state,
weapon
AGameInstance Local Only (never Never Lobby data, session
replicated) info, settings —
survives level
transitions
UActorComponent Depends on owner Depends on Ability components,
Class Exists On Replicates Owns (Combat
Context)
bReplicates inventory, stats —
replicate with
owner
3.1 AGameMode — The Server's Rulebook
▶ THEORY
AGameMode is the most misunderstood class for beginners. It exists ONLY on the
server. Clients have no instance of it and cannot call its functions. This is intentional —
clients should never be able to modify game rules.
GameMode is responsible for: deciding which Pawn class to spawn for each
PlayerController, handling player login/logout events, enforcing match state transitions
(WaitingToStart → InProgress → WaitingPostMatch), and implementing win condition
checks.
⚙ C++ IMPLEMENTATION — AMyGameMode — Core Structure
// MyGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "[Link].h"
UCLASS()
class MULTIPLAYERCOMBAT_API AMyGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
AMyGameMode();
// Called when a player successfully logs in. Server only.
virtual void PostLogin(APlayerController* NewPlayer) override;
// Called when a player disconnects. Server only.
virtual void Logout(AController* Exiting) override;
// Determines which Pawn class to spawn for a given controller.
// Override this to support class selection (warrior, rogue, etc.)
virtual UClass* GetDefaultPawnClassForController_Implementation(
AController* InController) override;
protected:
// Server-authoritative player count
int32 CurrentPlayerCount;
int32 MaxPlayers = 100;
};
⚙ C++ IMPLEMENTATION — AMyGameMode — Implementation
// [Link]
#include "MyGameMode.h"
#include "MyPlayerController.h"
#include "MyPlayerState.h"
#include "MyGameState.h"
#include "MyCharacter.h"
AMyGameMode::AMyGameMode()
{
// These assignments are replicated to clients via the Gameplay Framework.
// Clients will instantiate these classes automatically when they connect.
PlayerControllerClass = AMyPlayerController::StaticClass();
PlayerStateClass = AMyPlayerState::StaticClass();
GameStateClass = AMyGameState::StaticClass();
DefaultPawnClass = AMyCharacter::StaticClass();
}
void AMyGameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);
CurrentPlayerCount++;
UE_LOG(LogTemp, Log, TEXT("Player connected. Total: %d/%d"),
CurrentPlayerCount, MaxPlayers);
// CORRECT: Notify GameState so clients can see the updated count.
// DO NOT: Directly set data on the character here — it may not be spawned yet.
if (AMyGameState* GS = GetGameState<AMyGameState>())
{
GS->OnPlayerCountChanged(CurrentPlayerCount);
}
}
void AMyGameMode::Logout(AController* Exiting)
{
Super::Logout(Exiting);
CurrentPlayerCount = FMath::Max(0, CurrentPlayerCount - 1);
}
3.2 AGameState — The World's Memory for Clients
▶ THEORY
AGameState is replicated to all clients. Think of it as the server's 'public bulletin board.'
Any data that all players need to see — match timer, score, team information, phase of
the fight — belongs in GameState.
GameState is not for per-player data. That belongs in PlayerState. GameState is for data
that is global to the match itself.
⚙ C++ IMPLEMENTATION — AMyGameState — Replicated Match Data
// MyGameState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameState.h"
#include "[Link].h"
UCLASS()
class MULTIPLAYERCOMBAT_API AMyGameState : public AGameStateBase
{
GENERATED_BODY()
public:
// UPROPERTY with Replicated makes this variable sync from server to all clients.
// When the server changes MatchTimeRemaining, all clients receive the new value
// automatically at the next replication update interval.
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Match")
float MatchTimeRemaining;
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Match")
int32 ConnectedPlayerCount;
// ReplicatedUsing triggers a function ON THE CLIENT when the value changes.
// Essential for UI updates and sound/VFX triggers.
UPROPERTY(ReplicatedUsing = OnRep_MatchPhase, BlueprintReadOnly, Category = "Match")
uint8 MatchPhase; // 0=Lobby, 1=Countdown, 2=InProgress, 3=PostMatch
UFUNCTION()
void OnRep_MatchPhase();
// Called by GameMode (server only) to update the replicated count.
void OnPlayerCountChanged(int32 NewCount);
// Required by UE's replication system — register all replicated properties here.
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
⚙ C++ IMPLEMENTATION — AMyGameState — Implementation
// [Link]
#include "MyGameState.h"
#include "Net/UnrealNetwork.h" // Required for DOREPLIFETIME macros
void AMyGameState::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// DOREPLIFETIME: Replicate to ALL connected clients, always.
DOREPLIFETIME(AMyGameState, MatchTimeRemaining);
DOREPLIFETIME(AMyGameState, ConnectedPlayerCount);
DOREPLIFETIME(AMyGameState, MatchPhase);
}
void AMyGameState::OnRep_MatchPhase()
{
// This runs on CLIENTS ONLY when MatchPhase changes.
// Update UI, play ambient music change, trigger phase announcement, etc.
UE_LOG(LogTemp, Log, TEXT("Client: Match phase changed to %d"), MatchPhase);
// BP_OnMatchPhaseChanged(MatchPhase); // Call Blueprint event here
}
void AMyGameState::OnPlayerCountChanged(int32 NewCount)
{
// Only the server calls this function.
// Setting ConnectedPlayerCount here automatically replicates it to all clients.
ConnectedPlayerCount = NewCount;
}
3.3 APlayerController — The Client's Spine
▶ THEORY
APlayerController is perhaps the most subtle class in the framework. It exists on the
server AND on the owning client only. Other clients do not have an instance of your
PlayerController — they see your Pawn (character), not your controller.
This makes PlayerController the correct home for client-specific RPCs. If you want to
send information to one specific player — like telling them they were hit, or displaying
their personal kill notification — you call a Client RPC on their PlayerController.
PlayerController does NOT hold combat stats or health. Those belong in Pawn (volatile
in-match state) or PlayerState (persistent across respawns).
⚙ C++ IMPLEMENTATION — AMyPlayerController — Network Structure
// MyPlayerController.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "[Link].h"
UCLASS()
class MULTIPLAYERCOMBAT_API AMyPlayerController : public APlayerController
{
GENERATED_BODY()
public:
// CLIENT RPC: Only runs on the machine that owns this controller.
// Use NetMulticast if you need ALL clients to react.
// Use Client if only THIS player needs to know.
UFUNCTION(Client, Reliable)
void Client_ShowHitConfirmation(FVector HitLocation, float Damage);
// SERVER RPC: Runs on the server when called from the client.
// The client cannot be trusted — this is where you put server-side validation.
UFUNCTION(Server, Reliable, WithValidation)
void Server_RequestAttack(FVector AttackOrigin, FVector AttackDirection);
bool Server_RequestAttack_Validate(FVector AttackOrigin, FVector AttackDirection);
void Server_RequestAttack_Implementation(FVector AttackOrigin, FVector
AttackDirection);
protected:
// AcknowledgedPawn provides access to our possessed Pawn from this controller.
// GetPawn() would also work, but AcknowledgedPawn is set earlier in the chain.
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
};
3.4 APlayerState — Persistent Per-Player Data
▶ THEORY
APlayerState is replicated to all clients. Unlike PlayerController, which is private to the
owning client, PlayerState is public — every client can read every other player's
PlayerState.
Most importantly, PlayerState SURVIVES RESPAWN. When a Pawn is destroyed and a
new one is spawned, the PlayerController is re-possessed into the new Pawn, and the
PlayerState travels with the PlayerController. This is why kill counts, experience, and
team assignment live in PlayerState rather than in the Pawn.
⚙ C++ IMPLEMENTATION — AMyPlayerState — Combat Stats
// MyPlayerState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "[Link].h"
UCLASS()
class MULTIPLAYERCOMBAT_API AMyPlayerState : public APlayerState
{
GENERATED_BODY()
public:
UPROPERTY(Replicated, BlueprintReadOnly, Category="Stats")
int32 KillCount;
UPROPERTY(Replicated, BlueprintReadOnly, Category="Stats")
int32 DeathCount;
UPROPERTY(Replicated, BlueprintReadOnly, Category="Combat")
uint8 TeamID; // 0 = no team, 1 = team A, 2 = team B, etc.
// Combo tracking — replicated so spectators can see combo counters
UPROPERTY(ReplicatedUsing=OnRep_ComboCount, BlueprintReadOnly, Category="Combat")
int32 CurrentComboCount;
UFUNCTION()
void OnRep_ComboCount();
// Server-callable functions (called by GameMode or Character on server)
void AddKill();
void AddDeath();
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
3.5 APawn / ACharacter — The Replicated Battlefield Actor
▶ THEORY
The Character is the most network-intensive Actor in your game. It replicates transform
(position, rotation), skeletal mesh pose (via animation), and every gameplay-critical
variable you attach to it.
The Character has three copies across the network: the authoritative copy on the server,
the autonomous proxy copy on the owning client (which runs prediction), and simulated
proxy copies on all other clients (which are interpolated).
Health, stamina, and current ability state are replicated from the server's authoritative
copy to all clients. The owning client's predicted values may temporarily diverge from
the server, but corrections snap them back into sync.
⚙ C++ IMPLEMENTATION — AMyCharacter — Header (Networking Essentials)
// MyCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "[Link].h"
UCLASS()
class MULTIPLAYERCOMBAT_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AMyCharacter();
// bReplicates = true is REQUIRED on any Actor that should exist on clients.
// ACharacter sets this in its constructor by default, but always verify.
// SetReplicates(true); // Called in constructor if not ACharacter subclass
// Health is authoritative on the server.
// The ReplicatedUsing callback fires on clients when this value changes.
UPROPERTY(ReplicatedUsing=OnRep_CurrentHealth, BlueprintReadOnly, Category="Combat")
float CurrentHealth;
UPROPERTY(Replicated, BlueprintReadOnly, Category="Combat")
float MaxHealth;
// CombatState drives animation and input blocking across all clients.
UPROPERTY(ReplicatedUsing=OnRep_CombatState, BlueprintReadOnly, Category="Combat")
uint8 CombatState; // 0=Idle, 1=Attacking, 2=Staggered, 3=Parrying, 4=Dead
UFUNCTION()
void OnRep_CurrentHealth();
UFUNCTION()
void OnRep_CombatState();
// Server-authoritative damage application.
// NEVER apply damage directly on the client — always go through the server.
UFUNCTION(BlueprintCallable, Category="Combat")
void TakeCombatDamage(float DamageAmount, AMyCharacter* DamageInstigator);
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
// GetLocalRole() == ROLE_Authority -> We are the server (or the only instance)
// GetLocalRole() == ROLE_AutonomousProxy -> We are the owning client
// GetLocalRole() == ROLE_SimulatedProxy -> We are a different client watching
};
⚡ OPTIMIZATION RULES
RULE: Never put game logic that modifies state inside OnRep_ functions. OnRep_
functions are for reacting to state changes (playing VFX, updating UI) — not for
changing state further. The server changed the value; clients just respond.
RULE: AGameMode logic must ALWAYS check HasAuthority() before executing. If you
forget, the logic may run twice — once on the server and once on the incorrect machine.
RULE: AGameInstance is your cross-level persistent data store. Session information,
selected character class, lobby settings — anything that must survive a seamless travel
or level transition lives here.
PHASE 2: Core Replication & RPCs — The
Lifeblood of Multiplayer
Chapter 4 — Property Replication: The Rules of Synchronized State
▶ THEORY
Property replication is the backbone of all networked state in Unreal Engine. When a
replicated property changes on the server, the replication system packages the new
value into a network packet and sends it to all relevant clients. This happens
automatically — you do not manually serialize data.
The system is not instantaneous. Replication occurs at the Actor's NetUpdateFrequency
(default: 100Hz for Characters, 1Hz for environment objects). Between updates, clients
extrapolate or hold the last known value. This latency is why prediction exists.
Properties do not replicate from client to server. EVER. Replication is a one-way
channel: server to clients. To send data from client to server, you must use a Server
RPC.
4.1 UPROPERTY Specifiers for Replication
Specifier Behavior
Replicated Property syncs from server to clients. No
callback on client.
ReplicatedUsing=FuncName Property syncs AND calls FuncName() on
clients when value changes. The function
must be a UFUNCTION().
NotReplicated Explicitly opts a property out of replication
(useful in subclasses).
4.2 GetLifetimeReplicatedProps — The Registration Contract
▶ THEORY
Every class that uses Replicated or ReplicatedUsing properties MUST implement
GetLifetimeReplicatedProps. This function is called by the engine to build a replication
map. If you declare a property as Replicated but forget to register it here, it will NEVER
replicate and you will spend hours debugging.
⚙ C++ IMPLEMENTATION — GetLifetimeReplicatedProps — All Condition Macros
#include "Net/UnrealNetwork.h"
void AMyCharacter::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// DOREPLIFETIME: Replicate to ALL clients, every update interval.
// Use for data every player needs: health, position, combat state.
DOREPLIFETIME(AMyCharacter, CurrentHealth);
DOREPLIFETIME(AMyCharacter, MaxHealth);
DOREPLIFETIME(AMyCharacter, CombatState);
// DOREPLIFETIME_CONDITION: Replicate only under specific conditions.
// COND_OwnerOnly: Only the owning client receives this. Good for ammo,
// stamina bars — data only the local player needs to see.
DOREPLIFETIME_CONDITION(AMyCharacter, CurrentStamina, COND_OwnerOnly);
// COND_SimulatedOnly: Only simulated proxies (other players' clients) receive this.
// Useful for data the autonomous proxy calculates locally via prediction.
DOREPLIFETIME_CONDITION(AMyCharacter, ServerCorrectedVelocity, COND_SimulatedOnly);
// COND_InitialOnly: Sent once when the Actor first replicates to a client.
// Use for immutable character setup data: class type, team color, etc.
DOREPLIFETIME_CONDITION(AMyCharacter, CharacterClassID, COND_InitialOnly);
// DOREPLIFETIME_WITH_PARAMS: Full control using FDoRepLifetimeParams.
// Allows custom condition, RepNotify behavior, and push-model hints.
{
FDoRepLifetimeParams Params;
[Link] = COND_None; // Equivalent to DOREPLIFETIME
[Link] = REPNOTIFY_Always; // Notify even if value didn't
change
DOREPLIFETIME_WITH_PARAMS(AMyCharacter, ForceRepProperty, Params);
}
}
4.3 RepNotifies — Reacting to State Changes on Clients
▶ THEORY
A RepNotify (ReplicatedUsing) function runs on the CLIENT every time the server sends
a new value for that property. This is your hook for driving UI, sound, and visual effects
in response to authoritative state changes.
RepNotifies do NOT run on the server. Do NOT run gameplay logic inside them. They
are purely reactive: 'The server told me health changed, so I will update the health bar.'
The old value of the property is available before the function executes — you can access
it via the function parameter or by caching it before the RepNotify fires.
⚙ C++ IMPLEMENTATION — OnRep Functions — Combat Health & State
void AMyCharacter::OnRep_CurrentHealth()
{
// This function runs on EVERY CLIENT when CurrentHealth changes on the server.
// At this point, CurrentHealth already contains the NEW value.
// 1. Update the health bar UI
if (AMyPlayerController* PC = Cast<AMyPlayerController>(GetController()))
{
// Only update OUR health bar, not other players'
if (PC->IsLocalController())
{
PC->UpdateHealthBar(CurrentHealth, MaxHealth);
}
}
// 2. Play hit reaction VFX/sound
if (HitReactionParticle)
{
UGameplayStatics::SpawnEmitterAtLocation(this, HitReactionParticle,
GetActorLocation());
}
// 3. Handle death (health reached zero)
if (CurrentHealth <= 0.0f && !bIsDead)
{
bIsDead = true;
// Trigger death animation on this client's copy of the character
PlayDeathAnimation();
}
}
void AMyCharacter::OnRep_CombatState()
{
// The server has changed our combat state. Update animations accordingly.
// CombatState: 0=Idle, 1=Attacking, 2=Staggered, 3=Parrying, 4=Dead
switch (CombatState)
{
case 1: // Attacking
GetMesh()->GetAnimInstance()->Montage_Play(AttackMontage);
break;
case 2: // Staggered
GetMesh()->GetAnimInstance()->Montage_Play(StaggerMontage);
break;
case 3: // Parrying
GetMesh()->GetAnimInstance()->Montage_Play(ParryMontage);
break;
}
}
Chapter 5 — Remote Procedure Calls: The Three-Direction Highway
▶ THEORY
If property replication is the passive broadcast system (server pushes state to clients),
RPCs are the active messaging system. An RPC is a function call that travels across the
network and executes on a different machine.
There are exactly three directions: Server (client calls, server executes), Client (server
calls, owning client executes), NetMulticast (server calls, executes on server AND all
clients).
Every RPC has a cost: it generates a packet. In a 100-player game, misuse of
NetMulticast RPCs is catastrophic — calling one per frame sends 100 packets per frame
per client, totaling 10,000 packets per frame across the network.
5.1 Server RPCs — The Client's Request Line
▶ THEORY
A Server RPC is how a client asks the server to do something. The client calls the
function locally; the call is serialized, sent to the server, and the _Implementation
version executes there.
CRITICAL: Client input CANNOT be trusted. Every Server RPC must be validated. A
client could send any function parameters — you must verify them server-side. Use
WithValidation to implement a _Validate function. If Validate returns false, the RPC is
rejected and the calling client is kicked.
⚙ C++ IMPLEMENTATION — Server RPC — Attack Request with Validation
// In header: UFUNCTION(Server, Reliable, WithValidation)
// void Server_RequestAttack(FVector AttackOrigin, FVector AttackDirection);
// bool Server_RequestAttack_Validate(FVector AttackOrigin, FVector AttackDirection);
// void Server_RequestAttack_Implementation(FVector AttackOrigin, FVector
AttackDirection);
bool AMyPlayerController::Server_RequestAttack_Validate(
FVector AttackOrigin, FVector AttackDirection)
{
// VALIDATION runs first. Return false to reject the RPC and kick the client.
// Check 1: Is the attack origin plausible? (Is the client claiming they are
// somewhere they cannot be?)
AMyCharacter* Char = Cast<AMyCharacter>(GetPawn());
if (!Char) return false;
// Server knows where this character actually is.
float DistanceSq = FVector::DistSquared(Char->GetActorLocation(), AttackOrigin);
const float MaxTeleportDistanceSq = 500.0f * 500.0f; // 5 meters tolerance
if (DistanceSq > MaxTeleportDistanceSq)
{
UE_LOG(LogTemp, Warning, TEXT("CHEAT DETECTED: Player %s attack origin too far."),
*GetPlayerState<AMyPlayerState>()->GetPlayerName());
return false; // Reject RPC, client gets kicked by engine
}
// Check 2: Direction vector must be normalized
if (![Link]()) return false;
return true;
}
void AMyPlayerController::Server_RequestAttack_Implementation(
FVector AttackOrigin, FVector AttackDirection)
{
// We are now on the SERVER. Validation passed.
// Apply the attack through the authoritative combat system.
AMyCharacter* Char = Cast<AMyCharacter>(GetPawn());
if (Char && Char->HasAuthority())
{
Char->PerformServerAttack(AttackOrigin, AttackDirection);
}
}
5.2 Client RPCs — The Server's Whisper to One Player
▶ THEORY
A Client RPC is called on the server but executes ONLY on the machine that owns the
calling controller. This is the correct mechanism for sending private, player-specific
information: kill notifications, hit confirmations, penalty notices, ability cooldown
updates.
Client RPCs execute on the owning client's PlayerController. If you need to tell a Pawn
about something, call the Client RPC on the controller and dispatch from there to the
Pawn.
⚙ C++ IMPLEMENTATION — Client RPC — Hit Confirmation Feedback
// Called by the server's combat system after damage is applied
void AMyPlayerController::Client_ShowHitConfirmation_Implementation(
FVector HitLocation, float Damage)
{
// This runs ONLY on the client who owns this controller.
// Perfect for: hitmarkers, damage numbers, screen flash, audio sting.
// Spawn damage number widget at world location
if (DamageNumberWidgetClass)
{
UDamageNumberWidget* Widget = CreateWidget<UDamageNumberWidget>(
this, DamageNumberWidgetClass);
if (Widget)
{
Widget->SetDamageValue(Damage);
Widget->SetWorldLocation(HitLocation);
Widget->AddToViewport();
}
}
// Play hitmarker sound (local only — other players do NOT hear this)
UGameplayStatics::PlaySound2D(this, HitConfirmSound);
}
5.3 NetMulticast RPCs — The Broadcast
▶ THEORY
NetMulticast RPCs execute on the server AND on all clients simultaneously. They are
the most expensive RPC type and should be used sparingly.
The canonical use case is a visual event that everyone must see simultaneously: an
explosion, a special ability activation, a death ragdoll trigger. If the event is replicated
via a property change, you do NOT need a NetMulticast — RepNotifies handle that.
Never use NetMulticast to send per-player data. Never call NetMulticast from Tick.
Never use Reliable NetMulticast for frequent events — use Unreliable and accept
occasional misses.
⚙ C++ IMPLEMENTATION — NetMulticast RPC — Death Sequence
// In Header:
// UFUNCTION(NetMulticast, Reliable)
// void Multicast_PlayDeathSequence(FVector KillForce, FName HitBone);
void AMyCharacter::Multicast_PlayDeathSequence_Implementation(
FVector KillForce, FName HitBone)
{
// Runs on SERVER and ALL CLIENTS simultaneously.
// Use for: ragdoll physics, death particle burst, death sound.
// Stop all active montages
GetMesh()->GetAnimInstance()->StopAllMontages(0.1f);
// Activate ragdoll physics on the mesh
GetMesh()->SetCollisionProfileName(TEXT("Ragdoll"));
GetMesh()->SetSimulatePhysics(true);
// Apply directional death force at the hit bone
GetMesh()->AddImpulse(KillForce * 1000.0f, HitBone, true);
// Disable capsule collision (don't block living players with corpse)
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// Spawn death particles
if (DeathParticle)
{
UGameplayStatics::SpawnEmitterAtLocation(this, DeathParticle,
GetActorLocation());
}
}
5.4 Reliable vs. Unreliable RPCs — Choose Carefully
Type Guarantee Use For
Reliable Guaranteed delivery, State-changing events:
ordered. Will resend until attack, death, ability use,
acknowledged. damage
Unreliable Fire-and-forget. May be High-frequency cosmetic
dropped. No resend. data: footstep sounds, non-
critical VFX
⚠ CRITICAL WARNING
Every Reliable RPC that is NOT acknowledged accumulates in the reliable buffer. If this
buffer fills (approximately 256 KB per connection), UE5 will DISCONNECT the client.
This is called a reliable buffer overflow.
Never call Reliable RPCs in Tick(). Never loop Reliable RPCs rapidly. For high-frequency
updates (movement replication, fast projectiles), use the NetUpdateFrequency property
system instead — it is designed for this.
Chapter 6 — Actor Roles, Remote Roles, and HasAuthority
▶ THEORY
GetLocalRole() and GetRemoteRole() are functions every networked Actor can call. They
tell you what THIS machine believes about authority over this Actor. Mastering these is
mandatory.
GetLocalRole() == ROLE_Authority: You are on the server, or this Actor has no
replication. You can freely modify replicated properties.
GetLocalRole() == ROLE_AutonomousProxy: You are the owning client for this Actor.
You can run prediction. Use this to distinguish your own character from others.
GetLocalRole() == ROLE_SimulatedProxy: You are a client observing someone else's
Actor. You are read-only. The engine interpolates movement for you.
HasAuthority() is a convenience function equivalent to GetLocalRole() ==
ROLE_Authority. Use it constantly for guard clauses.
⚙ C++ IMPLEMENTATION — Role Guards — Essential Patterns
void AMyCharacter::TakeCombatDamage(float DamageAmount, AMyCharacter* Instigator)
{
// GUARD: Damage logic must ONLY run on the server.
// Without this check, clients could inadvertently apply damage locally,
// creating desynced health values.
if (!HasAuthority()) return;
// We are the server. Apply the damage.
CurrentHealth = FMath::Max(0.0f, CurrentHealth - DamageAmount);
// CurrentHealth is Replicated — clients will receive the new value
// automatically at the next replication cycle.
if (CurrentHealth <= 0.0f)
{
HandleDeath(Instigator);
}
}
void AMyCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// Only run heavy AI/physics logic on the server
if (HasAuthority())
{
RunServerOnlyLogic(DeltaTime);
}
// Only run input/prediction on the owning client
if (GetLocalRole() == ROLE_AutonomousProxy)
{
RunPredictionLogic(DeltaTime);
}
// Interpolation for all other players' characters — lightweight
if (GetLocalRole() == ROLE_SimulatedProxy)
{
InterpolateToServerPosition(DeltaTime);
}
}
◈ BLUEPRINT LOGIC
In Blueprints, use the 'Switch Has Authority' node found under 'Networking.'
This node branches into two execution pins: Authority (server) and Remote (client).
Example pattern: On BeginPlay, use Switch Has Authority to branch.
On the Authority pin: Initialize replicated variables (CurrentHealth = MaxHealth).
On the Remote pin: Setup UI, register callbacks for RepNotify events.
NEVER modify replicated variables on the Remote branch — those changes are local
only and will be overwritten by the server's next replication update.
PHASE 3: Movement & The Network Prediction
Plugin
Chapter 7 — The Character Movement Component Under the Hood
▶ THEORY
UCharacterMovementComponent (CMC) is one of the most sophisticated networked
systems in Unreal Engine. Understanding its architecture is essential before attempting
to add custom movement modes.
The CMC implements a full client-side prediction loop. When you press forward, the
following pipeline executes:
1. CLIENT: PerformMovement() runs locally with your input, moving your capsule
immediately.
2. CLIENT: The input and timestamp are packaged into an
FCharacterNetworkMoveData and sent to the server via ServerMove() — a Server RPC.
3. SERVER: ReplicateMoveToServer() re-executes the movement logic with the received
input.
4. SERVER: The resulting position is compared to the client's claimed position.
5. SERVER: If difference exceeds ClientErrorThreshold (default ~1cm), a correction is
sent via ClientAdjustPosition().
6. CLIENT: Upon receiving a correction, the client rewinds its local state, replays all
buffered moves from the correction timestamp forward, and snaps to the corrected
position.
This pipeline is why your own character feels instant — you see local prediction — while
other players' characters (SimulatedProxies) are slightly interpolated.
7.1 Move Data Flow Diagram
Step Machine Function Purpose
1 Client PerformMovement() Apply input locally
(prediction)
2 Client ServerMove() RPC Send input +
timestamp to server
3 Server MoveAutonomous() Replay the move on
server
4 Server ServerMoveHandle Compare positions,
ClientError() detect drift
5 Server ClientAdjustPosition Send correction if
() RPC needed
6 Client ClientAdjustPosition Rewind + replay
_Implementation() from correction
7.2 Key CMC Variables You Must Know
⚙ C++ IMPLEMENTATION — CMC Configuration in Character Constructor
AMyCharacter::AMyCharacter()
{
// Access the CMC through GetCharacterMovement()
UCharacterMovementComponent* CMC = GetCharacterMovement();
// --- Prediction & Correction Thresholds ---
// How far (in cm) the client and server positions can differ before
// a correction is sent. Lower = more corrections = more bandwidth.
// Higher = more visible snapping for other players.
// Default: 1.0f. For melee combat precision, stay at 2.0f max.
CMC->NetworkMaxSmoothUpdateDistance = 92.0f;
CMC->NetworkNoSmoothUpdateDistance = 140.0f;
// --- Bandwidth: Move Combines ---
// Combine small moves into one packet. Essential for 100-player bandwidth.
// Default: true. Never disable this.
CMC->bServerAcceptClientAuthoritativePosition = false; // NEVER true in competitive
// --- Movement Params (Melee Combat Feel) ---
CMC->MaxWalkSpeed = 600.0f;
CMC->MaxAcceleration = 2048.0f; // Snappy acceleration for melee feel
CMC->BrakingDecelerationWalking = 2048.0f; // Crisp stop
CMC->GravityScale = 1.5f; // Heavier feel, less floaty
CMC->JumpZVelocity = 600.0f;
CMC->AirControl = 0.35f;
// --- Networking Frequency ---
// How often (per second) this Actor sends replication data.
// For our player character, we want high frequency.
NetUpdateFrequency = 100.0f; // 100 times per second
MinNetUpdateFrequency = 33.0f; // Minimum even when nothing changes
}
7.3 Adding Custom Movement Modes
▶ THEORY
Custom movement modes (dash, wall-run, grapple) require extending the CMC. Naively
adding a timer and impulse works in standalone, but breaks networking because the
server and client execute different code paths, causing desync.
The correct approach is to override the SavedMove and NetworkMoveData structures to
include your custom input flags, then override PerformMovement to handle your custom
mode. This ensures the move is included in the prediction and replay pipeline.
⚙ C++ IMPLEMENTATION — Custom CMC — Dash Implementation (Header)
// MyCharacterMovementComponent.h
#pragma once
#include "GameFramework/CharacterMovementComponent.h"
#include "[Link].h"
UCLASS()
class UMyCharacterMovementComponent : public UCharacterMovementComponent
{
GENERATED_BODY()
public:
// Custom move flags — packed into the move data sent to server
// These are bit flags on the uint8 CompressedFlags byte.
// FLAG_Custom_0 through FLAG_Custom_3 are available for your use.
uint8 bWantsToDash : 1;
// Dash parameters (set in Blueprint or constructor)
UPROPERTY(EditDefaultsOnly, Category="Dash")
float DashSpeed = 2500.0f;
UPROPERTY(EditDefaultsOnly, Category="Dash")
float DashDuration = 0.15f;
UPROPERTY(EditDefaultsOnly, Category="Dash")
float DashCooldown = 0.8f;
protected:
float DashTimeRemaining = 0.0f;
float LastDashTime = -999.0f;
FVector DashDirection;
// --- Overrides for prediction system ---
virtual void UpdateFromCompressedFlags(uint8 Flags) override;
virtual FNetworkPredictionData_Client* GetPredictionData_Client() const override;
virtual void PerformMovement(float DeltaTime) override;
// Internal dash logic (runs on both server and client during replay)
void ExecuteDash(float DeltaTime);
public:
// Called by character input (runs on autonomous proxy first, then server via RPC)
void TriggerDash();
// SavedMove subclass — stores our custom flag for move replay
class FSavedMove_My : public FSavedMove_Character
{
public:
typedef FSavedMove_Character Super;
uint8 bSavedWantsToDash : 1;
virtual bool CanCombineWith(const FSavedMovePtr& NewMove, ACharacter* InCharacter,
float MaxDelta) const override;
virtual void Clear() override;
virtual uint8 GetCompressedFlags() const override;
virtual void SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel,
class FNetworkPredictionData_Client_Character& ClientData) override;
virtual void PrepMoveFor(ACharacter* C) override;
};
class FNetworkPredictionData_Client_My
: public FNetworkPredictionData_Client_Character
{
public:
FNetworkPredictionData_Client_My(const UCharacterMovementComponent&
ClientMovement);
virtual FSavedMovePtr AllocateNewMove() override;
};
};
⚙ C++ IMPLEMENTATION — Custom CMC — Dash Implementation (Key Functions)
// [Link]
void UMyCharacterMovementComponent::UpdateFromCompressedFlags(uint8 Flags)
{
Super::UpdateFromCompressedFlags(Flags);
// Extract our custom flag from the compressed byte.
// FLAG_Custom_0 = 0x10
bWantsToDash = ((Flags & FSavedMove_Character::FLAG_Custom_0) != 0);
}
void UMyCharacterMovementComponent::PerformMovement(float DeltaTime)
{
// Check if dash was requested this frame
if (bWantsToDash)
{
float CurrentTime = GetWorld()->GetTimeSeconds();
if (CurrentTime - LastDashTime >= DashCooldown)
{
// Capture dash direction from current velocity/input
DashDirection = Velocity.GetSafeNormal2D();
if ([Link]())
DashDirection = GetOwner()->GetActorForwardVector();
DashTimeRemaining = DashDuration;
LastDashTime = CurrentTime;
}
bWantsToDash = false;
}
// If dashing, override velocity
if (DashTimeRemaining > 0.0f)
{
DashTimeRemaining -= DeltaTime;
Velocity = DashDirection * DashSpeed;
// Disable gravity during dash
FVector OldGravity = FVector(0, 0, GravityScale);
GravityScale = 0.0f;
Super::PerformMovement(DeltaTime);
GravityScale = OldGravity.Z; // Restore
return;
}
Super::PerformMovement(DeltaTime);
}
// SavedMove — GetCompressedFlags packs our flag for network transmission
uint8 UMyCharacterMovementComponent::FSavedMove_My::GetCompressedFlags() const
{
uint8 Result = Super::GetCompressedFlags();
if (bSavedWantsToDash)
Result |= FLAG_Custom_0;
return Result;
}
// SetMoveFor captures the CMC state at the time the move is saved
void UMyCharacterMovementComponent::FSavedMove_My::SetMoveFor(
ACharacter* C, float InDeltaTime, FVector const& NewAccel,
FNetworkPredictionData_Client_Character& ClientData)
{
Super::SetMoveFor(C, InDeltaTime, NewAccel, ClientData);
UMyCharacterMovementComponent* MyCMC =
Cast<UMyCharacterMovementComponent>(C->GetCharacterMovement());
if (MyCMC)
bSavedWantsToDash = MyCMC->bWantsToDash;
}
// PrepMoveFor restores CMC state when replaying a saved move
void UMyCharacterMovementComponent::FSavedMove_My::PrepMoveFor(ACharacter* C)
{
Super::PrepMoveFor(C);
UMyCharacterMovementComponent* MyCMC =
Cast<UMyCharacterMovementComponent>(C->GetCharacterMovement());
if (MyCMC)
MyCMC->bWantsToDash = bSavedWantsToDash;
}
⚡ OPTIMIZATION RULES
RULE: Never apply movement impulses directly to the Character's physics body without
going through the CMC. Direct physics changes are not part of the prediction replay and
will cause violent snapping corrections.
RULE: bServerAcceptClientAuthoritativePosition must be FALSE for competitive play.
When true, the server accepts the client's claimed position without verification — trivial
teleport cheating.
RULE: Keep your CustomFlags count to a minimum. Each flag costs bandwidth on every
move packet. Batch related flags together where possible.
PHASE 4: Architecting Complex Multiplayer
Combat
Chapter 8 — Why Standard RPCs Fail for Fast-Paced Melee
▶ THEORY
Consider a naive melee attack implementation: the player presses attack, a Client RPC is
sent to the server, the server triggers a hitbox sweep, and the result is sent back. On a
100ms connection, this produces 200ms of latency from input to damage confirmation.
For comparison, a human reaction window is approximately 250ms.
At 60fps, a 200ms round trip means 12 frames of visible lag between pressing attack and
seeing the hit register. Against fast-moving opponents, this means the hitbox is
sweeping through air where the opponent WAS 200ms ago, not where they ARE now.
The solution is not to give clients authority over hit detection — that enables cheating.
The solution is lag compensation: the server rewinds time to the moment the client
swung, evaluates the hit against historically-accurate character positions, and then
returns to present time to apply the result.
8.1 The Timeline of a Melee Hit
Time Event Machine
T=0ms Player presses attack Client
button
T=0ms Animation begins locally Client
(prediction)
T=50ms Server receives attack RPC Server
T=50ms Server evaluates hit against Server — NAÏVE
CURRENT positions
(WRONG)
T=50ms Server rewinds to T=0ms, Server — LAG COMP
evaluates hit (CORRECT)
T=50ms Server applies damage, Server
replicates health change
T=100ms Client receives health Client
change, shows hit
confirmation
Chapter 9 — Server-Side Lag Compensation: Rewind & Rollback
▶ THEORY
Lag compensation requires the server to maintain a history buffer of all players'
positions and capsule states over the last N milliseconds (typically 300ms — enough to
compensate for the vast majority of connections).
When a hit is validated, the server pauses the game world, rewinds all character
positions to the moment the client sent the attack (using the packet timestamp),
executes the hit trace, records results, then fast-forwards back to the present.
This is a complex system. We will implement it from scratch with a UActorComponent
that attaches to every character.
⚙ C++ IMPLEMENTATION — ULagCompensationComponent — History Buffer
// LagCompensationComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "[Link].h"
// A snapshot of a character's collision state at a specific server time.
USTRUCT(BlueprintType)
struct FFramePackage
{
GENERATED_BODY()
float Time; // Server world time when this snapshot was taken
FVector CharacterLocation; // Character capsule center
FRotator CharacterRotation; // Character facing direction
UCapsuleComponent* HitBox; // Pointer to the actual capsule (for rewinding)
// For precise bone-level hit detection (optional, more expensive):
TMap<FName, FTransform> BoneTransforms; // Key: bone name, Value: world transform
};
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class MULTIPLAYERCOMBAT_API ULagCompensationComponent : public UActorComponent
{
GENERATED_BODY()
public:
ULagCompensationComponent();
// How many seconds of history to store.
// 300ms handles ~97% of player connections (under 150ms RTT).
UPROPERTY(EditDefaultsOnly, Category="Lag Compensation")
float MaxRecordTime = 0.3f;
// The circular history buffer. TDoubleLinkedList for O(1) front/back access.
TDoubleLinkedList<FFramePackage> FrameHistory;
// Called every server tick to record the current frame.
void RecordFramePackage();
// Core function: Rewinds this character to the given server time.
// Returns the interpolated FFramePackage between the two nearest frames.
FFramePackage GetFrameAtTime(float TargetTime) const;
// Temporarily moves the capsule to the rewound position.
void EnableFramePackage(const FFramePackage& Package);
// Restores the capsule to its current authoritative position.
void DisableFramePackage(const FFramePackage& Package);
// The main entry point called by the combat system.
// HitTime: the server timestamp extracted from the client's attack RPC.
FHitResult ConfirmHit(
const FVector& TraceStart,
const FVector& TraceEnd,
float HitTime);
protected:
virtual void BeginPlay() override;
virtual void TickComponent(
float DeltaTime,
ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
private:
void SaveFramePackage(FFramePackage& Package);
FFramePackage InterpBetweenFrames(
const FFramePackage& OlderFrame,
const FFramePackage& YoungerFrame,
float TargetTime) const;
};
⚙ C++ IMPLEMENTATION — ULagCompensationComponent — Implementation
// [Link]
#include "LagCompensationComponent.h"
#include "GameFramework/Character.h"
#include "Components/CapsuleComponent.h"
#include "DrawDebugHelpers.h"
void ULagCompensationComponent::TickComponent(
float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// Only the server maintains the history buffer.
// Clients don't need to store this data.
if (!GetOwner()->HasAuthority()) return;
RecordFramePackage();
}
void ULagCompensationComponent::RecordFramePackage()
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!Character) return;
FFramePackage ThisFrame;
SaveFramePackage(ThisFrame);
[Link](ThisFrame);
// Remove frames older than MaxRecordTime to prevent unbounded memory growth.
// This keeps our buffer at exactly MaxRecordTime seconds of history.
float HistoryLength = 0.0f;
if ([Link]() > 1)
{
HistoryLength = [Link]()->GetValue().Time
- [Link]()->GetValue().Time;
}
while (HistoryLength > MaxRecordTime && [Link]())
{
[Link]([Link]());
if ([Link]() > 1)
{
HistoryLength = [Link]()->GetValue().Time
- [Link]()->GetValue().Time;
}
}
}
void ULagCompensationComponent::SaveFramePackage(FFramePackage& Package)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!Character) return;
[Link] = GetWorld()->GetTimeSeconds();
[Link] = Character->GetActorLocation();
[Link] = Character->GetActorRotation();
[Link] = Character->GetCapsuleComponent();
}
FFramePackage ULagCompensationComponent::GetFrameAtTime(float TargetTime) const
{
// Walk the history buffer to find the two frames that bracket TargetTime.
if ([Link]() == nullptr)
return FFramePackage();
// Clamp to oldest/newest available frames
if (TargetTime <= [Link]()->GetValue().Time)
return [Link]()->GetValue();
if (TargetTime >= [Link]()->GetValue().Time)
return [Link]()->GetValue();
// Find the two nearest frames
auto Younger = [Link]();
auto Older = [Link]();
while (Older->GetValue().Time > TargetTime)
{
if (!Older->GetNextNode()) break;
Older = Older->GetNextNode();
}
Younger = Older->GetPrevNode() ? Older->GetPrevNode() : Older;
// Exact match
if (Older->GetValue().Time == TargetTime)
return Older->GetValue();
return InterpBetweenFrames(
Older->GetValue(), Younger->GetValue(), TargetTime);
}
FFramePackage ULagCompensationComponent::InterpBetweenFrames(
const FFramePackage& OlderFrame,
const FFramePackage& YoungerFrame,
float TargetTime) const
{
// Calculate interpolation alpha
float TotalTime = [Link] - [Link];
float InterpFraction = FMath::Clamp(
(TargetTime - [Link]) / TotalTime, 0.0f, 1.0f);
FFramePackage InterpFrame;
[Link] = TargetTime;
[Link] = FMath::Lerp(
[Link], [Link], InterpFraction);
[Link] = FMath::Lerp(
[Link], [Link], InterpFraction);
[Link] = [Link];
return InterpFrame;
}
FHitResult ULagCompensationComponent::ConfirmHit(
const FVector& TraceStart,
const FVector& TraceEnd,
float HitTime)
{
// Must only run on server
if (!GetOwner()->HasAuthority()) return FHitResult();
FFramePackage FrameToCheck = GetFrameAtTime(HitTime);
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!Character || ![Link]) return FHitResult();
// --- REWIND ---
// Save current authoritative position
FVector CurrentLocation = Character->GetActorLocation();
FRotator CurrentRotation = Character->GetActorRotation();
// Move the capsule to the historical position
Character->SetActorLocationAndRotation(
[Link], [Link],
false, nullptr, ETeleportType::TeleportPhysics);
// --- TRACE ---
FHitResult HitResult;
FCollisionQueryParams Params;
[Link](GetOwner()); // Don't hit ourselves
GetWorld()->LineTraceSingleByChannel(
HitResult, TraceStart, TraceEnd,
ECC_Visibility, Params);
// --- RESTORE ---
// CRITICAL: Always restore, even if no hit. Never leave the world in rewind state.
Character->SetActorLocationAndRotation(
CurrentLocation, CurrentRotation,
false, nullptr, ETeleportType::TeleportPhysics);
return HitResult;
}
⚠ CRITICAL WARNING
The rewind window (MaxRecordTime) must be tuned for your expected player latency.
At 300ms, you compensate for players up to 150ms RTT. Consider adjusting per-player
based on their measured ping (available via APlayerState::GetPing()).
Lag compensation can be exploited by players who artificially inflate their ping.
Implement a maximum lag compensation threshold (e.g., refuse to compensate beyond
200ms) and kick players who consistently report impossible latencies.
Chapter 10 — Animation Montages & Networked Hit Detection
▶ THEORY
Animation Montages are UE5's system for playing complex, priority-controlled animation
sequences. In melee combat, each attack is a Montage. The challenge: Montages play on
clients via animation systems, but hitbox activation must be authoritative on the server.
The correct pattern is: Animation Notifies drive hitbox activation. A Notify fires at the
exact frame where the weapon should deal damage. On the server, this Notify triggers
the actual hit trace. On clients, it triggers VFX and sound.
The critical mistake beginners make: calling the RPC from the client when the animation
Notify fires. This introduces round-trip latency. Instead, the server should track the
attack's phase and activate hitboxes at the correct time server-side.
10.1 The Attack Montage Pipeline
◈ BLUEPRINT LOGIC
SETUP IN ANIMATION BLUEPRINT:
1. Create an AnimMontage for each attack (LightAttack_1, LightAttack_2,
HeavyAttack_1, etc.).
2. Add ANS_ActivateHitbox AnimNotifyState that spans the frames where the weapon
should deal damage. This notify has a Begin and End event.
3. Add AN_ComboWindowOpen AnimNotify at the frame where the player can input the
next combo hit.
4. In the Animation Blueprint's Event Graph: Bind to these notifies.
SERVER-SIDE HITBOX ACTIVATION:
On the server Character, override the AnimNotify reception. In the C++ notify class, call
GetOwner()->HasAuthority() before performing any traces. If authority, execute the
hitbox sweep. If not, execute VFX only.
MULTICAST FOR VISUAL SYNC:
After confirming a hit server-side, call Multicast_PlayHitReaction on the struck
character so all clients see the stagger animation simultaneously.
⚙ C++ IMPLEMENTATION — UAN_ActivateHitbox — Animation Notify State
// AN_ActivateHitbox.h
#pragma once
#include "CoreMinimal.h"
#include "Animation/AnimNotifies/AnimNotifyState.h"
#include "AN_ActivateHitbox.generated.h"
UCLASS()
class MULTIPLAYERCOMBAT_API UAN_ActivateHitbox : public UAnimNotifyState
{
GENERATED_BODY()
public:
// The weapon hitbox to activate (set in the montage editor)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Hitbox")
FName WeaponSocketName = "weapon_tip";
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Hitbox")
float HitboxRadius = 35.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Hitbox")
float HitboxReach = 120.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Hitbox")
float BaseDamage = 25.0f;
virtual void NotifyBegin(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation, float TotalDuration,
const FAnimNotifyEventReference& EventReference) override;
virtual void NotifyTick(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation, float FrameDeltaTime,
const FAnimNotifyEventReference& EventReference) override;
virtual void NotifyEnd(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation,
const FAnimNotifyEventReference& EventReference) override;
private:
// Track which actors we've already hit this swing (prevent multi-hit spam)
TSet<AActor*> HitActorsThisSwing;
};
⚙ C++ IMPLEMENTATION — UAN_ActivateHitbox — Server-Authoritative Hit Trace
void UAN_ActivateHitbox::NotifyBegin(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation, float TotalDuration,
const FAnimNotifyEventReference& EventReference)
{
[Link]();
// Nothing else needed at Begin — we trace every tick during the active window
}
void UAN_ActivateHitbox::NotifyTick(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation, float FrameDeltaTime,
const FAnimNotifyEventReference& EventReference)
{
if (!MeshComp || !MeshComp->GetOwner()) return;
AMyCharacter* AttackerChar = Cast<AMyCharacter>(MeshComp->GetOwner());
if (!AttackerChar) return;
// CRITICAL: Only execute hit detection on the server.
// On clients: trust the server's RepNotify to update health.
if (!AttackerChar->HasAuthority()) return;
// Get weapon socket world position for trace origin
FVector TraceStart = MeshComp->GetSocketLocation(WeaponSocketName);
FVector WeaponForward = MeshComp->GetSocketRotation(WeaponSocketName).Vector();
FVector TraceEnd = TraceStart + (WeaponForward * HitboxReach);
// Sphere sweep — more forgiving than a line trace for melee
TArray<FHitResult> HitResults;
FCollisionQueryParams Params;
[Link](AttackerChar); // Don't hit the attacker
GetWorld()->SweepMultiByChannel(
HitResults,
TraceStart,
TraceEnd,
FQuat::Identity,
ECC_Pawn,
FCollisionShape::MakeSphere(HitboxRadius),
Params);
for (const FHitResult& Hit : HitResults)
{
AMyCharacter* VictimChar = Cast<AMyCharacter>([Link]());
if (!VictimChar) continue;
if ([Link](VictimChar)) continue; // Already hit
// Register hit to prevent multi-application
[Link](VictimChar);
// Apply lag-compensated hit validation
if (ULagCompensationComponent* LagComp =
VictimChar->FindComponentByClass<ULagCompensationComponent>())
{
// Use the attacker's network timestamp for compensation
float HitTime = AttackerChar->GetLastAttackTimestamp();
FHitResult ConfirmedHit = LagComp->ConfirmHit(
TraceStart, TraceEnd, HitTime);
if ([Link])
{
// Apply damage on the server
VictimChar->TakeCombatDamage(BaseDamage, AttackerChar);
// Notify the attacker's client of hit confirmation
if (AMyPlayerController* PC =
Cast<AMyPlayerController>(AttackerChar->GetController()))
{
PC->Client_ShowHitConfirmation(
[Link], BaseDamage);
}
}
}
}
}
void UAN_ActivateHitbox::NotifyEnd(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation,
const FAnimNotifyEventReference& EventReference)
{
[Link]();
}
Chapter 11 — The Replicated Combat State Machine
▶ THEORY
Fast-paced melee systems require a state machine to manage: which attacks can chain
from which states, when the player has invincibility frames, when they are staggered
and cannot act, and when a parry can be triggered.
This state machine must be replicated. The server owns the authoritative state. Clients
predict their own state transitions locally. If the server disagrees with the client's
predicted state, a correction is sent.
We will implement this as a component-based system using a replication-safe enum state
approach. Advanced implementations use the Gameplay Ability System (GAS) which is
covered after this chapter.
⚙ C++ IMPLEMENTATION — UCombatStateComponent — State Machine
// CombatStateComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "[Link].h"
UENUM(BlueprintType)
enum class ECombatState : uint8
{
Idle UMETA(DisplayName="Idle"),
Attacking UMETA(DisplayName="Attacking"),
ComboWindow UMETA(DisplayName="ComboWindow"), // Can input next attack
Staggered UMETA(DisplayName="Staggered"),
Parrying UMETA(DisplayName="Parrying"),
IFrames UMETA(DisplayName="IFrames"), // Invincibility frames
Dead UMETA(DisplayName="Dead"),
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
FOnCombatStateChanged, ECombatState, OldState, ECombatState, NewState);
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class MULTIPLAYERCOMBAT_API UCombatStateComponent : public UActorComponent
{
GENERATED_BODY()
public:
UCombatStateComponent();
UPROPERTY(ReplicatedUsing=OnRep_CombatState, BlueprintReadOnly, Category="Combat")
ECombatState CurrentState;
UPROPERTY(BlueprintAssignable, Category="Combat")
FOnCombatStateChanged OnCombatStateChanged;
// Combo tracking
UPROPERTY(Replicated, BlueprintReadOnly, Category="Combat")
int32 ComboIndex; // Which attack in the combo sequence we're on
UPROPERTY(Replicated, BlueprintReadOnly, Category="Combat")
int32 MaxComboLength; // Maximum hits in current combo
// Server-callable state transitions. Always check CanTransitionTo first.
bool TryTransitionTo(ECombatState NewState);
bool CanTransitionTo(ECombatState NewState) const;
// Transition rules table
bool IsValidTransition(ECombatState From, ECombatState To) const;
// Called by the animation system when a combo window opens
void OnComboWindowOpen();
// Called when an attack montage ends
void OnAttackMontageComplete();
// Check if the character is in invincible frames
bool IsInvincible() const { return CurrentState == ECombatState::IFrames; }
UFUNCTION()
void OnRep_CombatState();
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
⚙ C++ IMPLEMENTATION — UCombatStateComponent — Transition Logic
bool UCombatStateComponent::IsValidTransition(
ECombatState From, ECombatState To) const
{
// This is the heart of the combat state machine.
// Define EXACTLY which transitions are legal.
// Any unlisted transition is ILLEGAL and will be rejected.
switch (From)
{
case ECombatState::Idle:
// From idle: can begin an attack or a parry
return To == ECombatState::Attacking || To == ECombatState::Parrying;
case ECombatState::Attacking:
// During attack: can be staggered by a hit, or reach combo window
return To == ECombatState::ComboWindow
|| To == ECombatState::Staggered
|| To == ECombatState::Dead
|| To == ECombatState::Idle; // Attack finished, no combo
case ECombatState::ComboWindow:
// Combo window: can attack again (chain), be staggered, or timeout to idle
return To == ECombatState::Attacking
|| To == ECombatState::Staggered
|| To == ECombatState::Dead
|| To == ECombatState::Idle;
case ECombatState::Staggered:
// Staggered: can recover to idle or die
return To == ECombatState::Idle || To == ECombatState::Dead;
case ECombatState::Parrying:
// Parry: successful parry grants IFrames, failed transitions to stagger
return To == ECombatState::IFrames
|| To == ECombatState::Staggered
|| To == ECombatState::Idle;
case ECombatState::IFrames:
// IFrames: can attack out of IFrames or return to idle
return To == ECombatState::Attacking || To == ECombatState::Idle;
case ECombatState::Dead:
return false; // Dead characters stay dead
}
return false;
}
bool UCombatStateComponent::TryTransitionTo(ECombatState NewState)
{
// GUARD: State transitions must only execute on the server.
if (!GetOwner()->HasAuthority()) return false;
if (!CanTransitionTo(NewState))
{
UE_LOG(LogTemp, Warning,
TEXT("Invalid state transition: %s -> %s"),
*UEnum::GetValueAsString(CurrentState),
*UEnum::GetValueAsString(NewState));
return false;
}
ECombatState OldState = CurrentState;
CurrentState = NewState;
// CurrentState is replicated — clients will receive this change
// and OnRep_CombatState will fire on each client.
[Link](OldState, NewState);
return true;
}
void UCombatStateComponent::OnRep_CombatState()
{
// Fires on clients when the server changes CurrentState.
// Dispatch the delegate so UI and animation systems can react.
[Link](ECombatState::Idle, CurrentState);
// NOTE: The 'old state' parameter is not reliable here without caching.
// For precise old-state tracking, cache CurrentState before replication.
}
⚡ OPTIMIZATION RULES
RULE: Never execute TryTransitionTo on a client. It will silently fail due to the
HasAuthority check. All state transitions are initiated by the server — either directly or
in response to a validated Server RPC from the client.
RULE: Implement a maximum combo timeout. If the player does not input the next
attack within N seconds, automatically transition back to Idle. This prevents edge cases
where a player dies mid-combo and their state machine gets stuck in ComboWindow.
RULE: I-Frames must be enforced at the server level in TakeCombatDamage. If
IsInvincible() returns true when damage is received, reject the damage call entirely. Do
not rely on clients to report their own invincibility — they will lie.
PHASE 5: Scaling to 100 Players — Optimization &
the Iris System
Chapter 12 — Legacy Replication vs. Iris: The Architecture Decision
▶ THEORY
Unreal Engine's legacy replication system (pre-UE5.1) uses a single-threaded model. The
server iterates over every Actor marked for replication every tick, evaluates relevancy
for every client, and generates packets. At 100 players with 200+ Actors each, this loop
becomes the performance bottleneck.
Iris (introduced in UE5.1, production-ready in UE5.5+) is a complete rewrite of the
replication system. It moves replication processing to a dedicated thread, uses a data-
oriented design for cache efficiency, and implements a fragment-based delta
compression system.
Iris is not a drop-in replacement. Enabling it requires code changes and testing.
However, for a 100-player deployment, it is mandatory — legacy replication cannot
sustain this player count without the server tick rate collapsing below 10Hz.
12.1 Enabling Iris in Your Project
⚙ C++ IMPLEMENTATION — [Link] — Iris Configuration
; Add to [Link]
[SystemSettings]
; Enable the Iris replication system
[Link]=1
; Enable Iris push model (marks dirty only changed properties,
; rather than scanning all replicated properties every frame)
[Link]=1
; Set the number of Iris replication threads
; Recommend: physical CPU cores - 2 (leave cores for game thread and render thread)
[Link]=6
; Iris uses a connection-based update budget. This limits how many bytes
; per frame are sent to each client, preventing bandwidth spikes.
[Link]=8192
[/Script/[Link]]
; Maximum simultaneous connections (must match your server licensing)
MaxSimultaneousConnections=100
; Total bandwidth budget for the server (bytes per second)
; 100 players * 8KB/s each = 800KB/s minimum headroom
TotalNetBandwidth=1048576 ; 1 MB/s
; Per-player bandwidth budget
MaxDynamicBandwidth=8192 ; 8 KB/s per player
MinDynamicBandwidth=4096 ; 4 KB/s minimum
12.2 Migrating Your Actor to Iris Push Model
▶ THEORY
The Push Model changes how dirty state is tracked. In the legacy system, the engine
scans all replicated properties every frame to detect changes — an O(n) scan per Actor
per tick.
With the Push Model, YOU are responsible for marking properties as dirty when you
change them. This sounds like more work, but it enables a massive optimization: the
replication system only processes Actors with dirty properties, skipping everything else.
⚙ C++ IMPLEMENTATION — Push Model — Marking Properties Dirty
// In your module's .[Link], add the Push Model dependency:
// [Link]("NetCore");
#include "Net/Core/PushModel/PushModel.h"
// When you change a replicated property that uses Push Model,
// you MUST call MARK_PROPERTY_DIRTY to notify Iris.
void AMyCharacter::TakeCombatDamage(float DamageAmount, AMyCharacter* Instigator)
{
if (!HasAuthority()) return;
float OldHealth = CurrentHealth;
CurrentHealth = FMath::Max(0.0f, CurrentHealth - DamageAmount);
// MARK_PROPERTY_DIRTY tells Iris this property changed and must be replicated.
// Without this, Iris will NEVER send the updated value in Push Model mode.
MARK_PROPERTY_DIRTY_FROM_NAME(AMyCharacter, CurrentHealth, this);
if (CurrentHealth <= 0.0f && OldHealth > 0.0f)
{
CombatState = 7; // Dead
MARK_PROPERTY_DIRTY_FROM_NAME(AMyCharacter, CombatState, this);
HandleDeath(Instigator);
}
}
// In GetLifetimeReplicatedProps, add WITH_PUSH_MODEL flag:
void AMyCharacter::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// FDoRepLifetimeParams enables Push Model per-property
FDoRepLifetimeParams PushParams;
[Link] = true;
DOREPLIFETIME_WITH_PARAMS(AMyCharacter, CurrentHealth, PushParams);
DOREPLIFETIME_WITH_PARAMS(AMyCharacter, CombatState, PushParams);
DOREPLIFETIME_WITH_PARAMS(AMyCharacter, MaxHealth, PushParams);
}
Chapter 13 — The Replication Graph: Spatial Relevancy at Scale
▶ THEORY
Even with Iris, a 100-player server cannot send every Actor's state to every player every
frame. A player in the northwest corner of the map does not need 60Hz updates about
the health of a player in the southeast corner.
The Replication Graph is the system that determines WHICH Actors are relevant to
WHICH clients. Without it, the server defaults to sending all replicated Actors to all
clients — catastrophically expensive.
The Replication Graph organizes Actors into spatial cells (a grid or quadtree). Each
client only receives updates for Actors within their relevancy distance. This can reduce
per-server replication workload by 80-95% in large-world games.
⚙ C++ IMPLEMENTATION — UMyReplicationGraph — Spatial Grid Setup
// MyReplicationGraph.h
#pragma once
#include "CoreMinimal.h"
#include "ReplicationGraph.h"
#include "[Link].h"
UCLASS(Transient, config=Engine)
class UMyReplicationGraph : public UReplicationGraph
{
GENERATED_BODY()
public:
virtual void InitGlobalActorClassSettings() override;
virtual void InitGlobalGraphNodes() override;
virtual void InitConnectionGraphNodes(
UNetReplicationGraphConnection* RepGraphConnection) override;
virtual void RouteAddNetworkActorToNodes(
const FNewReplicatedActorInfo& ActorInfo,
FGlobalActorReplicationInfo& GlobalInfo) override;
virtual void RouteRemoveNetworkActorToNodes(
const FNewReplicatedActorInfo& ActorInfo) override;
// The grid node handles spatial relevancy for most gameplay actors
UPROPERTY()
UReplicationGraphNode_GridSpatialization2D* GridNode;
// Always-relevant node for globally important actors (GameState, etc.)
UPROPERTY()
UReplicationGraphNode_ActorList* AlwaysRelevantNode;
// Per-connection node for player-owned actors (PlayerController, etc.)
TArray<UReplicationGraphNode_AlwaysRelevant_ForConnection*>
AlwaysRelevantForConnectionNodes;
};
// ── Implementation ──────────────────────────────────────────────────────
void UMyReplicationGraph::InitGlobalGraphNodes()
{
// --- Always-Relevant Node ---
// Actors placed here replicate to ALL clients, always.
// Use sparingly: GameState, GlobalEventActors, etc.
AlwaysRelevantNode = CreateNewNode<UReplicationGraphNode_ActorList>();
AddGlobalGraphNode(AlwaysRelevantNode);
// --- Spatial Grid Node ---
// This is the core of large-world optimization.
GridNode = CreateNewNode<UReplicationGraphNode_GridSpatialization2D>();
// CellSize: the size of each grid cell in Unreal units (cm).
// 20000 units = 200 meters. Tune based on your map size.
GridNode->CellSize = 20000.0f;
// SpatialBias: offset so origin (0,0,0) is not at a cell boundary.
// Prevents edge-case relevancy flickering for Actors near the map center.
GridNode->SpatialBias = FVector2D(-150000.0f, -150000.0f);
AddGlobalGraphNode(GridNode);
}
void UMyReplicationGraph::InitGlobalActorClassSettings()
{
Super::InitGlobalActorClassSettings();
// Define routing rules for each Actor class.
// This tells the Replication Graph HOW to handle each class.
auto SetCullDistForClass = [&](UClass* Class, float CullDist)
{
FClassReplicationInfo ClassInfo;
[Link](CullDist * CullDist);
[Link](Class, ClassInfo);
};
// Characters: visible at 150 meters for dense 100-player maps
SetCullDistForClass(AMyCharacter::StaticClass(), 15000.0f);
// Projectiles: smaller cull distance since they move fast
// SetCullDistForClass(AMyProjectile::StaticClass(), 5000.0f);
}
void UMyReplicationGraph::RouteAddNetworkActorToNodes(
const FNewReplicatedActorInfo& ActorInfo,
FGlobalActorReplicationInfo& GlobalInfo)
{
// Route each newly spawned Actor to the correct graph node.
// GameState and PlayerState: always relevant to all clients
if ([Link]->IsA<AGameStateBase>() ||
[Link]->IsA<APlayerState>())
{
AlwaysRelevantNode->NotifyAddNetworkActor(ActorInfo);
return;
}
// Player Characters: spatially relevant via the grid
if ([Link]->IsA<AMyCharacter>())
{
GridNode->AddActor_Dormancy(ActorInfo, GlobalInfo);
return;
}
// Default: put everything else in the grid
GridNode->AddActor_Dormancy(ActorInfo, GlobalInfo);
}
13.1 Registering Your Replication Graph
⚙ C++ IMPLEMENTATION — [Link] — Enable Your Graph
; Register your custom Replication Graph class.
; Without this, UE5 uses the default (no spatial optimization).
[/Script/[Link]]
ReplicationDriverClassName="/Script/[Link]"
Chapter 14 — Net Dormancy, Update Frequency, and Network LOD
▶ THEORY
Three tuning mechanisms prevent your server from wasting cycles on irrelevant
replication: Net Dormancy, Net Update Frequency, and Network LOD.
NET DORMANCY: An Actor set to DORM_Dormant stops replicating entirely until the
server explicitly wakes it up with FlushNetDormancy(). A chest that has been opened
does not need to replicate every frame — it can sleep until its state changes.
NET UPDATE FREQUENCY: The rate (Hz) at which an Actor sends replication data.
Characters need high frequency (66-100Hz). Environment objects need almost none
(0.1Hz — once per 10 seconds).
NETWORK LOD: Reducing replication detail based on client distance. A character 150
meters away does not need 100Hz health updates — 5Hz is sufficient. This is
implemented via NetUpdateFrequency scaling in the Replication Graph.
⚙ C++ IMPLEMENTATION — Net Dormancy & Update Frequency Configuration
// In your Actor's constructor, set the appropriate update rate.
// For a player character: high frequency for smooth gameplay
AMyCharacter::AMyCharacter()
{
// Replicate 66 times per second. At 100 players this is
// 6600 potential replication evaluations per second server-side.
// The Replication Graph culls this to only nearby players.
NetUpdateFrequency = 66.0f;
MinNetUpdateFrequency = 33.0f;
SetReplicates(true);
// Characters should NOT be dormant — they are always moving
NetDormancy = DORM_Never;
}
// For a pickup/collectible that rarely changes:
AMyPickup::AMyPickup()
{
NetUpdateFrequency = 1.0f; // Once per second is plenty
MinNetUpdateFrequency = 0.1f;
SetReplicates(true);
// Start dormant — only wake when collected or respawning
NetDormancy = DORM_DormantAll;
}
// Server code to wake a dormant actor when its state changes:
void AMyPickup::OnPickedUp(AMyCharacter* Collector)
{
if (!HasAuthority()) return;
bIsPickedUp = true;
MARK_PROPERTY_DIRTY_FROM_NAME(AMyPickup, bIsPickedUp, this);
// FlushNetDormancy wakes the Actor for ONE replication cycle,
// sending the state change to all clients, then returns to sleep.
FlushNetDormancy();
}
// Network LOD in Replication Graph — reduce frequency by distance:
// In InitGlobalActorClassSettings():
FClassReplicationInfo CharInfo;
// Add distance-based update frequency scaling
[Link] = 1; // Every frame when nearby
// At max cull distance, update every 20 frames (~3Hz at 60fps server)
[Link] = 20;
[Link](AMyCharacter::StaticClass(), CharInfo);
Chapter 15 — Bandwidth Profiling with Unreal Insights
▶ THEORY
Unreal Insights is Epic's profiling suite. It captures frame-by-frame data about CPU
usage, GPU usage, and — critically for networking — packet data, channel counts, and
replication overhead.
For a 100-player server, your profiling targets are: server tick rate must stay above
30Hz (ideally 60Hz), packet size per player must stay below 8KB/s, and total server CPU
must stay below 70% to allow headroom for spike events (combat, explosions).
◈ Unreal Insights: Network Session Capture
# Launch the Insights trace server before starting your dedicated server.
# Insights collects data into a .utrace file for post-session analysis.
# Start the Insights server on your development machine:
[Link] -RecordOnStart
# Launch your dedicated server with tracing enabled:
[Link] /Game/Maps/TestMap -server -log -port=7777 ^
-trace=Net,CPU,Frame,Bookmark ^
-tracehost=[Link]
# -trace=Net: Captures packet sizes, channel open/close, RPC calls
# -trace=CPU: Captures function-level timing
# -trace=Frame: Captures server tick rate over time
# -trace=Bookmark: Captures custom timing markers you add in code
# Connect Insights UI to visualize live data:
[Link]
# In the UI: Session Browser -> connect to [Link]:1980
15.1 Key Network Console Commands for Live Debugging
Command Purpose
[Link] 1 Overlay: packets sent/received,
incoming/outgoing bandwidth per second
[Link] 1 Visual ping and packet loss graph on
screen
DumpRepGraph Print the current Replication Graph state
to log — shows all nodes and their Actor
lists
[Link] Log all Actors currently being replicated
and their update rates
[Link] 0.05 Simulate 5% packet loss for robustness
testing
[Link] 100 Simulate 100ms additional latency for lag
compensation testing
Stat Net Show network statistics overlay including
bytes sent/received
Stat NetChan Show active channel count — high channel
count = high replication cost
15.2 Adding Custom Trace Bookmarks
⚙ C++ IMPLEMENTATION — Custom Insights Bookmarks in Combat Code
#include "ProfilingDebugging/MiscTrace.h"
void AMyCharacter::TakeCombatDamage(float DamageAmount, AMyCharacter* Instigator)
{
if (!HasAuthority()) return;
// Add a trace bookmark — appears as a vertical line in Insights timeline.
// Useful for correlating damage spikes with replication cost spikes.
TRACE_BOOKMARK(TEXT("DamageApplied_%s"), *GetName());
// ... rest of damage logic
}
// For timing a specific block of replication code:
void UMyReplicationGraph::RouteAddNetworkActorToNodes(
const FNewReplicatedActorInfo& ActorInfo,
FGlobalActorReplicationInfo& GlobalInfo)
{
TRACE_CPUPROFILER_EVENT_SCOPE(MyRepGraph_RouteActor);
// ... routing logic — this block will show up in the CPU trace
}
Chapter 16 — Server Architecture: Deployment at Scale
▶ THEORY
A production 100-player environment requires more than a correctly-coded game. The
server infrastructure itself must be designed for reliability, geographic distribution, and
cost efficiency.
Modern game server deployments use containerized dedicated server binaries (Docker)
orchestrated by a game server management layer (Agones on Kubernetes, or a managed
solution like AWS GameLift or Hathora). The dedicated server binary you compiled in
Phase 1 is the artifact that gets containerized.
Component Recommendation Purpose
Server Binary Shipping Server target No debug overhead,
stripped symbols
OS Ubuntu 22.04 LTS Lightweight, well-
supported, no GUI
Container Docker (debian:slim base) Reproducible deployments
Orchestration AWS GameLift / Hathora / Auto-scaling, matchmaking,
Agones session management
Monitoring Prometheus + Grafana Server tick rate, player
count, crash alerting
Region Multi-region CDN Players connect to nearest
server (<80ms target)
◈ Dockerfile: UE5 Dedicated Server
# Minimal Dockerfile for your UE5 dedicated server
FROM ubuntu:22.04
# Install UE5 Linux server runtime dependencies
RUN apt-get update && apt-get install -y \
libssl-dev \
libcurl4-openssl-dev \
libc6 \
&& rm -rf /var/lib/apt/lists/*
# Copy the compiled server binary
# (Cross-compile from Windows to Linux using the Linux toolchain)
COPY LinuxServer /app/server
WORKDIR /app/server
# Server port
EXPOSE 7777/udp
# Start the server
CMD ["./[Link]",
"/Game/Maps/MainMap",
"-server",
"-port=7777",
"-MaxPlayers=100",
"-log"]
⚡ OPTIMIZATION RULES
RULE: Cross-compile for Linux from Windows using the Linux Toolchain provided by
Epic. Running a Windows dedicated server is possible but costs significantly more on
cloud providers — Linux VMs are 30-40% cheaper for equivalent hardware.
RULE: Set the server tick rate ([Link]) to match your game's requirements.
For fast-paced melee at 100 players, 60Hz server tick is the minimum. Below 30Hz, lag
compensation accuracy degrades and combat feels wrong.
RULE: Use UDP transport. TCP's head-of-line blocking is lethal for real-time games. UE5
uses UDP by default. Never switch to TCP for game traffic.
RULE: Enable hardware accelerated encryption (DTLS) for your game sessions if you are
shipping a competitive title. Raw UDP traffic is trivially interceptable and packet-
injectable without encryption.
RULE: Budget your bandwidth. 100 players * 8 KB/s send rate = 800 KB/s uplink
minimum. A standard server NIC provides 1 Gbps = 125 MB/s. Bandwidth is not your
bottleneck — CPU is. Profile CPU first.
Chapter 17 — Final Architecture Review: The Complete 100-Player
Stack
▶ THEORY
Every component of this architecture is designed to work together. A change in one
layer affects all others. This final chapter presents the complete system as an integrated
whole.
Layer Technology Key Constraint
Input CMC Prediction + Must include all custom
SavedMove flags
Movement Custom CMC + Dash/Dodge All logic must run in
modes PerformMovement
Combat Input Server RPC + Validation Never trust client-provided
hit data
Hit Detection AnimNotify + Sphere Only execute on
Sweep (server) HasAuthority()
Lag Compensation LagCompensationCompone Max 300ms rewind, kick
nt + History Buffer high-pingers
State Machine CombatStateComponent + All transitions server-
TryTransitionTo authoritative
Health/Damage Replicated UPROPERTY + MARK_PROPERTY_DIRTY
Push Model on every change
Death/Respawn NetMulticast + Multicast for VFX;
[Link] GameMode for logic
Replication Culling Replication Graph + Spatial Tune CellSize to map scale
Grid
Dormancy DORM_DormantAll + All static/rare Actors must
FlushNetDormancy sleep
Bandwidth Iris + Push Model + Character: 66Hz; Pickups:
NetUpdateFrequency 1Hz
Profiling Unreal Insights + [Link] Profile every major system
change
Deployment Linux Docker + AWS Shipping Server target, no
GameLift logs in prod
This architecture, implemented correctly and profiled continuously, will sustain 100
simultaneous players in a fast-paced melee combat environment with sub-frame-
accurate hit detection, zero-latency-feel movement prediction, and server tick rates
above 60Hz under full player load.
The systems in this textbook represent the same architectural patterns used in
production multiplayer action games. The differences between commercial
implementations and what you have built here are refinements of detail — additional
animation layers, more sophisticated matchmaking, broader platform support — not
fundamental architectural differences.
END OF TEXTBOOK