
Fish-Net SyncType State Channels: Ownership, SendRate Tiers, and OnChange Correctness for Unity IO Games
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.
- 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
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
- Owner sends a ServerRpc (eat attempt, loadout pick, team request).
- Server validates range, cooldown, and match rules, then writes the SyncType.
- Fish-Net ships the dirty delta to observers.
- 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.
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
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
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
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 NetworkTransform and Rigidbody Synchronization for Smooth Multiplayer Physics in Unity IO Games
- Guides Server Authoritative vs Client Authoritative in Modern Multiplayer Game Development with Fish-Networking
- Fish-Net Multiplayer Advanced Client-Side Prediction with Fish-Networking in Unity
- Fish-Net Multiplayer Fish-Net Object Pooling and Network Spawning Best Practices