Fish-Net SyncType State Channels: Ownership, SendRate Tiers, and OnChange Correctness for Unity IO Games
Fish-Net Multiplayer
12 Min Read

Fish-Net SyncType State Channels: Ownership, SendRate Tiers, and OnChange Correctness for Unity IO Games

AI Generated

IO games are a few numbers under pressure. Mass, score, inventory, and growth decide who eats whom, yet they share a NetworkObject with movement, prediction, and a crowd of observers. The failure mode is familiar: every scalar becomes another high-rate write, SyncVars start acting like a second NetworkTransform, and OnChange applies the same visual twice on host.

Fish-Net SyncTypes — SyncVar, SyncList, SyncDictionary, and custom types — are state channels. A channel has one writer, a SendRate that matches how stale the value can be before play feels wrong, and a change path that is correct for host, remote clients, and late joiners. Movement still belongs to NetworkTransform or your own tick pipeline. SyncTypes carry the discrete game state that observers must converge on, not interpolate frame to frame.

This guide maps typical IO fields onto those channels. You will decide ownership first, assign SendRate tiers second, and write OnChange so a host build and a dedicated-server build produce the same result. The rest of the article follows that order so you can ship mass and inventory without turning every number into bandwidth.

Summary
  • Treat mass, score, inventory, and growth as discrete SyncType channels, not interpolated movement.
  • Keep writes server-owned; clients request changes instead of mutating SyncVars.
  • Pick SendRate by how stale a value can be before gameplay feels wrong.
  • Write OnChange so host, remotes, and late joiners apply the same visual once.
  • Use SyncList or SyncDictionary for inventory; pack growth in a custom SyncType when deltas matter.

Treat Discrete IO Facts as Channels, Not Features

Every gameplay number only holds up in production if you stop treating it as a feature to tick off and start treating it as a state channel: a durable, observer-facing fact that has to stay correct under ownership, culling, and host play.

A channel is not motion and it is not a one-shot. Mass, size tier, score, team, loadout, match phase — those last, observers must see them, and UI or VFX hang off them. A dash impulse, a loot RPC, or the next predicted pose are different jobs. Mixing them is how an IO title grows a second NetworkTransform it never meant to ship.

IO and fat-entity games multiply SyncType cost even when each field looks small. You are not paying for one value. You pay that field times every relevant entity, times every observer who still has the object spawned, times every send that fires because something in the interest set changed. Feature checklists hide that product; channel design makes it visible.

Draw the boundary and keep it. Continuous pose and velocity stay on NetworkTransform and prediction. Distance culling stays on observers and LOD. This article owns the remaining slice: correctness and cadence of discrete, replicated attributes. If a value is not a pose and not an event, it is a channel — and it needs a contract before you add the SyncType.

The five-question channel contract

That contract is five questions, not a plugin page. Answer them in this order before you pick a SyncType, a send interval, or an OnChange listener.

  • Authority — who may write it, and whether the host path still treats the server as owner
  • Change frequency — how often the fact actually moves in play, not how often you could write it
  • Miss tolerance — whether a late or dropped update is a visual glitch or a rules break
  • UI/VFX reaction — who must receive OnChange, and whether host double-fire is acceptable
  • Collection vs scalar — a single int is not an inventory list, and the replication shape has to match

Fill those five and you have a channel you can price. Skip them and the next choice — SyncType, RPC, or prediction — will be a guess.

One Job Per Fact: SyncType, RPC, or Prediction

Flowchart splitting gameplay signals into Fish-Net SyncType, RPC, and prediction paths for IO games

That choice is not a taste call. A SyncType is a durable, server-owned fact observers keep as last-known state. An RPC is a one-shot intent. Prediction is a local simulation of input-driven motion. Give one fact two of those jobs and you pay twice—once in bandwidth, again in host-only OnChange bugs.

SyncTypes hold the facts that survive a missed packet

Map the shape of the fact to the SyncType, not the feature name on the design doc.

  • SyncVar for a single authoritative scalar or enum: score, a mass bucket, an alive flag, a team id. One writer, many readers; late joiners reconcile from the current value.
  • SyncList and SyncDictionary for inventories, buff stacks, and participant tables—sets that grow and shrink by one entry.
  • SyncTimer for round clocks and ability cooldowns. The server owns remaining time; clients display it. Do not invent a second ticking field on every observer.

Those channels exist so a missed packet is not a lost truth. The observer already has the last value, and the next send repairs it.

RPCs fire intents that must never become fields

Eat attempts, emotes, burst VFX, a pellet click—fire-and-forget. A ServerRpc carries the request. ObserversRpc or TargetRpc paints the result for whoever needs to see it once. Promote that pulse into a SyncVar “just so everyone got it” and you have created a permanent field that keeps dirtying, serializing, and firing OnChange long after the moment is gone.

Prediction owns motion. SyncVars do not.

Input-driven pose and velocity already live on NetworkTransform plus prediction. Growth stats—mass, score, loadout—are not motion. Writing position into a SyncVar looks simple and plays worse than dedicated sync: interpolation quality drops, a high-frequency channel starts chatting, and you still need a second path for client-side feel. Leave pose on the transform stack. Leave size tiers on a SyncType.

Pack co-changing facts; mutate collections

Three chatty SyncVars—mass, size tier, skin id—that always change together are one packed custom SyncType. You serialize the composite once, observers receive one OnChange, and you never hand-roll an RPC per field to keep them in lockstep.

Collections should change in small mutations. Add one buff, remove one inventory slot, update one participant row. Fish-Net then ships only the dirty entries. Replacing the whole list every tick turns a cheap channel into a broadcast of the entire table—the opposite of pricing the fact.

Write Authority: Clients Ask, the Server Commits

That same pricing logic applies to who is allowed to dirty the field. A SyncType other peers must observe is a server contract: only the host or dedicated server writes it. A client assignment looks instant in the inspector, but it is not network truth. The next server snapshot overwrites the local value, and every observer who never saw the client's write stays on the old fact.

Predicted display, not a second ledger

The classic IO shortcut is to bump mass or score the instant a pellet is collected so the HUD feels snappy. That local write then fights Fish-Net's reconciliation: the bar flickers, OnChange fires twice with contradictory values, and a compromised client can advertise a size the server never granted. Keep a predicted display field on the owner—an unsynced float or int driven from local prediction—and leave the SyncVar as the committed channel. When the snapshot arrives, lerp or snap the display toward it.

One request path

  1. Owner sends a ServerRpc (eat attempt, loadout pick, team request).
  2. Server validates range, cooldown, and match rules, then writes the SyncType.
  3. Fish-Net ships the dirty delta to observers.
  4. OnChange is the only place UI and VFX react.

If the owner must feel the change before the round trip, predict into that local display field—never write the SyncVar from the client. ExcludeOwner sends, client-unsynchronized SyncTypes, and RunLocally ServerRpcs are parallel channels for owner-only echoes (ability wind-up, inventory highlight), not a second write to the observed fact.

Pro Tip

Never assign an observer-facing SyncType from a client. The next snapshot overwrites it, OnChange lies, and an open browser IO lobby treats that field as a cheat vector.

A client-writable mass or score is both a cheat surface and a desync engine. Peers render different worlds, eat checks disagree, and you spend the match reconciling a fact that should have been one server write.

Give Every Channel a SendRate Tier

Authority stops the cheat. SendRate stops the flood. Once only the server commits mass and score, the next decision is how often a dirty value is allowed to leave the box. In Fish-Net a SyncType SendRate is a per-second ceiling on those flushes. Zero means the field ships as often as the simulation tick allows whenever it is dirty—correct for an elimination flag, reckless for a growth number that dirties on every pellet.

The habit that hurts IO rooms is treating every SyncVar like a high-rate default stream. Hundreds of cells each nibble, each mark a field dirty, each try to flush on that cadence, and you have rebuilt a second motion channel out of floats. Observers do not pay for “one more number”; they pay for every nearby entity repeating that number.

Three tiers that match miss cost

  • Critical — combat flags, alive/dead, match phase. Keep SendRate near the tick so the first dirty write leaves immediately. A missed elimination changes who can eat whom.
  • Gameplay — mass, score, size bucket. Use a moderate ceiling. Players need the value to trend, not to see every crumb.
  • Cosmetic — name color, non-competitive skin FX, title flair. Slow is fine. A late hat never desyncs an eat check.

Coalesce on the server, then write once

Dirty-with-budget is the habit that makes the gameplay tier honest. Accumulate pickups in a private server field. On an interval—or when the delta crosses a visible threshold—assign the SyncVar once. OnChange then fires for a step players can see, not a spray of near-identical masses. The room still converges; you simply refused to pay per pellet.

Reliability is a different knob. Unreliable is acceptable only where a dropped sample is harmless because a later one supersedes it—mass and score visuals are the usual case. Never put inventory, loadout slots, or any collection whose meaning is “these entries exist” on an unreliable channel. A lost add is not a slightly stale number; it is an item OnChange will never replay.

OnChange Is the Reaction Bus — Survive the Host Trap

That same OnChange is not another write. After SendRate flushes a committed value, the SyncType already holds the new fact. The hook is a reaction bus: drive the mass meter, pop floating score text, swap a size-tier mesh, play the eat sting. Presentation follows truth; it does not invent it.

Fish-Net invokes the callback with previous, next, and asServer once next is already stored. Gameplay mutation belongs on the server write that dirtied the field—coalesce growth, clamp score, then assign the SyncVar. Do that work before or outside the hook. Inside the callback you only react: refresh UI, queue VFX, update a local cache the HUD already trusts. Late joiners receive the current value, not a replay of every intermediate OnChange, so logic that lives only in the hook will never reconstruct match state for them.

The host already applied the write

On a listen-server the server mutation runs first on the same peer. When the client-side callback later fires with asServer false, previous can already equal current because the in-memory field moved on the server pass. A naive check that next is greater than prev then silently skips the popup, tier swap, or sting in the editor and on host builds. Dedicated clients still see a real delta, so the game looks correct in a two-process test and broken the moment you press Play as host.

Host-safe patterns stay small and local:

  • Branch on asServer: skip presentation on the server pass, or drive host UI from the write site instead of waiting for the client hook.
  • Cache last-rendered values separately from the SyncVar so the host client can still compute a visual delta when previous already equals next.
  • Extract a shared local method and call it from the server write on host and from OnChange on remote clients.

Never put authoritative simulation inside client OnChange. Growth, scoring, and inventory commits already happened on the server write. A hook that re-applies mass, awards points, or dirties another SyncType double-fires on host and invents a second simulation on dedicated clients. Keep the bus one-way: committed state in, presentation out.

Init, Teardown, and Arrival: Lifecycle Contracts for SyncTypes

That one-way bus only stays honest if the channel is born, arrives, and dies in a contract you control. OnChange can fire on first spawn, on late-join catch-up, and again when a pooled NetworkObject is recycled. Treat those moments as part of the SyncType itself, not as Unity lifecycle trivia sitting beside it.

Ready locally, commit on the server

Initialize empty SyncList and SyncDictionary collections in Awake on both sides so local UI, HUD binders, and iteration paths never touch a null. That is readiness, not authority. Values that must replicate — starting mass, team, score, match phase — belong in OnStartServer (or the equivalent server-start callback), where the write is the same server-owned commit every other channel uses. Writes in Awake or OnEnable race NetworkObject identity and can leave late joiners staring at defaults while the host already thinks the match has begun.

Snapshot before destroy — never read after

Do not read a SyncVar in OnDestroy to award last-hit score, log final mass, or dirty another channel. By then Fish-Net may already have reset the field, and host versus dedicated will disagree about what you saw. Snapshot the committed value into a plain field on the last meaningful server write, or while the object is still valid in a stop/despawn callback, and tear scoring down from that snapshot. The SyncType is observer state, not a teardown ledger.

Arrival is not declaration order

Reliable SyncType updates do not land in the order you declared the fields. Codegen packs and applies them in reverse declaration order, so a client can see size-tier before mass, or team after loadout, even when the server wrote both in one tick. Handshake logic that assumes field A arrives before field B will misfire OnChange and paint the wrong presentation. When one value’s meaning depends on another, pack them into a single structured SyncType or gate the reaction behind an explicit sequence flag that only flips after the whole payload is valid.

Pools leak yesterday’s mass

Pooled NetworkObjects keep last SyncType values until you overwrite them. Recycled orbs and players will flush prior mass and score if the server spawn path does not reset every channel default before the first observer send. Treat spawn as a full channel wipe — same SendRate tiers, same host-safe OnChange path — not a “set what’s new” patch. A leaked score is still a second source of truth, and it costs the same bandwidth as a real one.

Four Recipes That Stay Cheap Beside Observers

IO player entity diagram showing Fish-Net state channels, server gate, observers, and separate NetworkTransform path

That is why the live match cannot treat updates as patches either. Once spawn is a wipe, every subsequent write is a full channel commit at its tier — four server-owned SyncTypes with a SendRate ceiling and a host-safe OnChange path, sitting beside observers and Network LOD rather than replacing them. Pose still rides NetworkTransform. These facts never become a second transform.

Mass as a bucket, not a stream

Food pickups tempt a live-float write every tick. Quantize mass into a SyncVar bucket, or pack mass, visual tier, and skin id into one custom SyncType so a single dirty flush carries the visual contract. Coalesce growth on the server across the gameplay interval, then commit once. OnChange drives scale-tier VFX only when the bucket actually changes — not on every gram. Hosts still use an asServer branch or a last-rendered cache so they do not double-play the pop.

Scoreboards that mutate, not rewrite

Personal score is a scalar SyncVar. The board is a SyncList or SyncDictionary of those scores. On a frag, mutate the one entry that moved — or snapshot ranks on a slower tier if the UI does not need per-kill immediacy. A full table rewrite per kill is a second source of truth wearing a leaderboard skin, and every observer pays for rows that did not change.

Inventories as ids, not graphs

Loadouts and carried orbs belong in a SyncList or SyncDictionary of item ids and stacks, never ScriptableObject graphs. Mutate the dirty key. Rebuilding the collection ships every entry again and fights the small-mutation rule already set for collections.

Phase and clock as two channels

Match phase is an enum SyncVar: lobby, play, overtime, results. The round clock is a SyncTimer. Clients draw remaining time from that timer channel. A local countdown that “should be close enough” is a guess, and guesses desync the host the first time a hitch lands.

None of this fights culling. Observers and Network LOD already decide who is in the set, so a coalesced mass write only leaves the server for interested peers. The before-and-after is blunt: every pickup writing raw mass every tick to every client, versus bucketed server commits at the gameplay SendRate only to observers who already care. Channels stay cheap because the audience is already small.

Key Takeaways

State channels, not motionMass tier, score, team, loadout, and match phase are durable observer-facing facts; pose, velocity, and culling stay on NetworkTransform, prediction, and LOD, never a second transform SyncVar.
One job per factSyncVar, SyncList/Dict, and SyncTimer carry authoritative scalars and collections; ServerRpc/ObserversRpc/TargetRpc fire intents; prediction stays on input-driven motion; pack fields that change together and mutate only dirty collection entries.
Clients ask, the server commitsOnly the server writes observer-facing SyncTypes after a validated ServerRpc; keep predicted local display separate so OnChange stays truthful and open-browser IO cannot cheat the channel.
SendRate is a per-channel ceilingAssign critical, gameplay, or cosmetic flush rates, coalesce growth on the server before a single write, and reserve unreliable delivery for supersedable visuals, not inventory integrity.
OnChange is a reaction busDrive meters, VFX, and tier swaps from previous/next/asServer after the server write; survive the host trap with asServer branches, last-rendered caches, or a shared presentation method, never client-side authority.
Lifecycle and cheap recipesEmpty collections in Awake, write replicas in OnStartServer, snapshot before teardown, reset pooled defaults, and respect reverse arrival order; keep mass bucketed, leaderboards mutate-one, inventories as id/stack, and phase plus SyncTimer riding existing observers.

Open the player prefab, list every discrete IO fact, and give each a server-owned SyncType, a SendRate tier, and a host-safe OnChange path before the next playtest.

Frequently Asked Questions

Should player mass be a SyncVar or a custom SyncType?
Use a SyncVar when mass is a single authoritative number that observers only need at a modest rate. Move to a custom SyncType when you pack mass with related growth fields, or when you need your own dirty and serialize path to cut payload size.
Who should own writes to score, inventory, and growth?
The server should own every write that changes ranking, items, or size. Clients send input or requests; the server validates, mutates the SyncType, and observers receive the result. Client-writable SyncVars on these fields invite desync and easy cheating.
How should SendRate differ between score and inventory?
Score can sit on a slow tier because a slightly late number rarely changes a frame of play. Inventory and mass need a faster tier when a missed update would let someone eat, miss a pickup, or show the wrong loadout. Match SendRate to how wrong the value can be, not to the tick rate.
Why does OnChange fire twice on a Fish-Net host?
Host is server and client in one process, so the local apply and the observer callback can both run unless you gate them. Write OnChange so visuals and gameplay effects run once, and so a late joiner who receives the current value still reaches the same state.
When is a SyncList or SyncDictionary better than several SyncVars?
Use a SyncList or SyncDictionary when the set of items grows, shrinks, or updates by key. Separate SyncVars explode as slots change and hide which entry actually dirtied. Collections send the delta you need and keep OnChange scoped to the entry that changed.
When should an IO value be an RPC instead of a SyncType?
Use an RPC for a one-shot event that no late joiner must reconstruct, such as a feed popup. If a reconnecting or distant observer still needs the current mass, score, or bag, it belongs on a SyncType so the channel carries the latest state, not a missed impulse.

You Might Also Like