Sync design
This page covers what synchronization actually does (what arrives and what doesn’t) and how to write code with that in mind. The list of supported attributes and methods is in Sync.
Where this page says “not guaranteed”, it does not mean we failed to verify it. It means VRChat’s official documentation contains no statement guaranteeing it. Conversely, anything described here as explicitly stated by the official documentation is limited to what the original text guarantees in so many words.
What is guaranteed
Section titled “What is guaranteed”The official documentation guarantees two things: the rule that decides an instance’s master, and the owner of a PlayerObject. The same page declares that this is the only guarantee VRChat currently makes about master behaviour, and that any other observed behaviour is subject to change.
| Claim | How the official documentation treats it |
|---|---|
The first player to join becomes master | Stated as a guarantee |
A PlayerObject’s owner is fixed to that player | Stated as a guarantee (scoped to while Start and OnDeserialization are running) |
| Synced variables arrive | No statement of guarantee |
| They arrive in the order they were sent | No statement of guarantee |
| They arrive exactly once | No statement of guarantee |
| An object has exactly one owner | Described, but with no statement of guarantee |
| Players who join late receive the network events sent before they arrived | Explicitly stated not to arrive |
Not a single word of guarantee appears in the official documentation about the delivery of synced variables. Every piece of advice on this page therefore takes the form of deciding up front what happens when something doesn’t arrive.
What gets synchronized
Section titled “What gets synchronized”| What it runs | Fields marked [UdonSynced]. Types are limited to those the runtime provides (see Sync for the list) |
| The unit that gets sent | Not a single field. One message carrying all of a Behaviour’s synced variables is sent together, and there is no way to send just one of them |
| Synced arrays | Always initialize them before use. A single uninitialized one stops synchronization for that entire Behaviour (one of the few failure conditions the official documentation states flatly) |
| Synchronized? | |
|---|---|
Fields marked [UdonSynced] | Yes |
| Fields without the attribute | No |
Local variables, static, const, readonly | The attribute can’t be applied |
| Ownership itself | Not a synced variable. Transfers travel by a different path |
| Objects created at runtime | Absent from the official documentation proper; the SDK release notes mention that they don’t synchronize |
| The history of network events | Not re-sent |
How much you can send
Section titled “How much you can send”The official documentation gives figures for the following three (approximations even in the original, and the same section opens with the caveat that every specification written there is subject to change).
| What it measures | Rough figure |
|---|---|
| Amount sendable per second | About 11 kilobytes |
| Limit per send | Roughly 280,496 bytes for manual sync, roughly 200 bytes for continuous sync |
| How often a network event can be called | 5 times per second per event by default, up to 100 by writing something like [NetworkCallable(100)]. On top of that, roughly 100 times per second across everything the sender sends |
| What to watch for when reading this | |
|---|---|
| The roughly 100 per second overall | The official documentation says it is determined dynamically and can’t be configured. You can stay within the rate you specified yourself and still hit this one |
| The roughly 200 bytes for continuous sync | A VRChat staff member has stated roughly 256 bytes elsewhere, which conflicts with the figure in the official documentation. Estimating with the smaller one is safer |
| A single send | Not necessarily one on the wire. Network event arguments over 1024 bytes are split internally into several events, so a send you intended as one can hit the rate limit |
The following figures aren’t in the official documentation.
- How many times per second continuous sync transmits
- The upper bound on string length (this can only be stated as “whatever fits the limit above, at 2 bytes per character”)
- The upper bound on array element count
When it sends
Section titled “When it sends”[UdonBehaviourSyncMode] decides which sync mode that Behaviour uses.
| In either mode | What you send does not necessarily arrive. The official documentation’s description of the attribute says manual sync makes the update certain when you request it, but several in-game reports contradict this and remain unresolved |
| Continuous sync | Treat it as delivering only part of the sequence of values you sent. Signalling a state change through a continuously synced variable does not work (put values that switch on the manual sync side) |
OnPreSerialization() / OnPostSerialization() under continuous sync | The official documentation says nothing about whether they are called. Keep them light on the assumption that they are, and either way is fine |
| Behaviours with different modes on one object | Don’t put them on the same GameObject. One page of the official documentation says the most restrictive one wins and another says it doesn’t work; the two don’t agree |
sequenceDiagram
participant O as Owner
participant N as Network
O->>O: Assign to a synced variable
alt Manual sync
O->>O: Call RequestSerialization()
O->>N: Send
O->>O: Assign to a synced variable again
Note over O: Assigning alone doesn't send it
else Continuous sync
O->>N: Send
O->>N: Send (repeats even if the value hasn't changed)
Note over O: RequestSerialization() has no effect
end
| Item | Manual sync (Manual) | Continuous sync (Continuous) |
|---|---|---|
| What triggers a send | The owner calling RequestSerialization() | Automatic sending at a fixed interval, even when nothing changed |
| Choosing when to send | Available | The official documentation explicitly says you can’t |
| When assigning to a synced variable | Not sent. The value stays with the owner until RequestSerialization() is next called | Left as is, it rides along on the next send |
When calling RequestSerialization() | A send is scheduled | Nothing happens. It doesn’t add a send |
| When sends back up | Held and resent | It is dropped and an error appears in the log |
| Limit per send | Roughly 280,496 bytes | Roughly 200 bytes |
| The order values are received in | Only some of the values you sent may arrive | Only some of the values you sent arrive; the ones in between are skipped |
| What it suits | Discrete values such as scores, states, and board positions | Continuously changing visuals such as position, rotation, and dials |
Ownership
Section titled “Ownership”The mechanism itself is in Sync. This section covers only the three points that affect design.
sequenceDiagram
participant R as Requester
participant O as Current owner
participant P as Other player
R->>R: Call SetOwner(self)
R->>R: OnOwnershipRequest() is called
O->>O: The same OnOwnershipRequest() is called here too
R->>R: If accepted, the owner becomes self on the spot
R->>R: OnOwnershipTransferred() is called
R-->>P: Reaches other players
P->>P: The owner as seen by other players also becomes the requester
P->>P: OnOwnershipTransferred() is called
Whether a request is allowed
Section titled “Whether a request is allowed”| Where it runs | On both the requester and the current owner, each locally. The official documentation warns that state diverges when the two reach different verdicts |
| What the verdict may use | Only what is certain to look the same to both (static information that doesn’t change at runtime, and the player identifiers passed as arguments) |
| What it must not use | Synced variables, and values that exist only locally. There is no guarantee the two hold the same value, so the verdicts split |
| When the current owner has already left | It isn’t called; an owner is assigned automatically. There is no rule for who gets it |
Ownership transfer
Section titled “Ownership transfer”| What this is for | It’s where you re-establish the local state you derive from ownership (whether it can be grabbed, which side drives it, how it’s displayed) |
| Relationship to synced variables | Ownership is not a synced variable, so no amount of synced variables will perform this re-establishment |
| If you forget to write it | It snaps back the moment it’s grabbed, two people can move it at once, and it looks different to different people. The API succeeds and no error appears |
The result of Networking.IsOwner() | Don’t store it; read it fresh right before each use. A collision or a pickup can change the owner, and nothing is called when that happens |
Simultaneous acquisition
Section titled “Simultaneous acquisition”sequenceDiagram
participant X as First player
participant N as Network
participant Y as Second player
X->>X: Call SetOwner(self)
Y->>Y: Call SetOwner(self)
X->>X: The owner as seen by the first player is the first player
Y->>Y: The owner as seen by the second player is the second player
X->>N: Assign to a synced variable and send
Y->>N: Assign to a synced variable and send
N->>N: Whichever arrives first becomes the owner
N->>X: Receive the winning value
N->>Y: Receive the winning value
When two people call Networking.SetOwner() at once | Both become the owner on their own side. Acquiring ownership is not mutual exclusion (the loser’s assignment disappears the moment the winner’s value arrives) |
| Reports of it never resolving | Unresolved. It is said to happen more readily the lower the latency, which makes a development environment a likely place to hit it |
| When you want exactly one person to take a turn | Hold the turn itself in a synced variable rather than in ownership |
Where to write
Section titled “Where to write”| Location | Assigning to a synced variable |
|---|---|
| After confirming you are the owner | Write it here |
Inside Start | Avoid. There are reports of synced variables being overwritten before Start, and of RequestSerialization() inside Start never arriving |
Inside Update (manual sync) | Assigning is fine, but don’t call RequestSerialization() every frame |
Inside Update (continuous sync) | Assigning is enough. Calling RequestSerialization() doesn’t add any sends |
OnPreSerialization() | The official documentation calls this a good place to set synced variables. Even so, there is no guarantee that what you write here rides along in that message |
OnDeserialization() | Don’t write here. It overwrites the value you received |
OnOwnershipRequest() | Don’t read here. The two sides don’t hold the same value |
The setter side of a FieldChangeCallback | Don’t read other synced variables. The official documentation explicitly says they may still hold old values |
Receiving a network event can run before that Behaviour’s Start. Write the receiving side so that it survives its fields still holding their initial values. See also Field initialization and defaults for execution order.
Detecting arrival
Section titled “Detecting arrival”| Means | What it tells you | What it doesn’t tell you |
|---|---|---|
OnDeserialization() | That every write to the synced variables has finished | Whether a value changed. Whether you are the owner |
FieldChangeCallback | That this one variable was written | Whether the other synced variables are fresh. Changes to an array’s contents |
OnPostSerialization() | That a send was attempted | Whether the other side received it |
| The order of writes | Variables are written one at a time in no fixed order. FieldChangeCallback runs on each write, and OnDeserialization() runs once they are all written |
| An array’s contents | FieldChangeCallback isn’t called (the array itself is unchanged). To signal it, hold a generation number in a separate synced variable |
SerializationResult.success | true still doesn’t mean it arrived. The official documentation describes it as immediately after the send was attempted |
OnDeserialization() | It is commonly treated as never firing on the owner, but the official documentation says nothing of the sort and cases of it firing on the owner have been reported. It can’t be used to decide whether you are the owner |
| A reliable way to know something arrived | There isn’t one. What you learn is that a value came, not that the value you hold is the newest. To judge that, hold a generation number as a synced variable |
Common failures
Section titled “Common failures”| Symptom | What is actually happening |
|---|---|
| The value you assigned reverts | You assigned without being the owner. No error appears. The official documentation says nothing about when it reverts, and secondary sources split between a few frames later and unknown |
| Two people can move it at once | Ownership is being used as mutual exclusion |
| It snaps back the moment it’s grabbed | OnOwnershipTransferred() isn’t written |
| It arrives late, or not at all | You are hitting the send limit. Manual sync holds it and retries; continuous sync drops it |
| The order comes out swapped | Synced variables carry no ordering guarantee. A network event can arrive ahead of the synced variables |
| Someone who joined later gets nothing | Network events are not re-sent. There are reports of synced variables not arriving either |
| Every effect fires at once the instant someone joins | OnDeserialization() runs on the receive at join time, and the effects are being played there |
| The same work runs repeatedly | Calls to OnDeserialization() are not one-to-one with value changes |
| The value is right but the visuals are stale | The visuals aren’t updated on receive. Call the same routine from both the sending and the receiving side |
Players who join late
Section titled “Players who join late”| Network events | Not re-sent to a player who joined partway through. The official documentation states this explicitly, making it one of the few certainties in this area (a design that broadcasts state through events fails on this point alone) |
| Synced variables | These are supposed to be delivered. Three kinds of failure are reported, all of them unresolved |
| Failure 1 | Neither the value nor the notification arrives |
| Failure 2 | The value is there but OnDeserialization() isn’t called |
| Failure 3 | It starts with a stale value. It is valid both as a type and as content, so looking at the value tells you nothing |
| Detecting that you didn’t receive | You can’t. Failure 3 means asking whether the value is still its initial one doesn’t detect it, and follow-up testing shows that reading the value every frame doesn’t fix it either |
| Syntax | Rather than deciding whether you received, write it so that being observed in the not-yet-received state is harmless (the lock starts locked, the game starts not-joined, ownership starts unclaimed) |
| Detecting a join and re-sending | Calling RequestSerialization() inside OnPlayerJoined() is reported not to synchronize. The official documentation also states that when the owner is unresponsive, events during that time may not run |
Patterns to write
Section titled “Patterns to write”Every example here has been compiled. Whether the values actually arrive in-game has not been verified for this reference.
Taking ownership before writing
Section titled “Taking ownership before writing”Take ownership before writing synced variables, send after writing, and update your own visuals yourself. The receiving and sending sides call the same routine, so write Apply() so that calling it any number of times gives the same result.
using UnityEngine;using Tsukimi;using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class SyncOwnedWrite : TsukimiBehaviour{ [UdonSynced] private int stage;
public override void Interact() { if (!Networking.IsOwner(gameObject)) { Networking.SetOwner(Networking.LocalPlayer, gameObject); }
stage = (stage + 1) % 4; RequestSerialization(); Apply(); }
public override void OnDeserialization() { Apply(); }
private void Apply() { transform.localScale = Vector3.one * (1f + stage); }}Re-establishing state after ownership moves
Section titled “Re-establishing state after ownership moves”Re-establish the local state you derive from ownership, on the assumption that it has moved.
using Tsukimi;using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class SyncOwnershipRestore : TsukimiBehaviour{ [UdonSynced] private int stage;
public override void OnOwnershipTransferred(VRCPlayerApi player) { DisableInteractive = !Networking.IsOwner(gameObject); }}Deciding purely from the arguments
Section titled “Deciding purely from the arguments”Whether to decline a request is decided by looking only at the player in the arguments. It reads neither synced variables nor values that exist only locally.
using Tsukimi;using VRC.SDKBase;
public class SyncOwnershipRequestArgs : TsukimiBehaviour{ public override bool OnOwnershipRequest(VRCPlayerApi requestingPlayer, VRCPlayerApi requestedOwner) { return requestingPlayer.playerId == requestedOwner.playerId; }}Discarding stale values by generation number
Section titled “Discarding stale values by generation number”With no ordering guarantee, a stale value can arrive after a newer one. Send a generation number alongside and discard anything whose generation is lower than the last. Advance the number in the same place as the assignment. Advancing it in OnPreSerialization() is possible, but there is no guarantee that what you write there rides along in that message.
using UnityEngine;using Tsukimi;using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class SyncGeneration : TsukimiBehaviour{ [UdonSynced] private int state; [UdonSynced] private int generation;
private int applied = -1;
public override void Interact() { if (!Networking.IsOwner(gameObject)) { Networking.SetOwner(Networking.LocalPlayer, gameObject); }
state = (state + 1) % 4; generation = generation + 1; RequestSerialization(); Apply(generation, state); }
public override void OnDeserialization() { Apply(generation, state); }
private void Apply(int gen, int value) { if (gen <= applied) { return; }
applied = gen; transform.localScale = Vector3.one * (1f + value); }}Sending the state itself
Section titled “Sending the state itself”Sending only what changed drifts permanently after a single drop. Send the whole current state and one drop is corrected by the next send.
using UnityEngine;using Tsukimi;using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class SyncWholeState : TsukimiBehaviour{ [UdonSynced] private int[] slots = new int[8];
public override void Interact() { if (!Networking.IsOwner(gameObject)) { Networking.SetOwner(Networking.LocalPlayer, gameObject); }
for (int i = 0; i < slots.Length; i++) { slots[i] = (slots[i] + 1) % 3; }
RequestSerialization(); Apply(); }
public override void OnDeserialization() { Apply(); }
private void Apply() { int total = 0; for (int i = 0; i < slots.Length; i++) { total = total + slots[i]; }
transform.localPosition = new Vector3(0f, total * 0.1f, 0f); }}A safe default before receiving
Section titled “A safe default before receiving”Make the field’s initial value the side that is harmless in the not-yet-received state. In this example it stays locked until something arrives.
using UnityEngine;using Tsukimi;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class SyncSafeDefault : TsukimiBehaviour{ [UdonSynced] private bool unlocked;
void Start() { Apply(); }
public override void OnDeserialization() { Apply(); }
private void Apply() { transform.localScale = unlocked ? new Vector3(1f, 0.1f, 1f) : Vector3.one; }}Re-reading after a notification
Section titled “Re-reading after a notification”A network event can arrive ahead of the synced variables. Carry no value in the event and have the receiver re-read the synced variables, and the result is the same whichever arrives first.
using UnityEngine;using Tsukimi;using VRC.SDKBase;using VRC.SDK3.UdonNetworkCalling;using VRC.Udon.Common.Interfaces;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class SyncNotifyThenRead : TsukimiBehaviour{ [UdonSynced] private int stage;
public override void Interact() { if (!Networking.IsOwner(gameObject)) { Networking.SetOwner(Networking.LocalPlayer, gameObject); }
stage = (stage + 1) % 4; RequestSerialization(); SendCustomNetworkEvent(NetworkEventTarget.All, nameof(Changed)); }
[NetworkCallable] public void Changed() { Apply(); }
public override void OnDeserialization() { Apply(); }
private void Apply() { transform.localScale = Vector3.one * (1f + stage); }}Send failure and retry
Section titled “Send failure and retry”OnPostSerialization() tells you no more than that a send was attempted, but it does tell you when one failed. Retry on failure.
using Tsukimi;using VRC.SDKBase;using VRC.Udon.Common;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class SyncRetryOnFailure : TsukimiBehaviour{ [UdonSynced] private int stage;
public override void Interact() { if (!Networking.IsOwner(gameObject)) { Networking.SetOwner(Networking.LocalPlayer, gameObject); }
stage = (stage + 1) % 4; RequestSerialization(); }
public override void OnPostSerialization(SerializationResult result) { if (!result.success) { RequestSerialization(); } }}Forms better left unwritten
Section titled “Forms better left unwritten”All of these compile and raise no errors. If they break, they break at runtime, and not every time.
| Form | Why |
|---|---|
| Using ownership as mutual exclusion | A state where everyone believes they are the owner has been reported and remains unresolved. The lower the latency, the more readily it happens |
Initializing in Start and trusting it thereafter | There are standing reports of synced variables being overwritten before Start. Receiving a network event can also run before Start |
Writing synced variables immediately after Networking.SetOwner() | Statements that it takes effect immediately and statements that it doesn’t both exist, and the matter is unsettled |
| Writing synced variables and then announcing with an event | The event can arrive first, leaving the receiver reading a stale value |
Putting once-only work in OnDeserialization() | Both cases are reported: it isn’t called, and it is called with nothing changed |
Storing the result of Networking.IsOwner() and reusing it | Ownership changes without notification (collisions, pickups) |
Calling SetActive(false) on the synced object itself | The official documentation says nothing, but several write-ups state that no sending, receiving, or callbacks happen while it is inactive. The behaviour on re-enabling is inconsistent too |
Calling RequestSerialization() inside OnPlayerJoined() | There are standing reports that it doesn’t synchronize |
| Deriving elapsed time by subtracting server times | The official documentation says there is no reference point and that the value can wrap. Assigning it to a float coarsens the resolution |
| Seizing ownership when there is no reply | There is no upper bound on delivery delay. The official documentation asks you to write assuming any client may pause at any time, and pauses lasting hours have been reported |
Using the departing player’s identifier in OnPlayerLeft() | Both unreadable values and exceptions have been reported |
| Sending only what changed | A single drop leaves it permanently out of step |
| Hardcoding a player-count cap and packing to it | VRChat tells authors to handle counts going over the cap |
| Synchronizing objects created at runtime | They don’t synchronize |
What your code actually compiles to, and how many synced variables it has, is returned by inspect on the MCP server.