Skip to content

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.

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.

ClaimHow the official documentation treats it
The first player to join becomes masterStated as a guarantee
A PlayerObject’s owner is fixed to that playerStated as a guarantee (scoped to while Start and OnDeserialization are running)
Synced variables arriveNo statement of guarantee
They arrive in the order they were sentNo statement of guarantee
They arrive exactly onceNo statement of guarantee
An object has exactly one ownerDescribed, but with no statement of guarantee
Players who join late receive the network events sent before they arrivedExplicitly 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 it runsFields marked [UdonSynced]. Types are limited to those the runtime provides (see Sync for the list)
The unit that gets sentNot 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 arraysAlways 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 attributeNo
Local variables, static, const, readonlyThe attribute can’t be applied
Ownership itselfNot a synced variable. Transfers travel by a different path
Objects created at runtimeAbsent from the official documentation proper; the SDK release notes mention that they don’t synchronize
The history of network eventsNot re-sent

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 measuresRough figure
Amount sendable per secondAbout 11 kilobytes
Limit per sendRoughly 280,496 bytes for manual sync, roughly 200 bytes for continuous sync
How often a network event can be called5 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 overallThe 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 syncA 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 sendNot 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

[UdonBehaviourSyncMode] decides which sync mode that Behaviour uses.

In either modeWhat 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 syncTreat 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 syncThe 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 objectDon’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
ItemManual sync (Manual)Continuous sync (Continuous)
What triggers a sendThe owner calling RequestSerialization()Automatic sending at a fixed interval, even when nothing changed
Choosing when to sendAvailableThe official documentation explicitly says you can’t
When assigning to a synced variableNot sent. The value stays with the owner until RequestSerialization() is next calledLeft as is, it rides along on the next send
When calling RequestSerialization()A send is scheduledNothing happens. It doesn’t add a send
When sends back upHeld and resentIt is dropped and an error appears in the log
Limit per sendRoughly 280,496 bytesRoughly 200 bytes
The order values are received inOnly some of the values you sent may arriveOnly some of the values you sent arrive; the ones in between are skipped
What it suitsDiscrete values such as scores, states, and board positionsContinuously changing visuals such as position, rotation, and dials

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
Where it runsOn 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 useOnly 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 useSynced 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 leftIt isn’t called; an owner is assigned automatically. There is no rule for who gets it
What this is forIt’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 variablesOwnership is not a synced variable, so no amount of synced variables will perform this re-establishment
If you forget to write itIt 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
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 onceBoth 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 resolvingUnresolved. 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 turnHold the turn itself in a synced variable rather than in ownership
LocationAssigning to a synced variable
After confirming you are the ownerWrite it here
Inside StartAvoid. 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 FieldChangeCallbackDon’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.

MeansWhat it tells youWhat it doesn’t tell you
OnDeserialization()That every write to the synced variables has finishedWhether a value changed. Whether you are the owner
FieldChangeCallbackThat this one variable was writtenWhether the other synced variables are fresh. Changes to an array’s contents
OnPostSerialization()That a send was attemptedWhether the other side received it
The order of writesVariables 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 contentsFieldChangeCallback isn’t called (the array itself is unchanged). To signal it, hold a generation number in a separate synced variable
SerializationResult.successtrue 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 arrivedThere 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
SymptomWhat is actually happening
The value you assigned revertsYou 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 onceOwnership is being used as mutual exclusion
It snaps back the moment it’s grabbedOnOwnershipTransferred() isn’t written
It arrives late, or not at allYou are hitting the send limit. Manual sync holds it and retries; continuous sync drops it
The order comes out swappedSynced variables carry no ordering guarantee. A network event can arrive ahead of the synced variables
Someone who joined later gets nothingNetwork events are not re-sent. There are reports of synced variables not arriving either
Every effect fires at once the instant someone joinsOnDeserialization() runs on the receive at join time, and the effects are being played there
The same work runs repeatedlyCalls to OnDeserialization() are not one-to-one with value changes
The value is right but the visuals are staleThe visuals aren’t updated on receive. Call the same routine from both the sending and the receiving side
Network eventsNot 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 variablesThese are supposed to be delivered. Three kinds of failure are reported, all of them unresolved
Failure 1Neither the value nor the notification arrives
Failure 2The value is there but OnDeserialization() isn’t called
Failure 3It 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 receiveYou 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
SyntaxRather 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-sendingCalling 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

Every example here has been compiled. Whether the values actually arrive in-game has not been verified for this reference.

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);
}
}

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 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);
}
}

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;
}
}

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);
}
}

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();
}
}
}

All of these compile and raise no errors. If they break, they break at runtime, and not every time.

FormWhy
Using ownership as mutual exclusionA 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 thereafterThere 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 eventThe 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 itOwnership changes without notification (collisions, pickups)
Calling SetActive(false) on the synced object itselfThe 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 timesThe 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 replyThere 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 changedA single drop leaves it permanently out of step
Hardcoding a player-count cap and packing to itVRChat tells authors to handle counts going over the cap
Synchronizing objects created at runtimeThey don’t synchronize

What your code actually compiles to, and how many synced variables it has, is returned by inspect on the MCP server.