Skip to content

Sync

SyntaxDescription
[UdonSynced] private int value;Syncing an integer
[UdonSynced] private bool flag;Syncing a boolean
[UdonSynced] private string label;Syncing a string
[UdonSynced] private Vector3 point;Syncing a struct
[UdonSynced] private Mode mode;Syncing an enum
[UdonSynced] private int[] values;Syncing an array
[UdonSynced(UdonSyncMode.Linear)]Sync with interpolation
[UdonSynced(UdonSyncMode.Smooth)]Smooth interpolation
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]Manual sync
[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]Continuous sync
public override void OnDeserialization()When a value arrives
[FieldChangeCallback(nameof(Value))]Per-variable notification
Value = 1;Assignment on the owner’s side
public override void OnPreSerialization()Just before sending
public override void OnPostSerialization(SerializationResult result)After sending
Networking.IsOwner(gameObject)Whether you are the owner
Networking.SetOwner(Networking.LocalPlayer, gameObject)Taking ownership
public override void OnOwnershipTransferred(VRCPlayerApi player)When ownership transfers
public override bool OnOwnershipRequest(...)Whether to accept the request
[NetworkCallable]Permission on the callee side
SendCustomNetworkEvent(NetworkEventTarget.All, ...)Calling with arguments
SyntaxDescriptionErrorReasonInstead
[UdonSynced] private Pair pair;Sync a struct you defined yourselfTUKI0103undecidedSync each component separately
[UdonSynced] private int[][] grid;Sync an array of arraysTUKI0103runtimeUse one flat array and compute the index (arrays)
[UdonSynced] private GameObject target;Sync a reference to an objectTUKI0103runtimeWire the same reference for everyone in the inspector, or number the targets and sync the number
[UdonSynced(UdonSyncMode.Linear)] private string label;Specify interpolation on a type that can’t interpolateTUKI0106runtimeDrop the interpolation
[UdonSynced] public int Value { get; set; }Attach it to a propertyCS0592runtimeAttach it to the backing field and connect it to the property with [FieldChangeCallback]
[UdonSynced] int v = 1;Attach it to a local variable (inside a method)CS7014runtimeMake it a field

Only fields can carry [UdonSynced].

The official documentation guarantees only two things: the first player to join becomes master, and the owner of a PlayerObject is fixed to that player. Whether a value arrives, arrives in send order, and arrives exactly once are all unguaranteed. The diagrams below are not guarantees; they follow the official documentation’s description.

Ownership is a marker set per synced object, saying “this belongs to this player right now.” The target is a GameObject, checked by passing the GameObject to Networking.IsOwner(gameObject).

Ownership matters because only the owner can assign to a synced variable. If a non-owner player assigns to it, that value is not sent to other players.

The first player to join becomes master, and the owner of an object that has never had SetOwner() called on it is master. Of this, only how master is decided is guaranteed — that such an object’s owner is master is not guaranteed.

The official documentation says one object has one owner. This isn’t guaranteed, though: states with two owners and states with no owner have both been reported.

Figure 1 What happens when a value is synced (manual sync)

Section titled “Figure 1 What happens when a value is synced (manual sync)”
sequenceDiagram
    autonumber
    participant O as Owner
    participant N as Network
    participant P as Receiver

    O->>O: Assign to a synced variable
    O->>O: Call RequestSerialization()
    O->>O: OnPreSerialization() is called
    O->>N: Send all of that program's synced variables together at once
    O->>O: OnPostSerialization() is called
    N->>P: Receive
    P->>P: Assign the received values to the variables one by one
    P->>P: FieldChangeCallback is called on each assignment
    P->>P: OnDeserialization() is called once all assignments are done
WhereNote
RequestSerialization()It doesn’t send once per call. However many times you call it, only one send happens next
SendIt can be lost in transit. The sender is not notified if it’s lost
OnPostSerialization()This only tells you a send was attempted. Whether the other side received it is unknown
Assignment on the receiving sideThe order isn’t fixed. To see a state where multiple variables are all in place, wait for OnDeserialization()

The bottom half of figure 1 is the receiver’s behaviour. It doesn’t happen on the owner.

OnDeserialization() is called when a value is received. The owner doesn’t receive the value it sent, so it’s normally not called. But the official documentation doesn’t state that it “isn’t called on the owner,” and cases of it being called on the owner have been reported. It can’t be used to decide whether you’re the owner.

FieldChangeCallback is a mechanism that routes a received value through the property’s setter instead of writing the field directly. The owner doesn’t receive a value, so this path doesn’t run for it. But if the owner assigns to the property, that assignment itself runs the setter. This is plain C# behaviour, unrelated to sync.

How you assignOwnerReceiver
Value = 1 (property)The setter runs at the point of assignmentThe setter runs on receipt
backing = 1 (field)Doesn’t run

It looks like the same code runs on both sides, but the paths differ. If the owner assigns to the field directly, the processing is skipped only on the owner’s side (Assignment on the owner’s side).

Attaching [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] gives manual sync, and attaching [UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)] gives continuous sync. Figure 1 is the manual sync case.

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
Manual sync (Manual)Continuous sync (Continuous)
When it sendsWhen the owner calls RequestSerialization()Automatic. Sends repeatedly even if the value hasn’t changed
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 resentDropped, and an error is logged
Limit per sendRoughly 280,496 bytesRoughly 200 bytes
What it suitsValues sent only on change, like a score or a stateValues that keep changing, like position or rotation

Whether OnPreSerialization() and OnPostSerialization() are called under continuous sync isn’t documented officially. Commonly they’re treated as called on every send. Continuous sync sends repeatedly, so under that assumption they’re called many times per second. Keeping them light under that assumption works either way.

The official documentation gives numbers for only the following 3. Even in the original text they’re rough figures marked “about” or “roughly,” and can change with VRChat updates.

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
Times a single network event can be called5 per second by default. Write it as [NetworkCallable(100)] to raise it up to 100

What happens when you exceed the limit differs by what you’re sending. For network events, the excess isn’t discarded — it queues on the sender. It won’t disappear, but arrival is delayed. Synced variables behave as in the table above: manual sync holds and resends, continuous sync drops it.

The 200 bytes for continuous sync is a limit on the total of all synced variables placed in that program. Adding more variables brings you closer to the limit.

A single send isn’t necessarily one send on the network. Past 1024 bytes it’s split internally, so a send you intended as one call can hit the call-count limit you set.

The following figures aren’t in the official documentation.

  • How many times per second continuous sync sends (secondary sources say “about 10 times,” but it isn’t stated whether that’s measured or estimated)
  • 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

Ownership is held by each player individually as “who is the owner right now.” When you transfer it, the caller switches over first, and it reaches other players with a delay.

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

OnOwnershipRequest() is called on both the requester and the current owner. If the return values split, a mismatch remains, so use something that looks the same from both sides for the decision, such as a synced variable. If the current owner has already left, it isn’t called, and ownership is assigned automatically.

Between the requester switching over and it reaching other players, it looks like there are two owners.

A PlayerObject is an object created one per player who joins. Its owner is fixed to that player and can’t be transferred to anyone else. But the guaranteed scope is during the execution of Start and OnDeserialization; it doesn’t mean this is always true.

Figure 4 Two players take ownership at the same time

Section titled “Figure 4 Two players take ownership at the same time”
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

The losing assignment disappears the moment the winning value is received. Both succeed on their own side, so taking ownership isn’t mutual exclusion.

  • Ownership can change due to a collision or a pickup. Nothing is called when this happens
  • When the owner leaves, ownership is assigned automatically. There’s no rule for who it’s assigned to. OnOwnershipRequest() isn’t called, and the order relative to the leave notification isn’t fixed either
  • The state in figure 4 can fail to resolve. It appears to happen more in environments with lower network latency
MisunderstandingActually
Since there’s one owner, calling SetOwner() acts as mutual exclusionIf two players call SetOwner() at the same time, both become the owner on their own side. It’s not mutual exclusion
If OnDeserialization() was called, you’re not the ownerThere are records of OnDeserialization() being called on the owner. It can’t be used to decide whether you’re the owner
A value assigned while not the owner reverts immediatelyNo error occurs. The value reverts only when the true owner next sends. If the owner never sends, that value stays as is
A player who joins partway through still receives past network eventsSynced variable values are sent, but network events are not resent
Right after joining, nothing has been called yetOnDeserialization() is called at the point of joining. If it plays sounds or animations, they all play at once the moment you join

A field with [UdonSynced] becomes the target of sync.

This is the most basic form. Attach it to an int field.

using UnityEngine;
using Tsukimi;
public class AttributesSynced : TsukimiBehaviour
{
[UdonSynced] private int value;
void Start()
{
value = 1;
RequestSerialization();
Debug.Log(value); // => 1
}
}

It can also be attached to a bool. If nothing is set, it starts as false.

using UnityEngine;
using Tsukimi;
public class SyncBool : TsukimiBehaviour
{
[UdonSynced] private bool flag;
void Start()
{
Debug.Log(flag); // => false
}
}

It can also be attached to a string. The example reads back the value set by the initializer as is.

using UnityEngine;
using Tsukimi;
public class SyncString : TsukimiBehaviour
{
[UdonSynced] private string label = "a";
void Start()
{
Debug.Log(label); // => "a"
}
}

Structs the runtime has can be synced. A struct you defined yourself cannot (see the table below).

using UnityEngine;
using Tsukimi;
public class SyncVector3 : TsukimiBehaviour
{
[UdonSynced] private Vector3 point = new Vector3(1f, 2f, 3f);
void Start()
{
Debug.Log(point.x); // => a runtime value
}
}

An enum’s contents are an integer, so it can be synced as is.

using UnityEngine;
using Tsukimi;
public class SyncEnum : TsukimiBehaviour
{
private enum Mode { Off, On }
[UdonSynced] private Mode mode;
void Start()
{
Debug.Log((int)mode); // => 0
}
}

Arrays can also be synced. But an array of arrays cannot (see the table below).

using UnityEngine;
using Tsukimi;
public class ArraysFieldSynced : TsukimiBehaviour
{
[UdonSynced] private int[] values = new int[4];
void Start()
{
values[0] = 10;
RequestSerialization();
}
}

This specifies filling in between arriving values. The types it can attach to are limited: float compiles, string doesn’t.

using UnityEngine;
using Tsukimi;
public class AttributesSyncedInterpolated : TsukimiBehaviour
{
[UdonSynced(UdonSyncMode.Linear)] private float value;
void Start()
{
Debug.Log(value); // => 0
}
}
using UnityEngine;
using Tsukimi;
public class R_sync_interp : TsukimiBehaviour
{
[UdonSynced(UdonSyncMode.Linear)] private string label = "a";
void Start() { Debug.Log(label); }
}

Like Linear, this fills in between arriving values, but the interpolation method differs.

using UnityEngine;
using Tsukimi;
public class SyncSmooth : TsukimiBehaviour
{
[UdonSynced(UdonSyncMode.Smooth)] private float amount;
void Start()
{
Debug.Log(amount); // => 0
}
}

[UdonBehaviourSyncMode] decides that program’s sync method.

This sets manual sync. It sends when RequestSerialization() is called.

using UnityEngine;
using Tsukimi;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class AttributesSyncModeManual : TsukimiBehaviour
{
[UdonSynced] private int value;
void Start()
{
value = 1;
RequestSerialization();
}
}

This sets continuous sync. It sends even without calling RequestSerialization().

using UnityEngine;
using Tsukimi;
[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]
public class AttributesSyncModeContinuous : TsukimiBehaviour
{
[UdonSynced] private int value;
void Start()
{
value = 1;
}
}

These are methods called as part of sync. The base declares them, so write them with override.

Called after a synced value arrives.

using UnityEngine;
using Tsukimi;
public class SyncOnDeserialization : TsukimiBehaviour
{
[UdonSynced] private int value;
public override void OnDeserialization()
{
Debug.Log(value);
}
void Start()
{
Debug.Log(0);
}
}
// Output
// 0

When a value arrives for this variable via sync, it doesn’t write the field directly — it goes through the specified property’s setter.

using UnityEngine;
using Tsukimi;
public class AttributesFieldChangeCallback : TsukimiBehaviour
{
[UdonSynced, FieldChangeCallback(nameof(Value))] private int backing;
public int Value
{
get { return backing; }
set { backing = value; }
}
void Start()
{
Value = 1;
Debug.Log(Value); // => 1
}
}

Assigning to the property runs the setter right away. This is plain C# behaviour, unrelated to sync. It doesn’t run when you assign to the field directly.

using UnityEngine;
using Tsukimi;
public class SyncFieldChangeCallbackOnWrite : TsukimiBehaviour
{
[UdonSynced, FieldChangeCallback(nameof(Value))] private int backing;
public int Value
{
get { return backing; }
set
{
backing = value;
Debug.Log(100 + value); // => 101
}
}
void Start()
{
Value = 1;
backing = 2;
Debug.Log(backing); // => 2
}
}

An entry point for assembling the value to send, right before it’s sent.

using UnityEngine;
using Tsukimi;
public class SyncOnPreSerialization : TsukimiBehaviour
{
[UdonSynced] private int value;
public override void OnPreSerialization()
{
value = 1;
}
void Start()
{
RequestSerialization();
Debug.Log(value); // => 0
}
}

Lets you receive whether the send succeeded.

using UnityEngine;
using Tsukimi;
using VRC.Udon.Common;
public class SyncOnPostSerialization : TsukimiBehaviour
{
public override void OnPostSerialization(SerializationResult result)
{
Debug.Log(result.success);
}
void Start()
{
Debug.Log(0);
}
}
// Output
// 0

Forms for checking the owner or transferring ownership.

Lets you check whether you’re the owner of that object.

using UnityEngine;
using Tsukimi;
using VRC.SDKBase;
public class SyncIsOwner : TsukimiBehaviour
{
[UdonSynced] private int value;
void Start()
{
if (Networking.IsOwner(gameObject))
{
value = 1;
RequestSerialization();
}
}
}

Makes the player in the first argument the owner. The example passes self (Networking.LocalPlayer).

using UnityEngine;
using Tsukimi;
using VRC.SDKBase;
public class SyncSetOwner : TsukimiBehaviour
{
void Start()
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
}

Called after ownership transfers.

using UnityEngine;
using Tsukimi;
using VRC.SDKBase;
public class SyncOnOwnershipTransferred : TsukimiBehaviour
{
public override void OnOwnershipTransferred(VRCPlayerApi player)
{
Debug.Log(0);
}
void Start()
{
Debug.Log(1);
}
}
// Output
// 1

Returning true accepts the request; returning false declines it.

using UnityEngine;
using Tsukimi;
using VRC.SDKBase;
public class SyncOnOwnershipRequest : TsukimiBehaviour
{
public override bool OnOwnershipRequest(VRCPlayerApi requester, VRCPlayerApi newOwner)
{
return true;
}
void Start()
{
Debug.Log(1); // => 1
}
}

A form that sends a method call rather than a variable’s value.

Only a method with this marker can be called over the network.

using UnityEngine;
using Tsukimi;
using VRC.SDK3.UdonNetworkCalling;
public class AttributesNetworkCallable : TsukimiBehaviour
{
[NetworkCallable]
public void Receive(int n)
{
Debug.Log(n);
}
void Start()
{
Debug.Log(1);
}
}
// Output
// 1
using UnityEngine;
using Tsukimi;
using VRC.Udon.Common.Interfaces;
public class R_sync_no_callable : TsukimiBehaviour
{
public void Receive(int n) { Debug.Log(n); }
void Start()
{
SendCustomNetworkEvent(NetworkEventTarget.All, nameof(Receive), 1);
}
}

Values listed on the sending side land in the receiving side’s arguments. Up to 8 arguments can be written.

using UnityEngine;
using Tsukimi;
using VRC.Udon.Common.Interfaces;
using VRC.SDK3.UdonNetworkCalling;
public class ArraysSendAsNetworkEvent : TsukimiBehaviour
{
private int[] scores;
void Start()
{
scores = new int[] { 1, 2, 3 };
SendCustomNetworkEvent(NetworkEventTarget.All, nameof(Receive), scores);
}
[NetworkCallable]
public void Receive(int[] values)
{
Debug.Log(values.Length);
}
}