
Fish-Net Custom Serializers and Bandwidth-Efficient Data Sync for High-Entity Unity IO Games
IO games on Unity live or die on the wire. When hundreds of entities grow, eat, and collide every tick, default SyncVars and SyncLists start shipping fields you never meant to pay for—and WebGL will punish every allocation those writers create.
Fish-Net is a free, versatile Unity networking solution built from the ground up, with a high-level API for state and object sync and low-level Writer and Reader access when that path is too fat. It is routinely chosen for bandwidth and resource optimization and for player counts from dozens to hundreds; public comparisons have seen it hold past 100 CCU without the stability problems some other stacks hit much earlier. None of that headroom matters if every fish is still a bag of SyncVars.
The useful move is to treat custom serializers as per-entity byte budgets: packed positions, health, growth, and inventories written GC-free, aligned to the simulation tick, and never mixed with URP materials, trails, or juice the client can derive locally. RPCs are a poor substitute for that stream—they are not tick-aligned, they are slow, and they do not give you compression. Custom serialization does.
Minimize the objects and properties that actually synchronize, and design the pack for the worst player count the game allows. The rest of this piece shows when the built-in path is enough, how ICustomSerializer and Writer extensions pack real IO state, how that work sits next to interest management and pooling, and how to measure the result. First you need a clear picture of what Fish-Net serializes by default—and why that default fails a fat IO game long before the server runs out of CPU.
- Treat every networked entity as a per-tick byte budget, not a bag of SyncVars.
- Custom Writer and Reader paths beat default serialization for packed positions, growth, and inventories.
- Keep URP visuals client-side; only server-authoritative state should cross the wire.
- Tick-aligned packing and GC-free writes matter more than RPCs on WebGL transports.
- Combine serializers with interest management, Network LOD, and pooling before you scale entity count.
The Default Sync Shapes IO Arenas Cannot Afford
That default is a per-field dirty write. A SyncVar of Vector2 or Vector3 leaves as full-precision floats. Mass and growth leave as 32-bit floats. Score is an int. A short inventory becomes a SyncList, which pays collection overhead on top of every slot. On a dozen networked avatars that convenience is fine. On a few hundred cells, food orbs, and split pieces ticking together, the same shape multiplies into a per-tick bill you cannot pay.
Authoritative IO state is small. Position can be quantized to the playable map. Mass or radius is a compact value, not a simulation float. Score is a counter. Inventory, if you have one, is a handful of flags or ids—not a growing list of visual pickups. The default path does the opposite: it treats every marked field as first-class network state at native size.
- World position written as raw Vector2/Vector3 instead of a quantized cell on the arena.
- Mass, radius, or growth as a full float that dirties on every eat.
- Score as a wide integer even when the displayed value barely changes.
- Short inventories as SyncLists that re-send collection headers with the items.
Each extra word per entity is multiplied by how many objects are actually live, not by how many you tested in a quiet editor session. High-entity IO fails here first: the payload shape is wrong long before the server is out of CPU.
Continuous state is not an RPC
RPCs look like a shortcut for “the cell grew” or “this orb was eaten.” They are the wrong pipe for the stream that defines an IO match. They are not tick-aligned, they are slow compared with a serializer write on the tick, and they do not give you the compression a proper sync path can apply. Reserve RPCs for discrete, rare events—chat, a one-shot power-up grant. Positions, mass, and scores belong in tick-aligned custom writes, not fire-and-forget calls.
URP stays on the client
Shader parameters, trail meshes, squish, outlines, and particle bursts are presentation. If the client can reconstruct them from gameplay bytes, they are not network state and must never cross the wire. The job is not to turn sync off. You still need server-authoritative cells and orbs. The job is payload design: decide which bytes are gameplay, keep URP visuals local, and refuse Fish-Net’s default SyncVar and SyncList shapes for high-frequency entity data.
Write a Per-Archetype Byte Budget Before Any Serializer
That refusal is empty until the numbers exist on paper. Before you choose a SyncType, implement a custom serializer, or reach for a Writer extension, lock a hard byte ceiling for every replicated archetype—player body, cell, orb, pellet—and treat a field that would break the ceiling as a design defect, not something compression will mop up later.
Work the envelope backward from the match you ship, not from the fields on the prefab. Target concurrent players times the entities each client is allowed to see times simulation tick rate is the only honest multiplier. On mobile browsers that uplink and downlink already share the pipe with TLS, audio, and the URP frame; if the product does not fit, you cut fields now. Bandwidth and resource headroom only hold in an IO arena when every object in the interest set already sits in a known byte box.
Must-sync gameplay versus cosmetics
Document field priority in the same pass so later packing cannot quietly grow. Must-sync is server-authoritative simulation: quantized position, mass or radius, owner or team id, and the few flags that change who eats whom. Nice-to-have is everything a URP client can reconstruct locally—trail tint, squash, outline width, inventory chrome, nameplate style. If a value does not change the authoritative result of a tick, it does not belong on the wire at entity frequency.
- Must-sync every tick: quantized world position, mass or radius, owner or team id, eat and split flags
- On change or a slower cadence: score, name hash, loadout id
- Never sync: URP materials, particles, camera shake, prediction leftovers, anything the client can derive from mass
The budget complements interest management; it does not replace it. Area of interest and network LOD decide which entities enter a client’s set; serializers own every byte inside that set. A tighter radius that still ships full floats and SyncLists just relocates the same waste. Pooling and LOD already on the project only pay off once the per-entity payload is honest.
Write the ceilings where gameplay and netcode both sign them: a cell is this many bytes, an orb is that many, headers included. That document is the pass/fail test for every writer you add. Until those numbers exist, choosing a serializer is guessing.
GC-Free Writer and Reader Extensions That Honor the Budget
With the ceiling locked, the choice is no longer which SyncType looks convenient — it is which write path can spend those bytes without allocating, every tick, including on WebGL. Custom serializers exist to enforce the budget you just wrote, not to decorate the high-level API.
Fish-Net supports any kind of network topology through its Transport system. You do not replace NetworkBehaviours—you keep spawn, interest, and ownership at the high level, then route the per-entity gameplay blob through a Writer extension or an ICustomSerializer so the tick payload matches the archetype ceiling instead of a handful of full-width SyncVars.
Extension methods that never allocate on the tick
On WebGL, a GC spike is a hitch, not a profiler footnote. The Writer Fish-Net already created for the tick is the only buffer you should touch. Keep ICustomSerializer implementations as structs or as static extension methods so the first serialize cannot box. Boxed enums, stringly state, and temporary arrays allocated inside Serialize all become garbage once per entity, once per tick. Prefer an explicit integer tag, packed bits, and reuse — the same pooled buffers you already keep for IO entities.
- Pair every WriteFoo with a matching ReadFoo so the schema cannot drift between server and client.
- Never call ToString, box an enum, or allocate a collection on the serialize path.
- Quantize in the writer; the reader reconstructs gameplay values only — never URP cosmetics.
- Keep packed types blittable so nothing hides behind a class or interface.
Density is wasted if the schema churns every upgrade. Fish-Networking promises not to release any breaking API or behavior changes between major versions, which is the contract custom IO packs should sit on: public Writer and Reader extensions, no undocumented internals, no reflection. When a major version lands, the packed gameplay blob still round-trips through the same methods. That is how a per-entity byte budget stays a product rule instead of a rewrite.
With the write path GC-free and pinned to that API contract, the remaining work is filling the reserved bytes with a fixed schema — not inventing a new serializer for every field.
Fixed Schemas for Coords, Mass, Score, and Loadouts
That schema is identical for every instance of an archetype. You reserve a known width for the gameplay that must move each tick, pack those bits the same way on server and client, and refuse anything that would make payload length a surprise.
Quantize the IO staples, not the Unity types
Full floats for world position are the default tax this genre cannot pay. Map the playable plane to a grid the player cannot distinguish from interpolation, then write unsigned integers. Two 16-bit axes often cover an IO arena when origin and scale are chosen against the map, not Unity world units. That pair is usually the largest slice of the per-entity ceiling; mass, growth, and score fight for what remains.
Mass and growth drive collision and camera follow. They almost never need a 32-bit float on the wire. A small unsigned integer, or a half, is enough when the simulation already treats mass as discrete steps. Score follows the same rule: an integer with a hard ceiling. If the design only needs 16 bits of score, write 16 bits and stop.
Fixed slots and bitmasks, not unbounded inventories
Inventories are where SyncList traffic explodes. Prefer a fixed slot count — three power-up indices as bytes — or a bitmask of known pickup types. A mask is one or two bytes for the whole loadout; adding a pickup is a schema version bump, not a new list element every tick. Fixed length also matches pooled entities: every instance serializes the same number of bytes inside the interest set.
Version the layout; never discover it with reflection
Keep the layout versioned and explicit. A header nibble or a compile-time constant both sides share is enough; do not walk fields with reflection on WebGL. Server and client implement the same Writer and Reader extensions so v1 and v2 packs can coexist during a rollout without changing NetworkBehaviour types. A human should read the bit table and see which bytes are position, which are mass, and which were never cosmetics.
Every field still answers to the envelope worked backward from players, visible entities, and tick rate. If quantized XY, mass, score, and a short loadout mask overshoot that ceiling, drop a field to on-change or drop its bit width. The serializer does not get extra bytes because a SyncVar would have been convenient.
Tick-Aligned Deltas Beat Full Snapshots at Entity Scale
That ceiling is also why you do not get to treat every tick as a full snapshot. If the packed layout already fills the budget, resending unchanged mass, score, or loadout bits is the same overshoot you just refused. Spend those bytes only on fields that moved, or omit the entity from the payload for that tick.
Dense swarms versus sparse pickups
In a dense player swarm, quantized XY dirties almost every simulation tick; mass follows whenever someone eats. Rewriting the whole fixed schema can be cheaper than a mask-plus-payload when every field is live—the mask is overhead you only earn if something might stay clean. Sparse pickups sit the other way: they idle for long stretches, then despawn or change owner in a burst. Dirty-field deltas, or an on-change write off the hot path, are how pellets stay inside the envelope without competing with players for uplink. Split the policy by archetype: blobs toward snapshot-like live fields, pellets toward a compact dirty mask. One generic list-shaped path is how default serialization blows the budget.
Masks that stay branch-predictable
The mask matches the versioned schema: a handful of bits in field order, written first, then only the dirty packed values. Readers test known bit positions—no dictionaries, boxed enums, or string keys. A zero mask means keep last known state; a set bit means the following bits are exactly the quantized coord, discrete mass, capped score, or loadout already defined. That predictability matters on WebGL as much as the byte count.
Align writes to the tick
IO movement prediction stays coherent only if serializer output lands on tick boundaries. The server tick is the cadence the predictor reconciles against; mid-frame writes and RPCs stagger snapshots and force extra correction. Flush custom Writer extensions with the tick—same path, allocation-free—so coords and mass steps arrive with the tick index the server used. Interpolation and URP presentation stay client-side; they never ride the delta.
Conceptually, naive full-state sync resends every position, mass, score, and inventory slot for every visible entity every tick. Packed delta frames send a tiny mask plus only dirty fields inside the per-entity ceiling, on tick, for the same set. Dense players may still resemble snapshots; the idle majority of the world is where the budget holds.
Prove the Savings — Then Don't Give Them Back
That idle majority only stays cheap if you can prove it. Measure the custom pack path against a naive SyncVar and SyncList baseline with Fish-Net's bandwidth tools, at the same worst-case entity count that locked the envelope—not a quiet lobby. Hold tick rate and interest set fixed. If packed deltas still break the per-entity ceiling when every nearby cell is occupied, the writer is not done.
Serializer traps that steal the budget
- URP and VFX on the wire. Cosmetics belong on the client; packing them spends gameplay bytes on presentation.
- Allocations in Writer/Reader extensions. Temp arrays, boxed enums, and stringly state tax the tick and WebGL GC.
- RPC spam mixed with tick sync. Continuous state does not belong on unaligned, uncompressed RPCs.
- Schema drift between clients. An extra bit, a reordered field, or an unversioned layout desyncs prediction without a loud error.
Run that comparison on a dedicated-server build so editor-only noise does not masquerade as bandwidth. Enable Dedicated Server Optimizations: Unity strips shaders and reduces errors and warnings, which keeps serializer tests readable instead of drowning them in logs that never ship.
Profile kb/s on a dedicated-server player, not in the Editor. Unstripped shaders and editor warnings hide whether the custom path actually beat the baseline.
A short go-live checklist
- Confirm each archetype's packed size still fits the locked ceiling at peak visible entities.
- Compare custom versus naive kb/s on the same tick and AOI; ship only if the custom path wins under swarm density.
- Verify no URP or VFX fields leave the server, writers stay allocation-free, and RPCs are not carrying continuous state.
- Pin the schema version on both ends so a layout change cannot silently ship.
High-entity IO stays bandwidth-honest on Fish-Net when custom serializers enforce the per-entity byte budget, write GC-free on the tick, and leave URP visuals off the wire. Diagnostics are how you know that contract held in production, not just on paper.
Key Takeaways
Lock your per-archetype byte budgets, ship GC-free tick writers, and instrument worst-case kb/s before you add another SyncVar.
Frequently Asked Questions
You Might Also Like
- Fish-Net Multiplayer Cut Fish-Net Bandwidth Costs: Network LOD and Serialization for High-Entity Unity IO Games
- Fish-Net Multiplayer Fish-Net SyncType State Channels: Ownership, SendRate Tiers, and OnChange Correctness for Unity IO Games
- Fish-Net Multiplayer Mass-Aware Interest Management for Browser Fat Games with Fish-Net and URP
- Fish-Net Multiplayer Scaling Fish-Networking for High-Player Browser IO Experiences
- Fish-Net Multiplayer Fish-Net Object Pooling and Network Spawning Best Practices