Samuel Bouchet
Game developer at Lonestone game studio

Two Worlds, One Game: Networking in Unreal Engine

07/07/2026

A mental model for listen-server coop in Unreal Engine 5.7 — authority, replicated variables, OnRep quirks, data-first design, and what GAS replicates for you.

Two copies of the world: the listen server owns the truth, the client renders a replicated copy. Two copies of the world: the listen server owns the truth, the client renders a replicated copy.

A networked game is two worlds pretending to be one. The server holds the real one; the client holds an approximate, slightly stale copy. Nearly every replication bug I've written comes down to forgetting which copy a given line of code was running in.

I'm currently working on a two-player coop game in Unreal Engine 5.7. One player hosts, the other joins: a listen server and a single client, the smallest multiplayer setup that exists. No dedicated server binary, no matchmaking fleet, no 32-player relevancy tuning. It turns out to be exactly the right size to build a correct mental model of Unreal networking — every concept is still there, with none of the noise.

This article is that mental model. It's written for the two-player listen-server case, but everything generalizes.

Two worlds, one game

When player 2 joins, there are two processes running your game:

  • The listen server: the authoritative world, plus player 1's rendering, input and UI, all in the same process. The host is a player and the server.
  • The client: a separate process that holds a replica of the world — approximate, and always slightly late.

Everything else follows from one rule: replication flows one way, server to client. The server pushes actor spawns, destroys, and property values downstream. The client never replicates anything back. The only thing that travels upward is events — input relayed through Server RPCs, which we'll get to.

Client code is technically able to write to a replicated variable: nothing crashes and nothing is sent. The client's copy silently diverges from the truth — and it stays diverged until the server next changes that property, because replication only sends changes. The most confusing category of networking bug looks exactly like this: a value that is wrong on one screen only, and only sometimes.

The second thing to internalize is that the two worlds don't contain the same objects. The framework classes each live in specific places:

ObjectListen server (player 1)Client (player 2)
GameModeexistsdoes not exist
GameStateauthoritativereplica
PlayerController of P1existsdoes not exist
PlayerController of P2existsexists (owned by P2)
PlayerState of P1 and P2authoritativereplicas
Pawnsauthoritativereplicas
HUD, widgetsP1's onlyP2's only

This table answers "where do I put this code?" by itself. Game rules go in the GameMode: they physically cannot run on a client, because the class isn't there. State that everyone must see goes in GameState (match phase, objectives) or PlayerState (score, character selection). Anything UI is local by construction. And a PlayerController is a private object: player 2 cannot see player 1's controller, so it's the right place for "my player only" state like camera or input mode — and the wrong place for anything the other player needs to read.

Who's in charge: authority, roles, ownership

Three different concepts get collapsed into "who owns this" in casual conversation, and confusing them is a classic source of bugs. They are worth splitting properly.

Network authority answers: which world holds the truth for this actor? For every replicated actor — pawns, game state, interactables — the truth lives on the server. The guard for it is HasAuthority(), shorthand for GetLocalRole() == ROLE_Authority, and it's the check you'll write most often: in code that both worlds execute, it tells you whether this copy of the actor is the truth (and may change state) or a replica (and should only display it). On the listen server it returns true for every actor. On the client it returns false for everything that came from the server: most actors are ROLE_SimulatedProxy, and the pawn the local player controls is ROLE_AutonomousProxy (it receives your input directly, which is what makes client-side prediction possible).

So for gameplay code on replicated actors, HasAuthority() is the right check — use it freely. Just know what it answers: "is this copy the truth?", not "am I the server?". The two questions only coincide for replicated actors. An actor that doesn't replicate exists independently in both worlds, each copy authoritative in its own, so HasAuthority() returns true on the client too. When you need the process-level question — typically outside of any replicated actor — ask the world instead: IsNetMode(NM_Client) is false on the listen server, or check GetWorld()->GetAuthGameMode() != nullptr (the GameMode only exists on the server, as the table above says).

Connection ownership answers: which player does this actor belong to, network-wise? Every actor has an Owner pointer; if following it upward reaches a PlayerController, the actor belongs to that player. In our two-player game, the client owns essentially three things: its PlayerController, the pawn it possesses, and anything spawned with one of those as owner. Everything else — the other pawn, and every actor placed in the level: doors, pickups, interactables — is not owned by the client. Ownership doesn't grant any right to modify the actor; what it decides is who may exchange RPCs about it:

Action on a replicated actorOn the listen serverOn the client
Modify replicated stateallowed — this is the truthsilently diverges
Call a Server RPC on itexecutes immediately (already on the server)delivered if the client owns the actor, dropped silently otherwise
Client RPC (sent by the server)executes immediately if it targets the host's own playerexecutes if the client owns the actor
Multicast RPC (sent by the server)executesexecutes
Read replicated statethe truthslightly late copy

A note on the host column: on the listen server, RPCs never touch the network — they are plain function calls, executed on the spot. That's why they're always "allowed" there, and also why RPC mistakes never show up on the host's screen.

The "dropped silently" cell deserves emphasis. A Server RPC called from the client on an actor it doesn't own is not an error, not a crash — the call just never arrives. And since the client owns almost nothing, this rules out putting Server RPCs on world actors: a Server RPC declared on a door would work for the host and silently do nothing for the client. Client events must enter the server through something the client owns — its PlayerController or its pawn. Keep this one in mind; it shapes the reference implementation below.

Possession is the third concept: the PlayerController→Pawn relationship. It's a gameplay notion, but it matters to networking because possessing a pawn is what makes it owned by that player's connection (and ROLE_AutonomousProxy on their machine).

Replicated variables, and the two faces of OnRep

Here is the full C++ recipe for a replicated variable with a change callback:

// .h
UPROPERTY(ReplicatedUsing = OnRep_SelectionMask)
uint8 SelectionMask = 0;

UFUNCTION()
void OnRep_SelectionMask();

// .cpp
void ATAInteractable::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(ATAInteractable, SelectionMask);
}

In C++, OnRep only fires on clients, when the new value arrives over the network. It never fires on the server, which doesn't receive replication. But on a listen server, the host is also a player staring at a screen — if OnRep is what refreshes the visuals, the host never sees them refresh. Hence this idiom, which you'll end up typing in every server-side setter:

void ATAInteractable::SetSelectedBySlot(ETAPlayerSlot Slot, bool bSelected)
{
    const uint8 NewMask = /* ... */;
    if (NewMask == SelectionMask) return;
    SelectionMask = NewMask;
    OnRep_SelectionMask(); // the server never receives replication: call it by hand for the host
    ForceNetUpdate();
}

Now the counterintuitive part: Blueprint RepNotify variables behave the other way around. When you set a RepNotify variable through its setter node, the generated code calls the OnRep function locally, immediately — on the server as well. So a Blueprint OnRep runs on the server AND on clients, while a C++ OnRep runs on clients only:

Server sets the valueClient receives the value
C++ ReplicatedUsingOnRep NOT called (call it manually)OnRep called
Blueprint RepNotifyOnRep called by the setterOnRep called

Neither behavior is wrong — Blueprint is doing for you exactly what the manual C++ idiom does. But if you write both C++ and Blueprint in the same project (everyone does), the asymmetry will bite you at least once. The Blueprint version has its own trap, too: the setter calls OnRep locally even on a client, so a client that writes the variable will happily run its visual update on a diverged value.

Two more properties of OnRep worth engraving:

  • OnRep is change-driven. It fires when the received value differs from the local one. If the server flips a bool to true and back to false between two network updates, the client may never see either. Replication converges toward the latest state; it does not promise to show every intermediate step. If every step matters, a single "current value" variable is the wrong vehicle — replicate a counter or a sequence number instead, so every change is a different value.
  • OnRep also fires on initial replication: when an actor first reaches the client — at map load, or when it comes back into relevancy — the current values arrive and their OnReps run, so visuals driven by OnRep mostly initialize themselves. One exception: initial replication only sends values that differ from the class defaults, and a value equal to its default triggers no OnRep. Calling the OnRep manually in BeginPlay covers that case and guarantees a consistent starting state on every machine.

Beyond the recipe above, a few parameters control what the client receives (the host reads server memory directly and always sees everything):

  • bReplicates — set in the C++ constructor, or the Replicates checkbox in Blueprint class defaults. Without it nothing flows: an actor spawned by the server doesn't even exist on the client.
  • Per-property replication conditions — declared in GetLifetimeReplicatedProps. The plain DOREPLIFETIME used above applies COND_None, the default: the property goes to every client. The DOREPLIFETIME_CONDITION variant restricts it: COND_OwnerOnly (only the owning client — private data like an inventory), COND_SkipOwner (everyone but the owner — when the owner already predicted the change locally), COND_InitialOnly (sent once when the actor reaches the client).
  • Relevancy — the server skips replicating actors it deems irrelevant to a connection, by default based on distance. If a faraway actor seems frozen on the client, this is usually why. bAlwaysRelevant = true (constructor or class defaults) opts important shared actors out of the filtering, cheap determinism in a small coop level.
  • NetUpdateFrequency (actor property) caps how often the server considers the actor for replication; ForceNetUpdate() requests a pass right now. For rarely-changing state, a low frequency plus ForceNetUpdate() in the setter is an efficient combination.

Data first, RPCs last

You have two vehicles for making something happen on another machine: replicated properties carry state, RPCs carry events. My rule for networking in general: put everything you can in replicated state, and keep RPCs for the few things that are genuinely events.

Data-first isn't a networking idea — it's a general way of structuring a game. The game is its data; the display derives from it. Systems read the state and render it; methods and algorithms exist to update the state, not to drive screens. My article on reactive UI is this philosophy applied to UI; the Black Box Sim applies it to a whole simulation. Networking is where it pays off hardest, because Unreal replication is exactly a machinery for keeping copies of data converging across machines: if every screen is a function of the state, then replicating the state replicates the game. One code path — OnRep — drives the host's visuals and the client's visuals alike, and the client's display cannot desync from the game, because it is a view of the game's data.

Replicated state is also simply more robust than calls. A property re-asserts itself: whatever packets were dropped, whatever was momentarily irrelevant, the client's copy converges back to the truth and OnRep fires. An RPC that a machine misses is gone — there is no catch-up. Anything that should still be visible a second later must land in a replicated property.

Some things, though, are genuinely events, and forcing them into variables doesn't work. Input is the canonical one: a button can be pressed and released within the same frame. As a replicated bool, that's a noop — the value ends where it started, no change, no OnRep — yet it must trigger. An event exists at an instant; it isn't state. This leaves RPCs exactly two legitimate jobs:

  • Server RPCs carrying client-side events the server must react to — input, essentially. This is also the only upward channel that exists.
  • Unreliable multicasts for disposable cosmetic events — an impact flash, a footstep — where a missed delivery costs nothing because nothing needs to persist.

The resulting flow, for any player action: an input event on the client enters the server through a Server RPC on an actor the client owns; the server validates it and updates replicated state; both screens react to the state changing. Events at the boundary, data everywhere else.

A reference implementation

Here is a trimmed version of the interactable actor from my current project — the world objects players can select and activate — plus the one RPC that feeds it:

ATAInteractable::ATAInteractable()
{
    bReplicates = true;
    bAlwaysRelevant = true; // small coop level: shared objects always replicate
}

void ATAInteractable::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(ATAInteractable, SelectionMask); // one bit per player slot
}

// Server-only mutation, called by gameplay code running on the server.
void ATAInteractable::SetSelectedBySlot(ETAPlayerSlot Slot, bool bSelected)
{
    const uint8 Bit = 1 << SlotToIndex(Slot);
    const uint8 NewMask = bSelected ? (SelectionMask | Bit) : (SelectionMask & ~Bit);
    if (NewMask == SelectionMask) return;
    SelectionMask = NewMask;
    OnRep_SelectionMask(); // host visuals: the server never receives its own replication
    ForceNetUpdate();      // rarely changes, so push the update out immediately
}

// Runs on the client when the mask arrives, and on the server via the manual call.
void ATAInteractable::OnRep_SelectionMask() const
{
    WidgetComponent->DisplayIconInput(SelectionMask != 0);
    OnSelectionChangedNative.Broadcast();
}

void ATAInteractable::NotifyInteractInputPressed(AActor* Interactor)
{
    if (!HasAuthority()) return; // gameplay reaction is server-only, by contract
    /* ... */
}
// ATACharacter.h — where the input event enters the authoritative world.
// The character is owned by the client, so it may carry a Server RPC.
UFUNCTION(Server, Reliable)
void ServerInteractPressed(ATAInteractable* Interactable);

The interactable itself contains no RPC and no idea of which machine pressed a button. The single RPC of the feature sits on the character — an actor the client owns, as ownership demands — and does nothing but relay the input event to the server. From there everything travels as data: the server flips SelectionMask, and both screens update through the same OnRep_SelectionMask path — the client's when the value arrives over the network, the host's through the manual call. The HasAuthority() guard documents the contract: interaction logic runs in the authoritative world, and its visible consequences travel as replicated state.

Seeing both worlds: PIE setup

The listen server has a dangerous property during development: for the host, everything always works. Zero latency, full authority, every actor present, every RPC a plain function call. The setup to see both worlds is in the Play dropdown of the editor toolbar:

Unreal Editor Play dropdown: Number of Players set to 2, Net Mode submenu showing Play As Listen Server

Number of Players: 2, Net Mode: Play As Listen Server. The first window is the host, the second is a real network client connecting to it. Test on both — they hide different bugs. The host's window won't reveal missing replication, wrong ownership, or client-side writes: everything there is local and authoritative. The client's window won't reveal a forgotten manual OnRep call, which breaks visuals on the host only. It's tempting to iterate against a single window; a feature is done when it has been seen working on both.

Two more settings earn their keep:

  • Network emulation (Play settings, Advanced Settings): add 60–100ms of latency and a little packet loss. On localhost the two worlds are nearly synchronous, which hides the "slightly stale copy" reality this whole article is about. With emulated lag, the divergence windows become visible and reproducible.
  • Run Under One Process is on by default and fine for daily work, but it shares the editor process state between instances. Before trusting a milestone, run at least once with it off — a few categories of bugs (config, singletons, load order) only appear with fully separate processes.

The special case of abilities

If you use the Gameplay Ability System, part of the replication work is done for you — and knowing which part is exactly what keeps GAS pleasant instead of mystifying.

What GAS replicates on its own:

  • Ability grants: given on the server, the specs replicate to the owning client.
  • Activation state: activating, canceling and ending an ability is coordinated between server and owning client by the AbilitySystemComponent, according to the ability's Net Execution Policy — LocalPredicted (client starts immediately, server confirms or rolls back; the default feel-good choice for player actions), ServerInitiated, ServerOnly (the safe choice for anything that mutates world state), LocalOnly.
  • Attributes: an AttributeSet replicates its attributes like any C++ ReplicatedUsing property — which means the same OnRep rules apply, including "clients only" (the GAMEPLAYATTRIBUTE_REPNOTIFY macro exists to make prediction behave in those OnReps).
  • Gameplay cues: GAS's sanctioned channel for cosmetics — effectively managed multicasts, with the same "missed means gone" caveat as any event mechanism.
  • Montages played through ability tasks, and tags applied by gameplay effects.

What GAS does not replicate, ever:

  • Member variables of your ability instances. An ability with LocalPredicted policy runs one instance on the client and one on the server; they do not share state, and a value you compute in one is simply absent from the other.
  • Ability task internal state — same reason.
  • Target data: what the player aimed at is client knowledge, and it must be sent explicitly (ServerSetReplicatedTargetData in the target-data flow) for the server to act on it.
  • Loose gameplay tags added directly on the ASC — local by default, unlike effect-granted tags.

Note how well GAS's shape matches the data-first pattern: abilities carry the events and their validation, attributes and tags carry the state, cues carry the disposable cosmetics. For a two-player coop, set the ASC replication mode to Mixed on player pawns and the defaults do the right thing.


The whole model fits in five lines. There are two worlds, and only one of them is true. State flows down, events flow up. The server changes the data; every screen — the host's included — derives what it shows from the data. Ownership decides which actors may carry a client's events to the server. And when something is wrong on one screen only, don't debug the effect: ask which world ran the line that produced it.

Published by Samuel Bouchet.
Do you like reading SF? Try out latest game Neoproxima