Snapshot Bandwidth Budgeting for Co-op Sessions in 2026 - Godot 4.5 ENet vs Unity Netcode for GameObjects
Your two-player co-op prototype runs fine on your fibre link and looks rubbery the moment a friend joins from a hotel Wi-Fi. The fix is not "more interpolation" or "buy a better tickrate." It is snapshot bandwidth budgeting: deciding how many kilobits per second per player your game is allowed to use, then making the engine respect that number under every realistic network condition.
This post is a 2026 practical comparison of how that budgeting actually plays out in two of the most common small-team co-op stacks: Godot 4.5 with ENet (ENetMultiplayerPeer driving MultiplayerSynchronizer/MultiplayerSpawner) and Unity 6 with Netcode for GameObjects (NGO) running its default Unity Transport (UTP) and snapshot system. Where the two diverge is more interesting than where they overlap. By the end you will have a real Kbps-per-player budget, a tick rate that fits it, and an interest-management plan that survives metered broadband.
Why this matters now (2026)
A few things changed in early 2026 that pushed co-op bandwidth back into indie complaint threads:
- Metered broadband resurfaced. After the storefront sales waves of spring 2026, more players are co-oping from mobile-tethered laptops, capped fixed-wireless plans, and shared-room Wi-Fi. The 50-150 Kbps you happily spent in 2022 now triggers warnings in player chat.
- Quest cert windows are tighter. OpenXR co-op modes in 2026 Q2 now expect a published per-player bitrate target for partner review.
- Both engines moved. Godot 4.5 stabilized the multiplayer node split (
MultiplayerSynchronizer,MultiplayerSpawner,MultiplayerAuthorityon nodes) and reworked replication defaults; Unity Netcode for GameObjects kept iterating its snapshot pipeline and interest management options. Default-on settings are not what they were two years ago. - AI-generated test traffic is real. Indie teams routinely use AI-driven bot pilots to stress-test bandwidth in CI before shipping. A budget that holds up to ten AI bots tells you more than a six-friend LAN test.
If you do not budget, the engine will - but it will do so by dropping frames, snapping positions, or starving the audio thread under load. The work below makes the budget explicit.
What "co-op session" means here
This post covers 2-8 player small-team co-op:
- One host (player-listen-server or dedicated server)
- Authoritative server model (NGO default, common Godot pattern)
- Player movement plus a handful of replicated gameplay objects (enemies, pickups, projectiles)
- 20-60 Hz tick rate target
If you are building a 64-player FPS or a 1000-CCU MMO, this article is not for you - your bandwidth strategy is bigger than snapshot deltas. For small-team RPG, survival, asymmetric co-op, or short-session arcade, the numbers below are accurate today.
Set a Kbps-per-player budget first
Pick the budget before you pick the tick rate. Three target tiers cover most 2026 indie co-op:
| Budget tier | Up Kbps/player | Down Kbps/player | Plays well on |
|---|---|---|---|
| Tight | 32 | 96 | Hotel Wi-Fi, fixed wireless caps, mobile tether |
| Standard | 64 | 192 | Typical home broadband, console |
| Headroom | 96 | 288 | Fibre, LAN, esports-class |
Why up/down asymmetry: every client uploads only its own inputs; the host downloads everyone's inputs and uploads N-1 player worth of state. Most player complaints in 2026 are about upload starving the host when there are too many replicated entities, not about client download.
A realistic small-team co-op default target: Standard tier on the client, Tight tier on the host upload per peer (so a 4-player host uses about 192 Kbps up per peer x 3 = ~570 Kbps total, which a 5 Mbps residential upload can sustain even with ~10% packet loss).
If your engine and game cannot stay inside Standard tier with four players moving, you have a bandwidth bug, not a network problem.
The two stacks at a glance (2026)
| Concern | Godot 4.5 ENet | Unity Netcode for GameObjects (NGO) |
|---|---|---|
| Default transport | ENet via ENetMultiplayerPeer |
Unity Transport (UTP) |
| Default tick rate (server) | Project setting, default 30 Hz in 2026.x templates | NetworkConfig.TickRate default 30 |
| Reliable channels | ENet channels per peer, configurable | NGO reliable + unreliable; transport-managed |
| Replication primitive | MultiplayerSynchronizer (delta sync on nodes) |
NetworkVariable<T> and NetworkBehaviour snapshots |
| Interest management | Filter by MultiplayerSynchronizer.replication_config peer-visibility + set_visibility_for |
NetworkObject visibility callbacks + custom interest scripts |
| RPC primitive | @rpc annotated functions on nodes |
[ServerRpc] / [ClientRpc] |
| Default object snapshot frequency | Configurable per MultiplayerSynchronizer |
NGO NetworkTickSystem + per-variable rules |
| Headless server | Native via --headless and dedicated build |
Unity Dedicated Server template (2026 LTS) |
| Web export co-op | 2026 WebRTC peer or browser-side ENet via WebSocket gateway | 2026 WebGL + WebSocket transport |
The takeaway: both stacks support the patterns we will use; the defaults differ in ways that matter.
Tick rate: 20, 30, or 60?
A common mistake is "60 because Smash" or "20 because saves bandwidth." The right answer is: tick rate is a function of your budget, your interpolation window, and your input feel.
For a 4-player twin-stick co-op with up to 30 active entities at any time:
| Tick rate | Pros | Cons | Realistic budget at 30 entities |
|---|---|---|---|
| 20 Hz | Cheap; survives bad networks | Input feel suffers; snap visible without ~150 ms interp buffer | ~30-50 Kbps/player |
| 30 Hz | Sweet spot for co-op | Requires solid interp | ~50-90 Kbps/player |
| 60 Hz | Crisp feel | Doubles bandwidth; usually unnecessary | ~100-180 Kbps/player |
Both Godot 4.5 and Unity NGO target 30 Hz as default in 2026. That is the correct choice for indie co-op unless you have data to move it.
To change tick rate:
- Godot 4.5:
Project Settings > Network > Replication > Tick Per Second(default 30 in newer templates). Some pipelines also set per-MultiplayerSynchronizerreplication rates lower for static or rarely-changing nodes. - Unity NGO:
NetworkManager.NetworkConfig.TickRate = 30;in code or in the asset. NGO also exposes per-NetworkVariablesend rules; treatTickRateas the upper bound, not the only knob.
Pro tip for both: drop the tick rate for stationary, non-time-critical entities (locked doors, world props, dropped items) using their per-object snapshot configuration. This is the cheapest bandwidth win in 2026 indie co-op and almost no one does it.
Delta versus full-state snapshots
Both engines ship delta replication out of the box, but how you author your synchronizers matters.
Godot 4.5
MultiplayerSynchronizer reads the replication_config resource. Two questions:
- What properties am I replicating? Each property has an Always / On Change / Never setting (in 2026.x). Use On Change by default; reserve Always for properties that genuinely change every tick (camera target, velocity-driven smoothing).
- What is the replication interval? A
MultiplayerSynchronizercan override the global tick rate per node.
Common bug: setting a Vector3 position to Always at 60 Hz on every replicated enemy. Even at 4 players and 20 enemies, that is 4 x 20 x 60 = 4800 vector updates per second baseline. Switch to On Change plus a smoothing strategy and the same scene drops to a few hundred updates per second.
Unity Netcode for GameObjects
NGO replicates via NetworkVariable<T>, NetworkBehaviour writes, and the snapshot system. Equivalents of the question above:
NetworkVariableReadPermissionandNetworkVariableWritePermission(set narrowly).NetworkBehaviour.NetworkTransform(use this; do not roll a custom position sync unless you have a reason).NetworkConfig.SnapshotSpawnDestroyandEnableSceneManagement(leave on defaults at first; revisit when profiling shows snapshot churn).
Common bug: serializing entire NPC state into a custom NetworkVariable<T> struct that includes inventory, AI state, animation parameters. Split the struct - separate NetworkVariable<int> for animation state, NetworkTransform for position. Each will delta-replicate independently.
Interest management - the biggest single saving
Interest management is the rule that says "this player does not need this object's updates right now." Both engines support it; both make it your job to author.
Godot 4.5
# On a MultiplayerSynchronizer or its parent:
func _on_player_joined(peer_id: int) -> void:
var sync := $MultiplayerSynchronizer
sync.set_visibility_for(peer_id, _player_can_see(peer_id))
func _on_player_position_changed(peer_id: int, pos: Vector3) -> void:
var sync := $MultiplayerSynchronizer
sync.set_visibility_for(peer_id, _within_radius(pos, 30.0))
Pair with the MultiplayerSpawner so far-away enemies are not even instanced on remote peers. This single change is worth 30-60% bandwidth savings in mid-density co-op scenes.
Unity Netcode for GameObjects
public class DistanceVisibility : NetworkBehaviour
{
[SerializeField] private float visibilityRadius = 30f;
public override void OnNetworkSpawn()
{
NetworkObject.CheckObjectVisibility = (clientId) =>
{
if (!NetworkManager.ConnectedClients.TryGetValue(clientId, out var c)) return false;
var player = c.PlayerObject;
if (player == null) return false;
return Vector3.Distance(player.transform.position, transform.position)
< visibilityRadius;
};
}
}
CheckObjectVisibility is per-object, evaluated on visibility changes; combine with NetworkObject.Hide/Show for forced occlusion (boss arenas, instanced rooms).
Either way, the cardinal rule: do not let your bandwidth bill include objects no player can perceive this tick.
RPC discipline
RPCs are not free. Treat them as event channels, not state replicators.
Rules that apply equally to Godot @rpc and NGO [ServerRpc]/[ClientRpc]:
- Never RPC something a
NetworkVariableorMultiplayerSynchronizeralready covers. - Reliable RPCs cost much more bandwidth than unreliable when packet loss exists. Use reliable for inventory, ability unlocks, end-of-round; use unreliable for VFX flags and cosmetic events.
- Batch RPCs that fire in the same frame (e.g., loot drops from a boss). Send one RPC with an array, not 16 separate ones.
- Authority matters. Client-to-server RPCs that mutate global state are easy DoS vectors - validate inputs, rate-limit.
For Godot specifically:
@rpc("authority", "call_local", "reliable")
func on_round_end(scores: Dictionary) -> void:
_apply_results(scores)
For Unity NGO:
[ServerRpc(Delivery = RpcDelivery.Reliable, RequireOwnership = true)]
public void RequestUseItemServerRpc(int itemId)
{
if (!_inventory.HasItem(itemId)) return;
_inventory.Consume(itemId);
}
Measure before you tune
Both engines expose bandwidth visibility, but you have to ask for it.
Godot 4.5
- Run with
--debug-multiplayerflags during local QA. - Hook into
MultiplayerAPI.peer_packetand log bytes per second per peer. - The simplest path: print
OS.get_static_memory_usage()is not what you want; print actual peer stats viaENetMultiplayerPeer.host.peer_getif you need byte-level numbers.
A simple per-tick counter:
extends Node
var _bytes_sent: int = 0
var _bytes_recv: int = 0
var _last_tick: int = 0
func _ready() -> void:
multiplayer.peer_packet.connect(_on_packet)
func _on_packet(_id: int, packet: PackedByteArray) -> void:
_bytes_recv += packet.size()
func send_tracked(peer_id: int, payload: PackedByteArray) -> void:
multiplayer.multiplayer_peer.send(peer_id, payload, 0, 1)
_bytes_sent += payload.size()
func _process(_dt: float) -> void:
var now := Time.get_ticks_msec()
if now - _last_tick >= 1000:
var up_kbps := _bytes_sent * 8 / 1000.0
var dn_kbps := _bytes_recv * 8 / 1000.0
print("[bw] up=%.1fkbps dn=%.1fkbps" % [up_kbps, dn_kbps])
_bytes_sent = 0
_bytes_recv = 0
_last_tick = now
Unity Netcode for GameObjects
NGO surfaces bandwidth via the NetworkManager stats interface and the optional Multiplayer Tools package, which exposes a Profiler tab in 2026 LTS. The 30-second path without the package:
public class BandwidthMonitor : NetworkBehaviour
{
private ulong _lastSent;
private ulong _lastRecv;
private float _t;
private void Update()
{
_t += Time.deltaTime;
if (_t < 1f) return;
_t = 0f;
var sent = NetworkManager.NetworkMetrics?.SentBytes ?? 0;
var recv = NetworkManager.NetworkMetrics?.ReceivedBytes ?? 0;
var upKbps = (sent - _lastSent) * 8f / 1000f;
var dnKbps = (recv - _lastRecv) * 8f / 1000f;
_lastSent = sent; _lastRecv = recv;
Debug.Log($"[bw] up={upKbps:0.0}kbps dn={dnKbps:0.0}kbps");
}
}
For deeper inspection, install the Multiplayer Tools package (2026 release line). The Profiler tab shows per-NetworkObject byte costs and identifies the chatty offender in 30 seconds.
A practical budget walk-through
A four-player co-op with 30 active entities, 30 Hz tick, Standard tier target (64 Kbps up, 192 Kbps down/client):
-
Compute the naive baseline.
- Each entity replicates a
position(3 floats) and arotation(quaternion, 4 floats) = 7 floats = 28 bytes if uncompressed. - 30 entities x 30 Hz = 900 updates/sec from server to each of 3 remote clients.
- 900 x 28 = ~25 KB/s per client = 200 Kbps. Already at the Standard download limit before VFX, audio cues, RPCs, or inventory.
- Each entity replicates a
-
Apply interest management (radius 30 m, average 12 entities visible per client).
- 12 x 30 = 360 updates/sec. 360 x 28 = ~10 KB/s = 80 Kbps. Within budget.
-
Apply delta-only replication. Most entities are not moving every tick.
- Typical co-op scene: ~5 entities move per tick. 5 x 30 = 150 updates/sec. 150 x 28 = 4.2 KB/s = ~34 Kbps. Comfortable.
-
Add input upload. Each client sends inputs at 30 Hz: ~12 bytes/input x 30 = 360 B/s = ~3 Kbps. Trivial.
-
Add gameplay events. Loot drops, VFX flags, ability uses: budget ~10 Kbps headroom.
Total per client: ~50 Kbps download, ~5 Kbps upload. Host upload across 3 clients: ~150 Kbps. Well inside Standard tier with headroom.
If your numbers are 2-3x higher at this scene density, the next sections show where to look.
Engine-specific gotchas
Godot 4.5
- Default
MultiplayerSynchronizerwrites via reflection. Beautiful for prototyping; expensive when you replicate Lua-style "kitchen sink" nodes. Move heavy ephemeral state off replicated nodes. - Spawner deduplication.
MultiplayerSpawner.spawn_functionruns on the server; bad spawn paths cause duplicate spawns on rejoin. Pinspawn_pathand the spawn function to a stable signature. - WebRTC for browser co-op. 2026 mobile browsers added stricter SDP behavior; if your co-op runs in browser, test with the latest Chrome/Safari and pin the WebRTC build instructions to the patch you tested.
- Compression. ENet supports compression; enable
ENetMultiplayerPeer.set_bind_ipplus the compression channel (ENetConnection.COMPRESS_RANGE_CODERworks well for variable bursts). Compression adds a small CPU cost and big bandwidth savings on text-heavy or repeated state.
Unity Netcode for GameObjects
NetworkTransformsmoothing. DefaultInterpolate = true; if you turn it off, you must hand-roll smoothing or accept snap. Most teams should leave it on.- Owner authority on
NetworkVariable. Easy to mis-set; client-mutable variables can be spoofed. Default to server-write unless the variable is truly client-owned (e.g., aim direction). - Scene management defaults.
EnableSceneManagement = truesyncs scene changes; expensive if you load big scenes mid-session. Pre-load scenes additively and toggle root objects. - UTP fragmentation. Default MTU is fine; sending oversized RPC payloads triggers fragmentation and retransmits. Cap RPC payload sizes around 1100 bytes for safety.
Quick A/B in one afternoon
If you have a co-op prototype on either stack, run this same-day audit:
- Pick a benchmark scene (the busiest realistic scene).
- Run a 4-player test for 60 seconds with synthetic input bots if possible.
- Log up/down Kbps per peer using the snippets above.
- Apply interest management (distance-based for starters).
- Re-run. Save delta versus baseline.
- Disable per-tick
Alwaysreplication on stationary entities. Re-run. - Lower tick rate for non-time-critical entities (doors, drops, ambient enemies) to 10 Hz. Re-run.
- Cap your RPCs. Convert frame-spammy event RPCs to batched payloads. Re-run.
Each step is 10-15 minutes. By end of day you have a budget that holds at the tier you targeted, plus a baseline number to defend against future regressions.
CI guardrail: regress on bandwidth, not just frame time
A 2026 habit worth adopting: include a per-PR bandwidth assertion alongside your frame-time assertion.
# pseudo-CI step
- name: Co-op bandwidth smoke
script: |
./run_4p_bots --scene=Hub --seconds=60 --emit-metrics
assert:
host_upload_kbps_p95 <= 200
client_download_kbps_p95 <= 128
client_upload_kbps_p95 <= 32
When a PR pushes the p95 host upload past the cap, the merge fails and the developer sees the offending object via the same monitor scripts above. This is much cheaper than letting bandwidth regressions ship and get caught by player reports.
Frequently asked questions
Should I use WebRTC for co-op so it works in browser?
Only if browser co-op is a real distribution target. WebRTC's NAT traversal helps with consumer connectivity but adds setup cost. For Steam-first or console-first co-op, ENet (Godot) or UTP (Unity) is simpler and more predictable.
Is ENet still a good 2026 choice?
Yes, for small-team co-op. ENet's reliability layer plus Godot's MultiplayerSynchronizer ergonomics keep the surface area small. For 64-player FPS or large simulations, look at QUIC-backed transports.
Does NGO scale to dedicated servers in 2026?
The Unity Dedicated Server template plus NGO scales fine for small-team co-op and competent for mid-sized lobbies. Beyond that, evaluate purpose-built transports (Photon Quantum, Mirror with custom backends).
How much does compression actually save?
On chatty text/event payloads, often 25-40%. On already-tight delta floats, often 5-10%. Always profile before claiming savings.
Where should I put aim direction for a twin-stick shooter?
On the owner, replicated as a lightweight quaternion or 2D vector via NetworkVariable (NGO) or MultiplayerSynchronizer property (Godot). Tick at 30 Hz; do not RPC every frame.
Outbound references for both stacks
- Godot 4.5 documentation, High-level multiplayer: https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html
- Godot 4.5 documentation, MultiplayerSynchronizer: https://docs.godotengine.org/en/stable/classes/class_multiplayersynchronizer.html
- Unity Netcode for GameObjects documentation: https://docs-multiplayer.unity3d.com/netcode/current/about/
- Unity Multiplayer Tools package overview: https://docs.unity3d.com/Packages/com.unity.multiplayer.tools@latest
- Unity Transport (UTP) documentation: https://docs.unity3d.com/Packages/com.unity.transport@latest
- ENet library overview: https://enet.bespin.org/
What to read next on this site
- Godot 4.5 multiplayer rejoin reliability - ENet state recovery packet (2026) for the rejoin-time bandwidth spike pattern.
- Godot 4.6 multiplayer rejoin regressions - 2026 fast authority snapshot audit for small teams if you have already moved to the 4.6 beta line.
- Netcode for GameObjects vs Entities - when your multiplayer prototype should stay small for the NGO-versus-Entities scaling decision.
- Unity 6 UI Toolkit HUD row without breaking prefabs (2026) to keep the HUD that displays your bandwidth meter clean across prefab variants.
Closing - the only co-op bandwidth habit worth keeping
Treat per-player Kbps like frame time: a published target, monitored every PR, never silently relaxed. Pick Standard tier (64 up / 192 down) as your default. Author interest management before you author smoothing. Use delta replication, not "Always" properties, on everything that does not move every tick. Batch your RPCs. Run a 60-second 4-player bot test in CI and assert on the p95 numbers. Both Godot 4.5 and Unity NGO can hit those targets at 30 Hz for 30 entities in 2026 without exotic transports - you just have to budget like you mean it.
The difference between a co-op session that ships in early 2026 and one that gets returned for "lag" is rarely the engine. It is the team that wrote down a Kbps number on day one and refused to let it drift.