UdonSharp ↔ CVR WASM Mapping
TL;DR: UdonSharp compiles a restricted C# subset to VRChat’s Udon bytecode. CVR WASM scripting compiles a broader C# surface to WebAssembly via NativeAOT and runs it through Wasmtime. Both subclass a base behaviour; both expose a Player-like API, player/prop/world events, and networked messaging. They diverge sharply on state synchronization (Udon auto-syncs fields; WASM requires explicit messages) and supported language features (WASM handles generics, LINQ, inheritance, try/catch, and binary serialization; Udon does not). This page is a porting reference for creators coming from VRChat.
Reference sources:
- UdonSharp: https://github.com/vrchat-community/UdonSharp
- CVR CCK stubs:
CVR.CCK.Wasm/Scripting/Links/APIs/CCKStubs/— ships with the CCK Wasm package; the source of truth for the CVR side.
High-level architecture
Section titled “High-level architecture”| UdonSharp | CVR WASM | |
|---|---|---|
| Base class | UdonSharpBehaviour : MonoBehaviour | WasmBehaviour : MonoBehaviour (must be partial) |
| Runtime | Udon VM (custom, stack-based) | Wasmtime 34 with epoch preemption |
| Language subset | C# subset; no generics, no delegates (mostly), no LINQ, no try/catch | Full C# via NativeAOT (generics, LINQ, try/catch, async on single thread) |
| Compile output | Udon bytecode assembly (.uasset) | Assets/WasmModule.wasm |
| Per-object script count | One UdonSharpBehaviour per component | One VM per content root (avatar/prop/world), many behaviours share it |
| Reflection | Emulated via compiler-generated stubs | Disabled by default (<IlcDisableReflection>true</IlcDisableReflection>); opt-in per project |
| Threading | Single-thread | Single-thread (WASM threads disabled) |
Related on the CVR side: Architecture · Runtime Lifecycle · Authoring.
Base class mapping
Section titled “Base class mapping”// UdonSharp (VRChat)using UdonSharp;using UnityEngine;using VRC.SDKBase;using VRC.Udon;
public class Foo : UdonSharpBehaviour{ public void Start() { } public void Update() { }}// CVR WASM (equivalent shape)using UnityEngine;using WasmScripting;
public partial class Foo : WasmBehaviour{ public void Start() { } public void Update() { }}Key difference: the partial keyword is required on WasmBehaviour subclasses (the source generator emits a companion partial for serialization plumbing). See Authoring.
Concepts
Section titled “Concepts”| Concept | UdonSharp | CVR WASM |
|---|---|---|
| Local player | Networking.LocalPlayer → VRCPlayerApi | CVR.LocalPlayer.PlayerObject → CVR.Player (world-only) |
| Remote players | VRCPlayerApi.GetPlayers(array) | CVR.Player.GetAllPlayers() / GetRemotePlayers() |
| Player display name | player.displayName | player.GetUsername() |
| Persistent user ID | player.userId (deprecated in public; not stable across sessions) | player.GetUserId() — requires AccessUserIdentity world permission |
| Network ID | player.playerId (int) | player.GetNetworkId() (short) |
| World / scene | VRC.SDKBase.Utilities.IsValid(...) | Scene is always “Self” scope from a world script; world getters on CVR.World |
| Is owner? | Networking.IsOwner(gameObject) or Networking.GetOwner(gameObject) == Networking.LocalPlayer | No per-GameObject ownership. Use Networking.GetInstanceOwner() == LocalPlayer.PlayerObject for instance-level authority, or WasmUtils.GetOwnerContext() == CVRScriptOwnerContext.Self for avatar/prop-level |
| Transfer ownership | Networking.SetOwner(player, gameObject) | No first-class equivalent. Design around instance-owner authority + OnInstanceOwnerChange. |
| Log | Debug.Log(...) | Debug.Log(...) (bound via DebugLinksManual) |
Related: CVR Host Functions → Player · Permissions.
Event callbacks
Section titled “Event callbacks”| UdonSharp event | CVR WASM equivalent | Notes |
|---|---|---|
Start, Update, LateUpdate, FixedUpdate | Same names | WASM also exposes PostUpdate, PostLateUpdate, PostFixedUpdate for ordering after Unity + CVR systems (see Events → Execution order) |
OnEnable, OnDisable, OnDestroy | Same names | |
OnPlayerJoined(VRCPlayerApi) | OnPlayerJoined(CVR.Player) | Parameter type differs |
OnPlayerLeft(VRCPlayerApi) | OnPlayerLeft(CVR.Player) | |
OnPlayerRespawn(VRCPlayerApi) | OnPlayerRespawned(CVR.Player) | Note the -ed suffix |
OnPlayerTriggerEnter(VRCPlayerApi) | OnPlayerTriggerEnter(CVR.Player) | Single-arg in CVR — does not tell you which trigger; use Unity’s OnTriggerEnter(Collider) for per-trigger dispatch. See Events → Player events. |
OnTriggerEnter(Collider) | Same | Unity physics event, identical semantics |
OnCollisionEnter(Collision) | Same | |
OnPickup, OnDrop (VRCPickup) | No direct equivalent | CVR interaction is wired via CVRPointer / CVRInteractable + persistent UnityEvents |
OnPreSerialization, OnDeserialization | No equivalent | Udon has auto-sync; CVR uses explicit Networking.SendMessage / OnReceiveMessage |
OnOwnershipTransferred(VRCPlayerApi) | OnInstanceOwnerChange(CVR.Player) | CVR tracks instance-level ownership, not per-GameObject |
OnInputJump(bool, UdonInputEventArgs) | CVRInput — read CVRInput.GetButtonDown(CVRButton.Jump) in Update, or subscribe to OnInputReady | |
OnAvatarEyeHeightChanged | No direct equivalent | Read LocalPlayer.GetCurrentHeight() in a world script and diff it yourself |
OnStationEntered(VRCPlayerApi) | No direct equivalent | CVR uses chair / seat components, not a built-in station API |
VRCPlayerApi.IsUserInVR() | No direct binding; use LocalPlayer.GetPlaySpaceOffset() as a heuristic — it’s Vector3.zero unconditionally in desktop and a non-zero offset once a VR user moves in their playspace. Source: PlayerSetup.GetPlayerPlaySpaceOffset returns Vector3.zero when isUsingVr == false. Edge case: a stationary VR user at their exact tracking centre also reads zero. |
Full CVR event catalog: Events.
Networking
Section titled “Networking”This is the biggest divergence.
Networked state — Udon’s approach
Section titled “Networked state — Udon’s approach”// UdonSharpusing UdonSharp;using UnityEngine;using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class Counter : UdonSharpBehaviour{ [UdonSynced(UdonSyncMode.None)] public int count;
public override void OnDeserialization() { UpdateUi(); }
public void Increment() { if (!Networking.IsOwner(gameObject)) Networking.SetOwner(Networking.LocalPlayer, gameObject); count++; RequestSerialization(); }}Udon handles the wire format, serialization schedule, and late-joiner sync for you.
Networked state — CVR’s approach
Section titled “Networked state — CVR’s approach”// CVR WASM — explicit messagingusing System;using UnityEngine;using WasmScripting;using CVR;
public partial class Counter : WasmBehaviour{ [WasmSerialized] private int count; private const byte MSG_COUNT = 1;
void Start() { Networking.OnReceiveMessage += OnNet; } void OnDestroy() { Networking.OnReceiveMessage -= OnNet; }
public void OnClicked() { Player owner = Networking.GetInstanceOwner(); Player me = LocalPlayer.PlayerObject; if (owner != me) return; // instance-owner authority count++; Broadcast(); }
private void Broadcast() { var w = new BufferReaderWriter(8); w.Write(MSG_COUNT); w.Write(count); Networking.SendMessage(w.Buffer.Slice(0, w.Length), null, SendType.Reliable); }
private void OnNet(Player sender, Span<byte> msg) { var r = new BufferReaderWriter(msg); r.Read(out byte tag); if (tag != MSG_COUNT) return; if (sender != Networking.GetInstanceOwner()) return; r.Read(out count); }}You design the protocol, you choose the SendType, you verify sender authority. The CVR team considers this a feature — predictable bandwidth, no surprise churn — but it’s more boilerplate. See Networked Counter example, BufferReaderWriter, Networking.
Ownership model
Section titled “Ownership model”| UdonSharp | CVR WASM | |
|---|---|---|
| Granularity | Per-GameObject | Per-instance (one “instance owner” for the whole session) |
| Take ownership | Networking.SetOwner(player, gameObject) | Can’t; the instance owner is assigned by CVR’s matchmaker |
| Release ownership | Implicit on disconnect | Implicit on disconnect; new owner is chosen by CVR |
| Query | Networking.GetOwner(gameObject) / IsOwner(gameObject) | Networking.GetInstanceOwner() |
| Handoff event | OnOwnershipTransferred(VRCPlayerApi newOwner) | OnInstanceOwnerChange(Player newOwner) |
For avatar-scoped scripts, “Self” owner context means you’re the wearer; the equivalent question “is this my prop?” maps to WasmUtils.GetOwnerContext() == CVRScriptOwnerContext.Self. See Permissions.
Custom events across clients
Section titled “Custom events across clients”// UdonSharpSendCustomNetworkEvent(NetworkEventTarget.All, "MyEvent");SendCustomNetworkEvent(NetworkEventTarget.Owner, "MyOwnerOnlyEvent");// CVR WASM — wrap the event name yourselfvar w = new BufferReaderWriter(32);w.Write((byte)MSG_CUSTOM_EVENT);w.Write("MyEvent");Networking.SendMessage(w.Buffer.Slice(0, w.Length), target: null, // null = broadcast to all others sendType: SendType.Reliable);To target only the owner, resolve their short network ID and pass new short[] { owner.GetNetworkId() } as target.
| UdonSharp | CVR WASM |
|---|---|
Input.GetKey(KeyCode.Space) (in U# runs, though VRC restricts) | CVR.CVRInput.GetButtonDown(CVRButton.Jump) / .WasPressedThisFrame(...) |
VRC input events (OnInputJump, OnInputMoveHorizontal, …) | Read CVRInput.Movement, CVRInput.Look, CVRInput.InteractRight, etc. from Update |
Input set via UdonInputEventArgs.floatValue | Write with CVRInput.SetMovement(...) — only materializes from world scripts or avatar-on-wearer |
See CVR Input for the double-buffered memory model and the write-gating rules.
Storage
Section titled “Storage”| UdonSharp | CVR WASM | |
|---|---|---|
| Persistent world save | None built-in; abuse URL/VRCString via external services | WasmScripting.FileStorage — world-only, per-world quota, encrypted on disk |
| Per-player persistence | None built-in | Same as above; partition by Player.GetUserId() inside your save file |
| In-session state | [UdonSynced] fields | [WasmSerialized] on private fields + Networking.SendMessage for cross-client sync |
CVR’s storage is a real advantage for single-world persistence. See File Storage and the Idle / RPG examples.
HTTP / external I/O
Section titled “HTTP / external I/O”| UdonSharp | CVR WASM | |
|---|---|---|
| Web requests | VRCImageDownloader / VRCStringDownloader — limited, domain-gated | HTTP is gated by the HttpApiAllowed + HttpAllowedDomains world permissions. See World Permissions. |
| Load textures from URL | VRCImageDownloader.DownloadImage | Go through the permissioned HTTP API then assign to Texture2D |
| Raw sockets | Not possible | Not possible |
Language features
Section titled “Language features”| Feature | UdonSharp | CVR WASM |
|---|---|---|
Generics (List<T>, Dictionary<K,V>) | Partial — built-ins only, no custom generics | Fully supported (via NativeAOT) |
| LINQ | Not supported | Fully supported |
try / catch / finally | Limited / disabled | Fully supported; exceptions cross the WASM boundary back to the host |
async / await | Not supported | Supported at C# level but single-threaded (continuations on same invocation) |
| Custom classes (non-behaviour) | Via [System.Serializable] with specific rules | Via [WasmSerialized] on the class; fields serialize per normal rules |
| Interfaces | Not supported | Supported |
| Inheritance | Only direct UdonSharpBehaviour subclass (no intermediate abstract types) | Arbitrary inheritance chain, including abstract intermediates |
struct | Partial | Fully supported incl. readonly structs and record structs |
Delegates / Action / Func | Not supported | Supported |
| Unsafe / pointers | Not supported | Not supported (blocked by WASM — no raw pointers anyway) |
| Reflection | Emulated for GetProgramVariable / SendCustomEvent only | Disabled by default (IlcDisableReflection=true); opt-in per project |
Span<T> / ReadOnlySpan<T> | Not supported | Fully supported; used throughout the sandbox |
Practical rule: if you hit a limitation in UdonSharp, chances are the direct C# version works in WASM. The opposite is rarer.
Attributes
Section titled “Attributes”| UdonSharp attribute | CVR WASM equivalent |
|---|---|
[UdonSynced] / [UdonSynced(UdonSyncMode.Linear)] / [UdonSynced(UdonSyncMode.Smooth)] | No equivalent — explicit networking |
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] | N/A |
[FieldChangeCallback("PropertyName")] | Implement yourself: wrap the field in a property with setter side-effects |
[RecursiveMethod] | Not needed — WASM supports ordinary recursion |
[SerializeField] | Same — works as in Unity |
[HideInInspector], [Header], [Tooltip], [Range], [Space] | Supported but stripped from the WASM module unless UNITY_EDITOR_ATTRIBUTES is added to project defines. See Available Attributes. |
[ExternallyVisible] | Required on any method the host calls by name — UnityEvent persistent listeners (Button.onClick etc.), CVRInteractable, animation events. Omitting it silently no-ops the call. Lifecycle events (Start, Update, …) and game events (OnPlayerJoined, OnInstanceOwnerChange, …) are picked up by enum-name scans and don’t need it. See Unity Events Rewiring. |
Common porting patterns
Section titled “Common porting patterns”Manual sync counter
Section titled “Manual sync counter”Udon:
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public class Counter : UdonSharpBehaviour{ [UdonSynced] public int count; public override void OnDeserialization() => display.text = $"{count}"; public void Bump() { if (!Networking.IsOwner(gameObject)) Networking.SetOwner(Networking.LocalPlayer, gameObject); count++; RequestSerialization(); }}CVR (owner-authoritative):
public partial class Counter : WasmBehaviour{ [WasmSerialized] private int count; public TMPro.TMP_Text display; private const byte MSG = 1;
void Start() { Networking.OnReceiveMessage += OnNet; Refresh(); } void OnDestroy() { Networking.OnReceiveMessage -= OnNet; }
public void Bump() { if (Networking.GetInstanceOwner() != LocalPlayer.PlayerObject) return; count++; Broadcast(); Refresh(); }
void OnNet(System.Player sender, System.Span<byte> msg) { /* verify + apply */ } void Broadcast() { /* pack + Networking.SendMessage */ } void Refresh() { display.text = $"{count}"; }}Compare side-by-side: Networked Counter example.
Player-list scoreboard
Section titled “Player-list scoreboard”Udon:
VRCPlayerApi[] players = new VRCPlayerApi[80];int n = VRCPlayerApi.GetPlayers(players).Length;for (int i = 0; i < n; i++) Debug.Log(players[i].displayName);CVR:
Player[] players = Player.GetAllPlayers();if (players != null) for (int i = 0; i < players.Length; i++) Debug.Log(players[i].GetUsername());Note: Player.GetAllPlayers() is world-only. Use it from a world script; from an avatar script rely on game events (OnPlayerJoined / OnPlayerLeft) to maintain your own list.
Station / chair
Section titled “Station / chair”Udon has built-in stations (VRCStation). CVR does not. You use CVR’s native chair/seat components (CCK CVRSeat) wired to WasmBehaviour via UnityEvents. No code inside your WASM script manages the sit itself — you just listen for the UnityEvent.
Things that don’t port cleanly
Section titled “Things that don’t port cleanly”- Udon’s sync smoothing —
[UdonSynced(UdonSyncMode.Linear)]andSmoothinterpolate synced floats. No CVR equivalent; interpolate yourself using local timing + received state. - VRChat’s interactable layer (
OnInteract,OnPickup,OnPickupUseDown) — CVR usesCVRPointer/CVRInteractableCCK components wired through persistent UnityEvents. - Stations — no CVR built-in. Use CCK seat components.
- VRChat’s quest-compat concept — WASM scripts ship identically across platforms; no per-platform variants.
- URL whitelist loading — CVR’s HTTP path uses world permissions + domain whitelist; the shape is different enough that you’ll rewrite the download layer.
OnPlayerHeldItemPickupDrop— no equivalent.
Things CVR has that Udon doesn’t
Section titled “Things CVR has that Udon doesn’t”FileStoragefor per-world persistent save data.- Broad C# language surface (generics, LINQ, async, full try/catch).
- Fine-grained events like
PostUpdate/PostLateUpdate/PostFixedUpdatethat run after all Unity + CVR systems. BufferReaderWriter— zero-allocation binary packing for network / disk.WorldPermissionsAPI — user-approved capability tier (identity / HTTP / storage quota).CVRInputwith write access (world + avatar-on-wearer) — drive the local player’s input programmatically.- One VM per content root (not per component) — lets a world have hundreds of coordinated behaviours sharing state without VM-per-component overhead.
- Source-generator-driven serialization — tighter than Udon’s reflection-based sync.
Is a “translation layer” feasible?
Section titled “Is a “translation layer” feasible?”A literal UdonSharp → WASM code translator is not practical. The runtimes differ in fundamental ways:
- Udon’s per-GameObject ownership has no clean WASM equivalent; the “right” answer depends on whether you want instance-owner authority, spawner authority, or distributed peer-to-peer.
[UdonSynced]needs a design choice for each field (broadcast cadence, delta vs. snapshot, late-joiner handling) that the translator can’t guess.- Udon’s disabled features (generics, LINQ, try/catch) mean Udon code doesn’t exercise them; going the other direction risks breakage.
What is practical:
- This mapping page — a porting guide.
- Side-by-side examples — the examples demonstrate patterns you’d recognize from Udon (click counter, turn-based, networked state, file storage).
- Validator coverage — the script validator flags the most common UdonSharp-to-WASM drift: property access (
player.displayNamevsplayer.GetUsername()), ownership calls, unbound types.
Related
Section titled “Related”- Architecture — how the WASM runtime is put together.
- Permissions — three-axis access control.
- Events — full event catalog with signatures.
- Networking — sending and receiving messages.
- Examples — ready-to-test patterns for common gameplay needs.
- Not Exposed — what the sandbox denies on the CVR side.