Technical Audit Networking / Replication JUL 2026 · 14 MIN READ

Replication Graph:
Scaling Relevancy
to 100 Players

Part 3 of the series. UE5's default replication driver checks every actor against every connection, every cycle. That is fine at 8 players and fatal at 100. This audit documents the move to a spatialized Replication Graph — grid nodes, dormancy, and a per-connection always-relevant split — that pulled a 100-player dedicated server back inside its CPU and bandwidth budget.

RS
Reignance Studios // UE5 SYSTEMS & PERFORMANCE AUDIT

The Default Wall

Parts 1 and 2 stayed on the game thread. This one crosses to the network, but the underlying lesson is identical: the default system does work proportional to the wrong quantity, and the fix is to make the work proportional to what actually matters.

UE5's default replication driver, UNetDriver, decides what each client needs to know by iterating. For every connection, it walks the set of replicated actors and asks each one IsNetRelevantFor — a distance and visibility test — then prioritizes and serializes what survives. At small player counts this is invisible. The cost is roughly proportional to connections × replicated actors, and both of those grow at once as a server fills.

The audited project was a large-scale extraction shooter targeting 100 players on a single dedicated server, with roughly 4,000 replicated actors live at peak — players, AI, projectiles, loot, doors, destructible cover. On the default driver, ServerReplicateActors alone was consuming 18ms of a 33ms (30hz) server frame, and bandwidth per client was running hot enough to stall players on weaker connections.

// Audit Context

Project: extraction shooter on UE5.5, Linux dedicated server, 30hz tick. Load generated with 100 synthetic clients distributed across the playable space plus scripted AI density. Profiled with Unreal Insights (server trace) and Net CSV profiler. Figures are steady-state averages at peak population, not match-start spikes.

01 — O(N×M) Relevancy

The Pattern

The default relevancy pass is the dominant cost. With 100 connections and 4,000 actors, the driver performs on the order of 400,000 relevancy considerations per replication cycle — each a virtual call plus a distance check — before a single byte is serialized. Most of those answers are "no": a player in the north compound is never relevant to a player extracting in the south, but the default driver still asks, every cycle, for every pair.

This is the network mirror of the redundancy from Part 1. The relevancy answer for two actors on opposite sides of the map does not change cycle to cycle, yet the default driver recomputes it from scratch each time. There is no spatial structure telling it "these two will never matter to each other."

Before — ServerReplicateActors
18ms
Per 30hz frame · 100 players
Relevancy Considerations
~400k
Per cycle · connections × actors
Bandwidth / Client (down)
340 KB/s
Peak · saturating weak links

02 — No Dormancy

The Pattern

The second cost was actors that never change being treated as if they might. Roughly 2,500 of the 4,000 replicated actors were effectively static once spawned — loot piles, unopened doors, world props, spawned-but-idle cover. None of them were marked dormant. Every cycle, the driver re-examined and re-prioritized them as live candidates, paying relevancy and comparison cost for actors whose replicated state had not changed in minutes.

Dormancy exists precisely for this: an actor flagged DORM_Initial or DORM_DormantAll replicates its initial state, then drops out of the active set until something explicitly flushes it (FlushNetDormancy) on a real change. The project simply never used it — every actor sat at DORM_Awake.

[ insights_repdriver_default_18ms.png ]
Fig 1. Server-thread Insights capture on the default driver — ServerReplicateActors spanning 18ms, dominated by relevancy iteration across ~4,000 always-awake actors for 100 connections.
// Key Insight

Two different wastes, one root: the default driver has no model of space or change. It cannot cheaply know that two actors are too far apart to matter, nor that an actor hasn't changed since last cycle. The Replication Graph adds both — spatial structure and dormancy routing — so the driver only considers actors that are plausibly relevant and actually live.

The Graph Build

The Replication Graph replaces per-cycle iteration with a persistent structure of nodes. Each connection gathers its replication list by visiting the nodes relevant to it, rather than scanning every actor. The three nodes that did the heavy lifting here:

  • Grid spatializationUReplicationGraphNode_GridSpatialization2D buckets dynamic actors into spatial cells. A connection only pulls from cells near its viewer, so the north-vs-south pair is never even considered.
  • Dormancy node — static actors route into a dormancy-aware node that drops them from the active gather until they change.
  • Per-connection always-relevantUReplicationGraphNode_AlwaysRelevant_ForConnection handles the small set that must always replicate (the owning pawn, PlayerState, GameState) without forcing it through the spatial grid.

Actors are classified once, at registration, into a routing policy via FClassReplicationInfo and a RouteAddNetworkActorToNodes override. The classification is the helper-shaped part of the fix: a single function that decides, per class, which node owns an actor.

C++ ArenaReplicationGraph.cpp — node setup & routing
// Build the global nodes once, on graph init
void UArenaReplicationGraph::InitGlobalGraphNodes()
{
    // Spatial grid — dynamic, moving actors
    GridNode = CreateNewNode<UReplicationGraphNode_GridSpatialization2D>();
    GridNode->CellSize      = 10000.f;            // 100m cells
    GridNode->SpatialBias   = FVector2D(-200000.f, -200000.f);
    AddGlobalGraphNode(GridNode);

    // Always-relevant statics (GameState etc.)
    AlwaysRelevantNode = CreateNewNode<UReplicationGraphNode_ActorList>();
    AddGlobalGraphNode(AlwaysRelevantNode);
}

// Per class: decide which node owns the actor — classified once
EClassRepPolicy UArenaReplicationGraph::GetMappingPolicy(
    UClass* Class) const
{
    if (Class->IsChildOf(AArenaCharacter::StaticClass()))   return EClassRepPolicy::Spatialize_Dynamic;
    if (Class->IsChildOf(AArenaProjectile::StaticClass()))  return EClassRepPolicy::Spatialize_Dynamic;
    if (Class->IsChildOf(AArenaLootPickup::StaticClass())) return EClassRepPolicy::Spatialize_Static;  // dormant-friendly
    if (Class->IsChildOf(AGameStateBase::StaticClass()))  return EClassRepPolicy::RelevantAllConnections;
    return EClassRepPolicy::NotRouted;
}

// Route each actor to its node on registration, not per cycle
void UArenaReplicationGraph::RouteAddNetworkActorToNodes(
    const FNewReplicatedActorInfo& Info,
    FGlobalActorReplicationInfo&     GlobalInfo)
{
    switch (GetMappingPolicy(Info.Class))
    {
    case EClassRepPolicy::Spatialize_Dynamic:
        GridNode->AddActor_Dynamic(Info, GlobalInfo); break;
    case EClassRepPolicy::Spatialize_Static:
        GridNode->AddActor_Static(Info, GlobalInfo);  break;
    case EClassRepPolicy::RelevantAllConnections:
        AlwaysRelevantNode->NotifyAddNetworkActor(Info); break;
    default: break; // NotRouted — handled elsewhere / never replicated
    }
}

Loot and props route to the grid as static actors and are marked DORM_DormantAll at spawn; a pickup or door only calls FlushNetDormancy when its state actually changes. The grid keeps the north-vs-south pair out of each other's consideration set entirely. The always-relevant node carries the handful of genuinely global actors without polluting the spatial path.

// Important Constraint

Cell size is a tuning decision, not a default. Too small and fast actors thrash across cell boundaries, churning add/remove cost; too large and each gather pulls in actors that aren't really relevant, eroding the win. Size cells to the game's effective relevancy radius — here ~100m matched weapon and vision ranges. Validate with Net.RepGraph.PrintGraph and re-measure after every change.

[ repgraph_grid_overlay_cells.png ]
Fig 2. Debug overlay of the GridSpatialization2D cells over the playable space — a viewer's gather set is limited to its own and neighbouring cells, not the whole map.

Before & After

The graph did not change what any client ultimately sees — relevancy results are equivalent. It changed how the server arrives at them: spatial gather instead of full iteration, dormant actors out of the active set, global actors on a dedicated path. The same 100-player load, measured after the migration:

Metric Before After Saving
ServerReplicateActors (server thread) 18.0ms / frame 4.2ms / frame 13.8ms
Actors in active gather set ~4,000 ~1,500 Dormancy
Downstream bandwidth / client 340 KB/s 95 KB/s −72%
Server frame headroom (30hz) Over budget Inside 33ms Stable tick
Before — Replication Cost
18ms
Per frame · default driver
After — Replication Cost
4.2ms
Per frame · Replication Graph
Time to Implement
5–7 days
Setup, classification & tuning

The default driver asks every actor about every player. The graph only asks the ones that could possibly matter.

— Reignance Audit Note, Client D · Extraction Shooter, UE5.5

Conclusion

The Replication Graph is not a micro-optimization; it is a change of model. But the reason it works is the same principle that ran through Parts 1 and 2: do work proportional to what changes and what matters, not to the raw size of the world. Spatial cells make relevancy proportional to local density instead of total population. Dormancy makes cost proportional to actors that actually change instead of all actors. The always-relevant split keeps the few genuinely global actors off the hot path.

It is more involved than a helper function — it needs class classification, cell tuning, and careful dormancy discipline on the gameplay side. But for any project pushing past ~30–40 concurrent players, or carrying thousands of replicated actors, it is usually the single highest-leverage change available on the network side. Default-driver iteration simply does not scale to those numbers, and no amount of per-actor optimization changes the underlying O(N×M) shape.

// Series Recap

Three audits, one idea. Part 1 — cache state that changes at event rate instead of querying at tick rate. Part 2 — capture an attribute once per frame, not once per effect. Part 3 — gather actors by space and change, not by brute iteration. Performance is recovered by matching the rate of work to the rate of what the work depends on.

If your dedicated servers are over budget at scale — climbing replication cost, bandwidth saturating weaker connections, tick rate sagging as population grows — a Replication Graph migration is usually the fastest structural fix. A focused audit scopes it, classifies your actors, and gives you a tuned starting graph.