Fish-Net Custom Serializers and Bandwidth-Efficient Data Sync for High-Entity Unity IO Games
Fish-Net Multiplayer
11 Min Read

Fish-Net Custom Serializers and Bandwidth-Efficient Data Sync for High-Entity Unity IO Games

AI Generated

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.

Summary
  • 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.

Sources

Write a Per-Archetype Byte Budget Before Any Serializer

Per-entity-type byte budget diagram for Fish-Net IO player, food pellet, and hazard archetypes toward a browser bandwidth envelope

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.

Sources

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.

Sources

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

Tick-aligned deltas versus full snapshots bandwidth comparison across a dense Fish-Net IO player cluster

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.

Pro Tip

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

  1. Confirm each archetype's packed size still fits the locked ceiling at peak visible entities.
  2. Compare custom versus naive kb/s on the same tick and AOI; ship only if the custom path wins under swarm density.
  3. Verify no URP or VFX fields leave the server, writers stay allocation-free, and RPCs are not carrying continuous state.
  4. 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.

Sources

Key Takeaways

Default sync shapesFull-float SyncVars, SyncList inventories, and RPC-driven continuous state inflate the per-tick envelope at IO entity counts; the job is payload design, not turning sync off, and URP visuals stay off the wire.
Per-archetype byte budgetLock a hard ceiling per entity type before choosing SyncTypes or writers, working backward from players times visible entities times tick rate so serializers own bytes inside AOI rather than after it.
GC-free Writer and Reader pathsCustom pack extensions and ICustomSerializer honor that ceiling without replacing NetworkBehaviours; keep hot paths allocation-free on WebGL and stay on Fish-Net public APIs.
Fixed versioned IO schemasQuantized integer coords, discrete mass, capped scores, and bitmask or fixed-slot loadouts replace SyncList, with every field still answering to the same per-entity byte ceiling.
Tick-aligned dirty deltasSpend the budget on schema-ordered bit masks flushed on simulation ticks so client prediction stays coherent; rewrite like a snapshot only when a dense swarm dirties every field.
Prove the savingsMeasure custom writers against a naive SyncVar/SyncList baseline at worst-case entity counts, enable dedicated-server optimizations, and treat URP packing, writer reallocations, RPC mix-ins, and schema drift as regressions.

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

When do Fish-Net custom serializers beat SyncVars and SyncLists?
Use a custom serializer when the data is high-frequency, dense, or irregular—packed positions, growth stages, inventories—so per-field SyncVars would waste bytes every tick. SyncVars remain the right tool for rare, low-cardinality state. If you cannot name the byte budget for an entity type, you are not ready to customize it yet.
Why are RPCs a bad way to sync IO entity state?
RPCs are not tick-aligned, they are slow compared with proper sync, and they do not allow compression. Movement, growth, and health belong on a Writer packed to the simulation tick so every client reconstructs the same snapshot. Keep RPCs for discrete gameplay events, not per-entity streams.
How do I keep custom serializers GC-free on WebGL?
Reuse Fish-Net Writer and Reader buffers, pack with primitive writes, and never allocate strings, arrays, or boxed values on the serialize path. Browser hitching usually comes from garbage, not from the extra bit-packing math. Pair that with pooled entities so spawn and despawn stay allocation-quiet too.
What should stay off the network in a Unity IO game?
Anything the client can derive locally: URP materials, trails, juice, camera, and cosmetic scale that is a function of already-synced growth. Minimize synchronized objects and properties, and design the serializer for the worst player count the design allows. Server authority still owns positions, deaths, and inventory—it does not own the shader graph.
Can custom serializers support hundreds of entities?
Yes, if you combine them with interest management, Network LOD, and object pooling rather than broadcasting every packed entity to every peer. Fish-Net is built for bandwidth and resource optimization and for player counts from dozens to hundreds; serializers are how you spend that headroom on entities instead of wasted fields. Measure with bandwidth diagnostics against a naive SyncVar baseline before you call it done.
How do I know a custom serializer actually saved bandwidth?
Compare Fish-Net bandwidth diagnostics on the same scene: default SyncVars versus your packed Writer path, at the worst player and entity counts you intend to ship. Judge bytes per tick per entity, not peak FPS. If the custom path is not clearly leaner under load, the serializer is still packing noise.
Sources

You Might Also Like