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.
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."
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.
ServerReplicateActors spanning 18ms, dominated by relevancy iteration across ~4,000 always-awake actors for 100 connections.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 spatialization —
UReplicationGraphNode_GridSpatialization2Dbuckets 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-relevant —
UReplicationGraphNode_AlwaysRelevant_ForConnectionhandles 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.
// 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.
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.
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 |
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.5Conclusion
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.
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.