An earlier Unity prototype in this series has a dedicated bandwidth layer: 3,233 lines across sixteen files — a per-tick bandwidth budget, snapshot chunk splitting and reassembly, send and receive recorders, oversize detection, payload metering. Add another 2,059 lines for lobby, connection management, and seed sync, and that’s most of a project’s worth of code whose entire job is to keep replicated state within a packet budget. None of it is gameplay. It exists because Netcode for GameObjects doesn’t do this part for you.
The Unreal rebuild covers the same co-op ground. I went looking for where the equivalent code would live, expecting to write it. It was already there.
The token bucket already exists
BandwidthLabRunner and SnapshotBandwidthBudget on the Unity side are a hand-rolled rate limiter: track bits sent this tick, compare against a budget, decide what waits. On Unreal, that’s UNetConnection. Each connection is credited CurrentNetSpeed * DeltaTime * 8 bits per tick, clamped against running too far into debt (NetConnection.cpp:5107-5145). FlushNet drains the bucket and logs once on overflow (:2573-2582). IsNetReady() is the saturation check — QueuedBits + SendBuffer.GetNumBits() <= 0 (NetConnection.cpp:2731).
The part that matters more than the bucket itself: actors the server didn’t get to this tick aren’t dropped. They keep bPendingNetUpdate set and get retried next tick (NetDriver.cpp:5915). My Unity budget code had to decide that policy for itself — defer or drop, and how to track who’s owed a resend. On Unreal it’s a flag on the actor, set by the engine.
The tuning surface is four config values: NetServerMaxTickRate, MaxClientRate / ConfiguredInternetSpeed, NetUpdateFrequency, and GetNetPriority. A hand-written budget has nothing left to do underneath that.
Even with the Unreal bar drawn wider than its true scale just to stay visible, it barely registers next to the Unity side — that gap is the entire argument of this post in one picture.
Chunking and reassembly already exist
SnapshotChunkSendBuffers, SnapshotChunkAssembler, SnapshotChunkPayloadMeter, SnapshotOversizeChunkLogger — four classes on the Unity side dedicated to a problem Netcode for GameObjects hands you directly: an unreliable RPC that exceeds the MTU throws OverflowException rather than fragmenting, so anything periodic and size-variable needs its own chunk-and-reassemble path bolted on top. I hit this exact wall once already, on an earlier prototype in this series — the wave-3 MTU ceiling post covers it. The honest fix there was to shrink the wave sizes so the payload never crossed the ceiling; chunking stayed “on the shelf,” in that post’s own words. These four classes are what pulling it off the shelf actually looks like: split state across multiple unreliable RPCs, tag each with a sequence number, reassemble on the client, log when a chunk didn’t fit.
On Unreal, UChannel::SendBunch carves anything over MAX_SINGLE_BUNCH_SIZE_BITS into partial bunches (DataChannel.cpp:1371-1399); UChannel::ReceivedNextBunch merges them back by ChSequence (:785-926). Under pressure the channel promotes the bunch to reliable and pauses replication on it until acknowledged; if that would overflow the reliable buffer the connection closes and dumps it (DataChannel.cpp:1414-1445). This runs beneath every replicated property and every RPC — not something a developer opts into per feature, the way I had to opt my EnemyState array into chunking by hand.
Recorders already exist
SnapshotSendRecorder (279 lines) and SnapshotReceiveRecorder (303 lines) on the Unity side exist to answer one question: how much am I actually sending, and is it landing? On Unreal that’s stat net, NetworkProfiler, and CSV metrics already registered in BaseEngine.ini:3804-3824 — ReplicateActorTimeMS, NumOpenChannels, GatherPrioritizeTimeMS, shared-serialisation hit/miss. SaturationAnalytics on UNetConnection counts saturated frames without me asking it to (NetConnection.h:1621-1627).
Almost 600 lines of send/receive recording, replaced by turning on a stat command.
Laid out this way, the pattern across all three sections above is the same shape: a class or two doing one job on the Unity side, a named engine mechanism already doing it on the Unreal side.
The measurement that makes it real
None of this is worth much as an abstract claim without a number attached, so here’s one from a different Unreal prototype in the series: a server-driven AI pawn that chases the nearest player. No replication code was written for it: APawn’s constructor sets bReplicates and NetPriority = 3.0 (Pawn.cpp:86-87), and UCharacterMovementComponent defaults to ENetworkSmoothingMode::Exponential (CharacterMovementComponent.cpp:718).
Measured on a packaged build, macOS hosting and Windows joining, sampled once a second:
[ChaseNet] role=SimulatedProxy updates/s=47-49 worstDrift=11-19cm speed=420 netFreq=100
Against a configured NetUpdateFrequency of 100, the client actually saw 47-49 updates a second — the ceiling is a ceiling, not a rate. I don’t have a confirmed reason for the specific gap between 100 and 48: the connection wasn’t saturated in this test (a single AI pawn is nowhere near the bandwidth budget), so it isn’t the priority-and-saturation mechanism from the next section, and the number doesn’t line up with the server’s tick rate either. It’s an open question, not a settled one. Worst-case drift was 11-19 cm at a movement speed of 420 cm/s: one to two update intervals of extrapolation, and less than the width of the character. It reads as smooth on nothing more than the default Exponential position smoothing — no local input prediction is involved, because the client never controls this pawn.
The side finding — distance already changes the rate, for players too
This is the part I didn’t expect. AActor::GetNetPriority (ActorReplication.cpp:48-92) weights priority by distance and view direction against thresholds compiled into NetworkingDistanceConstants.h — being in front and within 80 m and looked at is a ×2 multiplier; being behind the viewer and beyond 20 m is ×0.2. The server walks the priority-sorted list and stops when IsNetReady() goes false; everything below that point is skipped and carried to the next tick (NetDriver.cpp:5695, :5816). The multiplier applies to time since this actor last replicated, so priority climbs the longer something goes unsent, and starvation corrects itself.
The cutoff line is the mechanism worth looking at: it isn’t a hard slot count, it’s wherever the connection’s bandwidth for this tick runs out, so the same actor can land above the line on one tick and below it on the next.
Watching two players in that same project, not the AI: characters update noticeably less often once they’re some metres apart, and sharpen up again on approach. That’s the priority system doing exactly what it’s designed to do, and it only shows up under contention — with bandwidth to spare, nothing gets skipped and distance changes nothing.
I went looking for the NGO equivalent and didn’t find one — not in the manual, and not in the package source: no priority sort, no starvation term, no distance-scaled update frequency show up anywhere in Unity’s public com.unity.netcode.gameobjects repository. That’s as far as I can push a negative claim; I can say I didn’t find it, not that it categorically doesn’t exist. What NGO does ship is NetworkVariable’s dirty-check send gate and per-instance update-rate traits (MinSecondsBetweenUpdates / MaxSecondsBetweenUpdates, both zero by default) — a throttle on whether to send, not a priority ordering over what to send when bandwidth runs out. If a Unity project wants players and enemies to compete for bandwidth by distance and facing, that’s a system to design and write, not a config value to set. That gap — not the chunking, not the budget — is the actual size of that earlier project’s bandwidth layer. The chunking and budget code replace maybe a thousand lines of what Unreal gives for free. The rest of it, the parts closer to SnapshotBandwidthProbe (677 lines) and BandwidthLabOverlay (386 lines), is scaffolding built to explore and demonstrate a tradeoff space Unreal’s priority system already resolves by default.
What’s still mine to write
Unreal’s replication stack ends at AActor and UActorComponent. It says nothing about:
- Whether the generation algorithm behind a replicated seed is deterministic across platforms — the engine delivers the value reliably, not correctly
- Priority policy —
GetNetPriorityis one float; deciding player state should outrank cosmetic props is still a decision I have to make - Payloads that don’t fit the replication model at all — a large one-off binary blob to a single client still means
NetDeltaSerialize, a customFNetSerializer, or the raw connection API - Anything outside actors and components — EOS lobby attributes, for instance, get none of the above
That last gap is where the same EOS lobby prototype in this series ran into its own separate wall — a stack Unreal ships but doesn’t actually stand behind.
PostUnreal has two online stacks. I picked the one that wasn't ready.A packaged Windows build of my Unreal EOS lobby project failed at sign-in while the same code ran fine on macOS, traced to a move assignment that drops one field. The fix was moving off the untested online stack entirely, and why nobody else hits this bug is the more interesting part.So this isn’t a case of the engine doing all the work. It’s a case of the engine doing a specific, large, tedious slice of the work that I’d already convinced myself was mine to do, because on Unity it was.
Wrap-up
This isn’t “Unreal is better than Unity” — NGO’s RPC-and-NetworkVariable model is a reasonable design with a smaller, more legible surface, and the wave-3 MTU ceiling I hit on it taught me something a bigger, more automatic system’s failure mode wouldn’t have.
What changes for the next prototype is the first move, not the toolset: before writing a system, grep the engine source for the mechanism first, rather than take silence as permission to build it. That habit doesn’t pay off every time — some corner of the engine may genuinely have nothing hiding underneath, and I won’t know which until I look.
One thing this post leaves open. The project’s transport is NetDriverEOS, peer-to-peer rather than the default IP driver, and nothing here confirms the budget or priority path behaves identically over it — QueuedBits lives on UNetConnection and is transport-agnostic in principle, but I haven’t traced the EOS path to check that the principle holds in practice. That’s the next thing to actually verify, not assume.
Look harder before deciding a feature isn’t there.