UdonSharp Skill Why This Skill Matters UdonSharp looks like regular Unity C# scripting — until you hit its hidden walls. Many standard C# features ( , , , LINQ, generics) silently fail or refuse to compile in Udon. Networking is even more treacherous: modifying a synced variable without ownership produces no error — it just does nothing. Forgetting means your state changes never leave your machine. Standard single-player local testing gives zero signal about these networking bugs because there is only one player. Every rule in this skill exists because UdonSharp's default behavior is to fail…

\\\n | sed 's/[[:space:]]*($//' \\\n | sort | uniq -d)\nif [[ -n \"$overloaded\" ]]; then\n warnings+=(\"[UdonSharp] WARNING: Method overloading detected for: $(echo \"$overloaded\" | tr '\\n' ' '). Only simple overloads may work; prefer unique method names.\")\nfi\n\n# Output warnings\nif [[ ${#warnings[@]} -gt 0 ]]; then\n echo \"\" >&2\n echo \"=== UdonSharp Validation Warnings ===\" >&2\n for warning in \"${warnings[@]}\"; do\n echo \"$warning\" >&2\n done\n echo \"===================================\" >&2\n echo \"\" >&2\nfi\n\n# Always output original input to allow the edit to proceed\necho \"$input\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":7970,"content_sha256":"5cc2006eb6ce958ae9a2796fde60ceb31eac0795b8857d0582bbf6d26f5dab04"},{"filename":"references/api.md","content":"# VRChat API Reference (UdonSharp)\n\nComplete reference of VRChat-specific classes, methods, and types available in UdonSharp.\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\n## VRCPlayerApi\n\nPlayer information and actions. Obtained from `Networking.LocalPlayer` or event parameters.\n\n### Properties\n\n| Property | Type | Description |\n|-----------|------|------|\n| `displayName` | `string` | Player's display name |\n| `playerId` | `int` | Unique player ID within this instance |\n| `isLocal` | `bool` | True if this is the local player |\n| `isMaster` | `bool` | True if this is the instance master |\n| `isInstanceOwner` | `bool` | True if this is the instance owner |\n| `isUserInVR` | `bool` | True if using VR |\n| `isSuspended` | `bool` | True if suspended (tabbed out) |\n| `isGrounded` | `bool` | True if grounded on the floor |\n| `isVRCPlus` | `bool` | True if the player has an active VRC+ subscription (requires SDK 3.10.3+) |\n\n#### `isVRCPlus` — Timing and Per-Client Evaluation (SDK 3.10.3+)\n\n`isVRCPlus` is evaluated **per-client, on the local machine**. Each client reads its own value from VRChat account data — the value for a remote player is whatever that player's client has reported to the network.\n\nTwo properties of that model drive correct usage:\n\n- **Timing**: Reading `isVRCPlus` inside `OnPlayerJoined` is not guaranteed to return an authoritative value. `OnPlayerJoined` fires during the network handshake before the player's profile and persistence data have settled; the subscription state may still be unset when the event fires. Gate reads behind `OnPlayerRestored` (or a local `_playerReady` flag set there) — `OnPlayerRestored` fires after persistence data has been loaded, which is the earliest point where account-tied properties are reliable.\n- **Anti-sync**: Do **NOT** store `isVRCPlus` in an `[UdonSynced]` variable and broadcast it. Each client must read `player.isVRCPlus` on its own against the `VRCPlayerApi` it holds. Syncing a single master-evaluated value will misreport the state for every other player and is a correctness bug, not a bandwidth optimisation. (See NEVER #18 for the general form of this anti-pattern; `isVRCPlus` is a new axis — \"per-client evaluation\" rather than \"component reference.\")\n\nFor a worked example of reading the property and enabling a local `GameObject` based on it, see `patterns-core.md` (VRC+ Detection — Reading `isVRCPlus`).\n\n### Movement Methods\n\n```csharp\n// Teleport player\nplayer.TeleportTo(Vector3 position, Quaternion rotation);\nplayer.TeleportTo(Vector3 position, Quaternion rotation, VRC_SceneDescriptor.SpawnOrientation orientation);\n\n// Get/Set velocity\nVector3 velocity = player.GetVelocity();\nplayer.SetVelocity(Vector3 velocity);\n\n// Movement settings\nplayer.SetWalkSpeed(float speed); // Default: 2.0\nplayer.SetRunSpeed(float speed); // Default: 4.0\nplayer.SetStrafeSpeed(float speed); // Default: 2.0\nplayer.SetJumpImpulse(float impulse); // Default: 3.0\nplayer.SetGravityStrength(float strength); // Default: 1.0\n\n// Immobilize player (prevent movement)\nplayer.Immobilize(bool immobile);\n```\n\n### Position Methods\n\n```csharp\n// Get positions\nVector3 position = player.GetPosition();\nQuaternion rotation = player.GetRotation();\n\n// Get bone position (for avatar bones)\nVector3 bonePos = player.GetBonePosition(HumanBodyBones bone);\nQuaternion boneRot = player.GetBoneRotation(HumanBodyBones bone);\n\n// Tracking data (VR headset, controllers)\nVRCPlayerApi.TrackingData trackingData = player.GetTrackingData(TrackingDataType type);\n// trackingData.position, trackingData.rotation\n```\n\n### Voice and Audio\n\n```csharp\n// Voice settings — how the local (listening) player hears the target player\nplayer.SetVoiceGain(float gain); // 0-24 dB, default 15\nplayer.SetVoiceDistanceNear(float distance); // Default: 0\nplayer.SetVoiceDistanceFar(float distance); // Default: 25\nplayer.SetVoiceVolumetricRadius(float radius); // Default: 0\nplayer.SetVoiceLowpass(bool enabled); // Default: true\n\n// Getters for the same settings (release-noted in SDK 3.6.1; see Note)\nfloat gain = player.GetVoiceGain();\nfloat near = player.GetVoiceDistanceNear();\nfloat far = player.GetVoiceDistanceFar();\nfloat radius = player.GetVoiceVolumetricRadius();\nbool lowpass = player.GetVoiceLowpass();\n```\n\n> **Note**: The voice getters are named in the [SDK 3.6.1 release notes](https://creators.vrchat.com/releases/release-3-6-1/) — added to `VRCPlayerApi` and exposed to Udon — but do not appear on the current [Player Audio](https://creators.vrchat.com/worlds/udon/players/player-audio/), Players, or UdonSharp API reference pages. Each is parameterless and returns the value type of its matching setter (4 `float`, 1 `bool`). This entry records that they exist and their signatures; it does not specify runtime read semantics. What a getter returns before its setter has run, whether it reflects the last value set versus the live effective value, and whether local- and remote-player reads behave identically, are not verified here — confirm against your SDK/client before relying on a getter's return value (for example, before using one in place of your own tracked state). Re-check after the Voice Audio Rework (SDK 3.10.4) lands.\n\n### Avatar Methods\n\n```csharp\n// Avatar audio parameters\nplayer.SetAvatarAudioGain(float gain);\nplayer.SetAvatarAudioNearRadius(float radius);\nplayer.SetAvatarAudioFarRadius(float radius);\nplayer.SetAvatarAudioVolumetricRadius(float radius);\nplayer.SetAvatarAudioForceSpatial(bool force);\n```\n\n### Avatar Scaling Methods\n\n```csharp\n// Get current avatar eye height\nfloat eyeHeight = player.GetAvatarEyeHeightAsMeters();\n\n// Set avatar eye height limits (local player only)\n// Minimum: >= 0.2 meters, Maximum: \u003c= 5.0 meters\nplayer.SetAvatarEyeHeightMinimumByMeters(0.5f);\nplayer.SetAvatarEyeHeightMaximumByMeters(3.0f);\n\n// Get current limits\nfloat minHeight = player.GetAvatarEyeHeightMinimumAsMeters();\nfloat maxHeight = player.GetAvatarEyeHeightMaximumAsMeters();\n\n// Set avatar eye height directly (within limits)\nplayer.SetAvatarEyeHeightByMeters(1.6f);\n\n// Set avatar eye height by multiplier (relative to avatar's default)\nplayer.SetAvatarEyeHeightByMultiplier(1.5f); // 1.5x default height\n```\n\n### Pickup Methods\n\n```csharp\n// Get pickup in hand\nVRC_Pickup pickup = player.GetPickupInHand(VRC_Pickup.PickupHand hand);\n\n// Play haptic feedback\nplayer.PlayHapticEventInHand(VRC_Pickup.PickupHand hand, float duration, float amplitude, float frequency);\n```\n\n### Player Tags\n\n```csharp\n// Tags (local only, not synced)\nplayer.SetPlayerTag(string tagName, string tagValue);\nstring value = player.GetPlayerTag(string tagName);\nplayer.ClearPlayerTags();\n```\n\n### PlayerObject Methods\n\n```csharp\n// Find a component in the player's PlayerObject instance\n// The reference parameter identifies which component type to find\nTransform matched = (Transform)player.FindComponentInPlayerObjects(referenceTransform);\n\n// Always validate before use\nif (Utilities.IsValid(matched))\n{\n Debug.Log($\"Found transform: {matched.name}\");\n}\n```\n\n`FindComponentInPlayerObjects` searches the PlayerObject hierarchy belonging to `player` for a component that matches the type and identity of the reference. The return value must be cast to the target component type. Always check with `Utilities.IsValid()` before using the result, as the player's PlayerObject may not be loaded yet.\n\n### Validity Check\n\n```csharp\n// Always check before using player reference\nif (player != null && player.IsValid())\n{\n // Safe to use player\n}\n```\n\n## Networking Class\n\nStatic methods for network operations.\n\n### Core Methods\n\n```csharp\n// Get local player\nVRCPlayerApi localPlayer = Networking.LocalPlayer;\n\n// Check instance master\nbool isMaster = Networking.IsMaster;\n\n// Get server time (synced across all players)\ndouble serverTime = Networking.GetServerTimeInSeconds();\n\n// Check network congestion\nbool clogged = Networking.IsClogged;\n\n// Simulate latency (for testing, editor only)\nNetworking.SimulateNetworkLatency(float latency);\n```\n\n### Ownership\n\n```csharp\n// Check ownership\nbool isOwner = Networking.IsOwner(VRCPlayerApi player, GameObject obj);\nbool isOwner = Networking.IsOwner(GameObject obj); // Checks local player\n\n// Get owner\nVRCPlayerApi owner = Networking.GetOwner(GameObject obj);\n\n// Transfer ownership\nNetworking.SetOwner(VRCPlayerApi player, GameObject obj);\n```\n\n### Player Enumeration\n\n```csharp\n// Get player count\nint count = VRCPlayerApi.GetPlayerCount();\n\n// Get all players\nVRCPlayerApi[] players = new VRCPlayerApi[VRCPlayerApi.GetPlayerCount()];\nVRCPlayerApi.GetPlayers(players);\n\n// Get player by ID\nVRCPlayerApi player = VRCPlayerApi.GetPlayerById(int playerId);\n```\n\n## NetworkCalling Class (SDK 3.8.1+)\n\nMonitoring and management of the network event queue.\n\n```csharp\nusing VRC.SDK3.UdonNetworkCalling;\n\n// Get queued events for a specific method on this behaviour\nint queuedCount = NetworkCalling.GetQueuedEvents(\n (IUdonEventReceiver)this,\n nameof(MyNetworkMethod)\n);\n\n// Get total queued events across entire world\nint totalQueued = NetworkCalling.GetAllQueuedEvents();\n\n// Check if network is congested (also available via Networking.IsClogged)\nbool isClogged = Networking.IsClogged;\n```\n\n### Usage Example: Rate Limit Monitoring\n\n```csharp\nusing TMPro;\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.UdonNetworkCalling;\nusing VRC.SDKBase;\nusing VRC.Udon.Common.Interfaces;\n\npublic class NetworkMonitor : UdonSharpBehaviour\n{\n [SerializeField] private TextMeshProUGUI statusText;\n\n void Update()\n {\n int myEventQueue = NetworkCalling.GetQueuedEvents(\n (IUdonEventReceiver)this,\n nameof(OnNetworkEvent)\n );\n int totalQueue = NetworkCalling.GetAllQueuedEvents();\n\n statusText.text = $\"My Queue: {myEventQueue}\\n\" +\n $\"Total Queue: {totalQueue}\\n\" +\n $\"Clogged: {Networking.IsClogged}\";\n }\n\n public void SendEvent()\n {\n // Check before sending to avoid queue buildup\n if (NetworkCalling.GetQueuedEvents((IUdonEventReceiver)this, nameof(OnNetworkEvent)) \u003c 10)\n {\n SendCustomNetworkEvent(NetworkEventTarget.All, nameof(OnNetworkEvent));\n }\n }\n\n [NetworkCallable]\n public void OnNetworkEvent()\n {\n Debug.Log(\"Network event received!\");\n }\n}\n```\n\n## VRC_Pickup\n\nPickup component for holdable objects.\n\n### Properties\n\n| Property | Type | Description |\n|-----------|------|------|\n| `currentPlayer` | `VRCPlayerApi` | Player currently holding (null when not held) |\n| `IsHeld` | `bool` | True if currently held |\n| `currentHand` | `PickupHand` | Which hand is holding it |\n| `pickupable` | `bool` | Whether it can be picked up |\n| `DisallowTheft` | `bool` | Prevent theft by other players |\n\n### Methods\n\n```csharp\nVRC_Pickup pickup = (VRC_Pickup)GetComponent(typeof(VRC_Pickup));\n\n// Force drop\npickup.Drop();\npickup.Drop(VRCPlayerApi player); // Drop from specific player\n\n// Generate haptic\npickup.GenerateHapticEvent(float duration, float amplitude, float frequency);\n```\n\n## VRCStation\n\nSeat/station component.\n\n### Properties\n\n| Property | Type | Description |\n|-----------|------|------|\n| `seated` | `bool` | Whether someone is seated |\n| `Occupant` | `VRCPlayerApi` | Current occupant (null when empty) |\n\n### Methods\n\n```csharp\nVRCStation station = (VRCStation)GetComponent(typeof(VRCStation));\n\n// Use station\nstation.UseStation(VRCPlayerApi player);\nstation.ExitStation(VRCPlayerApi player);\n```\n\n## VRCObjectPool\n\nObject pooling for network-aware objects. The pool manages and synchronizes the active state of each held object across all players.\n\n**Class**: `VRC.SDK3.Components.VRCObjectPool`\n\n### Properties\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `Pool` | `GameObject[]` | The objects managed by this pool |\n\n### Methods\n\n| Method | Signature | Owner-only | Network-synchronized |\n|---|---|---|---|\n| `TryToSpawn()` | `GameObject TryToSpawn()` | Yes | Yes |\n| `Return(obj)` | `void Return(GameObject obj)` | Yes | Yes |\n| `Shuffle()` | `void Shuffle()` | Yes | Yes |\n\n`TryToSpawn()` and `Return()` are publicly documented on creators.vrchat.com; `Shuffle()` is observed in the SDK 3.10.3 Udon wrapper symbols (`__Shuffle__SystemVoid` in `VRC.Udon.VRCWrapperModules.dll`) but absent from public API docs at the time of writing — its owner-only / synced behavior follows the same runtime contract as the other two methods. All three methods silently no-op when called by a non-owner at runtime; for patterns that require any client to trigger pool operations, see [Usage Pattern: Interact-Driven (User-Triggered)](#usage-pattern-interact-driven-user-triggered).\n\n```csharp\npublic VRCObjectPool pool;\n\n// Activate an unused object; returns null if all objects are in use.\nGameObject spawned = pool.TryToSpawn();\n\n// Return an object to the pool (deactivates it).\npool.Return(spawned);\n\n// Shuffle the internal order of available objects.\npool.Shuffle();\n```\n\n### Ownership Behavior\n\nThe VRCObjectPool itself is a networked object; only its **owner** can call `TryToSpawn()`, `Return()`, or `Shuffle()`. Non-owner calls silently no-op at runtime (see Methods table above). To support callers from any client (e.g. `Interact()` handlers), see [Usage Pattern: Interact-Driven (User-Triggered)](#usage-pattern-interact-driven-user-triggered).\n\n- **`TryToSpawn()`** activates an available pooled object and returns it. The ownership of the activated object is **not** automatically transferred to any specific player — call `Networking.SetOwner()` explicitly after spawning if you need the spawned object to be owned by a particular player.\n- **`Return()`** deactivates the object and returns it to the pool. Only the pool owner can call this method.\n- **`Shuffle()`** randomizes the internal order of available pooled objects, synchronized across all clients. Only the pool owner can call this method.\n\n### Network Synchronization\n\nThe pool synchronizes the active/inactive state of every held object across all players. Late joiners automatically receive the correct active or inactive state for each pooled object.\n\n### Pooled Object Contract\n\nWhen writing an UdonSharp behaviour that is intended to run on pooled objects, it should follow this convention so the pool can set ownership correctly:\n\n```csharp\nusing UdonSharp;\nusing VRC.SDKBase;\n\npublic class PooledObject : UdonSharpBehaviour\n{\n // Set by the pool manager after TryToSpawn(); null when unassigned\n public VRCPlayerApi Owner;\n\n // Called on all clients when the object is assigned to a new owner\n public void OnOwnerSet()\n {\n // React to ownership assignment here\n if (Utilities.IsValid(Owner))\n {\n Debug.Log($\"Object assigned to: {Owner.displayName}\");\n }\n }\n\n void OnEnable()\n {\n // OnEnable fires before Start() when the pool activates this object.\n // Use this instead of the deprecated OnSpawn event.\n }\n\n void OnDisable()\n {\n // Fired when Return() deactivates this object.\n Owner = null;\n }\n}\n```\n\n> **Note**: `OnSpawn` is **deprecated**. Use `OnEnable` to react to an object being activated by the pool.\n\n### Usage Pattern: Master-Managed Pool\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Components;\nusing VRC.SDKBase;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class PoolManager : UdonSharpBehaviour\n{\n public VRCObjectPool objectPool;\n\n public override void OnPlayerJoined(VRCPlayerApi player)\n {\n // Only the pool owner (e.g. master) calls TryToSpawn\n if (!Networking.IsOwner(objectPool.gameObject)) return;\n\n GameObject spawned = objectPool.TryToSpawn();\n if (spawned == null)\n {\n Debug.LogWarning(\"No objects available in pool\");\n return;\n }\n\n // Transfer ownership of the spawned object to the joining player\n Networking.SetOwner(player, spawned);\n\n PooledObject pooledBehaviour = (PooledObject)spawned.GetComponent(typeof(PooledObject));\n if (Utilities.IsValid(pooledBehaviour))\n {\n pooledBehaviour.Owner = player;\n pooledBehaviour.SendCustomNetworkEvent(NetworkEventTarget.All, nameof(PooledObject.OnOwnerSet));\n }\n }\n\n public override void OnPlayerLeft(VRCPlayerApi player)\n {\n if (!Networking.IsOwner(objectPool.gameObject)) return;\n\n // Find and return the object assigned to the leaving player\n foreach (GameObject obj in objectPool.Pool)\n {\n if (!obj.activeInHierarchy) continue;\n\n PooledObject pooledBehaviour = (PooledObject)obj.GetComponent(typeof(PooledObject));\n if (Utilities.IsValid(pooledBehaviour) && pooledBehaviour.Owner == player)\n {\n objectPool.Return(obj);\n break;\n }\n }\n }\n}\n```\n\n### Usage Pattern: Interact-Driven (User-Triggered)\n\nThe Master-Managed pattern above protects its pool calls with an `IsOwner` guard inside `OnPlayerJoined`. `OnPlayerJoined` runs on every client when a new player joins; the guard ensures only the pool owner's client actually executes `TryToSpawn()` / `Return()` — non-owner clients early-return safely without hitting a silent no-op at the pool method itself. When the trigger is `Interact()`, the handler body runs only on the interacting player's local client. Writing `pool.TryToSpawn()` directly there works only when the interacting player happens to own the pool — for everyone else the call reaches the pool method and silently no-ops with no exception. There are two correct resolutions, presented as a cost tier below.\n\n| Tier | Approach | Cost | When |\n|---|---|---|---|\n| 1 | Forward to current pool owner via `SendCustomNetworkEvent(NetworkEventTarget.Owner, …)`; do spawn inside the owner-side handler | One network event, no ownership change | Default. Preserves whatever ownership scheme the pool already uses (master-managed, last-interactor, etc.). |\n| 2 | Take ownership locally with `Networking.SetOwner(LocalPlayer, pool.gameObject)`, then call `TryToSpawn()` / `Shuffle()` directly | One ownership transfer per Interact, no separate event | When the interaction semantically implies \"this player now owns the next spawn\" (e.g. each player owns their own ammo pool). |\n\n#### Tier 1 — Forward to owner (recommended)\n\n> **Setup precondition**: Attach `PoolInteractForwarded` to the **same GameObject as the `VRCObjectPool`** it references. `NetworkEventTarget.Owner` resolves to the owner of the *sending UdonBehaviour's* GameObject (per [creators.vrchat.com networking events](https://creators.vrchat.com/worlds/udon/networking/events/)), not to `objectPool.gameObject`. Co-location ensures the event is delivered to the pool owner. If you need the interactor and the pool on separate GameObjects, see Tier 2 instead.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Components;\nusing VRC.SDKBase;\nusing VRC.Udon.Common.Interfaces;\n\n// Attach this script to the SAME GameObject as the VRCObjectPool it references.\npublic class PoolInteractForwarded : UdonSharpBehaviour\n{\n public VRCObjectPool objectPool;\n\n public override void Interact()\n {\n // NetworkEventTarget.Owner targets the owner of THIS UdonBehaviour's\n // GameObject. Since this script is co-located with objectPool, the\n // event is delivered to the pool owner — no ownership change needed.\n SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(OwnerSpawn));\n }\n\n public void OwnerSpawn()\n {\n // Defensive: if ownership transferred between the event send and arrival,\n // the new owner will still see this fire on the old owner's client; the\n // guard makes the call a safe no-op rather than silently spawning on\n // a stale owner.\n if (!Networking.IsOwner(objectPool.gameObject)) return;\n objectPool.Shuffle();\n GameObject spawned = objectPool.TryToSpawn();\n // ... assign ownership of `spawned` if needed\n }\n}\n```\n\n`OwnerSpawn` runs on the client that owns this script's GameObject (which, per the co-location precondition above, is the pool owner). The `IsOwner` guard is defensive against a race where ownership transfers between the `SendCustomNetworkEvent` call and the handler arriving on the previous owner's client.\n\n#### Tier 2 — Take ownership first (acceptable)\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Components;\nusing VRC.SDKBase;\n\npublic class PoolInteractTakeOwnership : UdonSharpBehaviour\n{\n public VRCObjectPool objectPool;\n\n public override void Interact()\n {\n Networking.SetOwner(Networking.LocalPlayer, objectPool.gameObject);\n objectPool.Shuffle();\n GameObject spawned = objectPool.TryToSpawn();\n // ... assign ownership of `spawned` if needed\n }\n}\n```\n\n`Networking.SetOwner` is locally immediate post-SDK 2021.2.2, so it is safe to call pool methods on the next line under an `IsOwner` invariant. See [networking.md](networking.md) for the full ownership model.\n\n#### Choosing between Tier 1 and Tier 2\n\nUse Tier 1 by default. Use Tier 2 when the interaction conceptually transfers ownership of the pool to the interacting player — for example, per-player ammo pools or individual draw-card decks. Mixing both patterns in one world is fine when each pool's role is different; Tier framing applies per-pool, not globally.\n\n### VRCObjectPool vs VRCInstantiate\n\n| | VRCObjectPool | VRCInstantiate |\n|---|---|---|\n| **Sync** | Network-synchronized across all players | Local only — not synced |\n| **Ownership** | Managed by pool owner; spawned object ownership must be set manually | No ownership concept |\n| **Late joiners** | Receive correct state automatically | Miss any previously instantiated objects |\n| **Object reuse** | Pre-allocated pool; `Return()` makes objects available again | Objects persist until destroyed |\n| **Use when** | Spawning networked bullets, per-player data containers, shared world objects | Local particle effects, client-side previews, non-networked decorations |\n\n#### Decision Flow\n\n```text\nDoes every player need to see the spawned object?\n├── No --> VRCInstantiate (local, no sync overhead)\n└── Yes --> VRCObjectPool (synchronized, ownership-aware)\n Does the object need to be reused frequently?\n ├── Yes --> VRCObjectPool (pooling avoids repeated allocation)\n └── No --> VRCObjectPool still preferred over VRCInstantiate for synced objects\n```\n\n## VRCObjectSync\n\nAutomatic position/rotation sync for physics objects.\n\n### Methods\n\n```csharp\nVRCObjectSync sync = (VRCObjectSync)GetComponent(typeof(VRCObjectSync));\n\n// Teleport (respects network sync)\nsync.TeleportTo(Transform target);\n\n// Respawn to original position\nsync.Respawn();\n\n// Enable/disable gravity and kinematic\nsync.SetGravity(bool enabled);\nsync.SetKinematic(bool enabled);\n\n// Flag for update\nsync.FlagDiscontinuity();\n```\n\n## VRCUrl / VRCStringDownloader / VRCImageDownloader\n\nFor details on the Web Loading API, see `references/web-loading.md`.\n\n**Key Points:**\n- `VRCStringDownloader.LoadUrl(url, (IUdonEventReceiver)this)` -- Text/JSON download\n- `new VRCImageDownloader().DownloadImage(url, material, (IUdonEventReceiver)this, textureInfo)` -- Image download\n- Rate limit: **Once every 5 seconds** (for String/Image each)\n- Max image resolution: **2048 x 2048**\n- Trusted URLs: Domain allowlist restrictions apply\n- Memory management: Must release with `IVRCImageDownload.Dispose()`\n\n```csharp\n// String Loading (VRC.SDK3.StringLoading)\nVRCStringDownloader.LoadUrl(dataUrl, (IUdonEventReceiver)this);\n// -> OnStringLoadSuccess / OnStringLoadError\n\n// Image Loading (VRC.SDK3.ImageLoading)\nvar downloader = new VRCImageDownloader();\ndownloader.DownloadImage(imageUrl, material, (IUdonEventReceiver)this);\n// -> OnImageLoadSuccess / OnImageLoadError\n```\n\n## Enumerations\n\n### NetworkEventTarget\n\n```csharp\nusing VRC.Udon.Common.Interfaces;\n\nNetworkEventTarget.All // Send to all players including self\nNetworkEventTarget.Owner // Send to object owner only\n```\n\n### TrackingDataType\n\n```csharp\nVRCPlayerApi.TrackingDataType.Head\nVRCPlayerApi.TrackingDataType.LeftHand\nVRCPlayerApi.TrackingDataType.RightHand\nVRCPlayerApi.TrackingDataType.Origin\n```\n\n### PickupHand\n\n```csharp\nVRC_Pickup.PickupHand.None\nVRC_Pickup.PickupHand.Left\nVRC_Pickup.PickupHand.Right\n```\n\n### SpawnOrientation\n\n```csharp\nVRC_SceneDescriptor.SpawnOrientation.Default\nVRC_SceneDescriptor.SpawnOrientation.AlignPlayerWithSpawnPoint\nVRC_SceneDescriptor.SpawnOrientation.AlignRoomWithSpawnPoint\n```\n\n## Synced Variable Types and Sizes\n\nKnow the sizes of synced types for bandwidth optimization:\n\n| Type | Size (bytes) | Notes |\n|------|--------------|-------|\n| `bool` | 1 | |\n| `byte`, `sbyte` | 1 | |\n| `short`, `ushort` | 2 | |\n| `int`, `uint` | 4 | |\n| `long`, `ulong` | 8 | |\n| `float` | 4 | |\n| `double` | 8 | |\n| `char` | 2 | UTF-16 |\n| `string` | variable | 2 bytes per char; no separate per-string limit — bounded by sync mode budget |\n| `Vector2` | 8 | 2 floats |\n| `Vector3` | 12 | 3 floats |\n| `Vector4` | 16 | 4 floats |\n| `Quaternion` | 16 | 4 floats |\n| `Color` | 16 | 4 floats (RGBA) |\n| `Color32` | 4 | 4 bytes (RGBA) |\n| `VRCUrl` | variable | String-like |\n\n### Bandwidth Limits\n\n- **Continuous sync**: ~200 bytes per UdonBehaviour\n- **Manual sync**: ~280KB (280,496 bytes) per UdonBehaviour\n- **Total transmission**: ~11 KB/sec\n\n## SerializationResult\n\nReturned from `OnPostSerialization` for debugging:\n\n```csharp\npublic override void OnPostSerialization(SerializationResult result)\n{\n Debug.Log($\"Synced {result.byteCount} bytes, success: {result.success}\");\n}\n```\n\n| Property | Type | Description |\n|-----------|------|------|\n| `success` | `bool` | Whether serialization succeeded |\n| `byteCount` | `int` | Number of bytes serialized |\n\n## DataList and DataDictionary\n\nGeneric-like collections from `VRC.SDK3.Data`:\n\n```csharp\nusing VRC.SDK3.Data;\n\n// DataList (replaces List\u003cT>)\nDataList list = new DataList();\nlist.Add(\"item1\");\nlist.Add(42);\nlist.Add(3.14f);\n\nstring str = list[0].String;\nint num = list[1].Int;\nfloat flt = list[2].Float;\n\nint count = list.Count;\nlist.RemoveAt(0);\nlist.Clear();\n\n// DataDictionary (replaces Dictionary\u003cstring, T>)\nDataDictionary dict = new DataDictionary();\ndict[\"key1\"] = \"value1\";\ndict[\"key2\"] = 100;\n\nstring val = dict[\"key1\"].String;\nint num = dict[\"key2\"].Int;\n\nbool hasKey = dict.ContainsKey(\"key1\");\ndict.Remove(\"key1\");\n```\n\n### DataToken\n\nA type-safe container used by DataList/DataDictionary:\n\n```csharp\nDataToken token = new DataToken(\"hello\");\nDataToken token = new DataToken(42);\nDataToken token = new DataToken(3.14f);\n\n// Type checking\nTokenType type = token.TokenType; // String, Int, Float, etc.\n\n// Value extraction\nstring s = token.String;\nint i = token.Int;\nfloat f = token.Float;\nbool b = token.Boolean;\n```\n\n## PlayerData API (SDK 3.7.4+)\n\nKey-value storage for player data persisted across sessions.\n\n### Static Methods\n\n```csharp\nusing VRC.SDK3.Persistence;\n\n// Check if key exists\nbool exists = PlayerData.HasKey(player, \"highScore\");\n\n// Get values (with TryGet pattern)\nif (PlayerData.TryGetInt(player, \"highScore\", out int score))\n{\n Debug.Log($\"High score: {score}\");\n}\n\n// Set values (only on local player's own data)\nPlayerData.SetInt(Networking.LocalPlayer, \"highScore\", 1000);\nPlayerData.SetString(Networking.LocalPlayer, \"username\", \"Player1\");\nPlayerData.SetFloat(Networking.LocalPlayer, \"volume\", 0.8f);\nPlayerData.SetBool(Networking.LocalPlayer, \"tutorialComplete\", true);\n\n// Delete key\nPlayerData.DeleteKey(Networking.LocalPlayer, \"oldKey\");\n```\n\n### Supported Types\n\n| Method | Type | Size Limit |\n|---------|------|-----------|\n| `SetBool` / `TryGetBool` | `bool` | 1 byte |\n| `SetInt` / `TryGetInt` | `int` | 4 bytes |\n| `SetFloat` / `TryGetFloat` | `float` | 4 bytes |\n| `SetDouble` / `TryGetDouble` | `double` | 8 bytes |\n| `SetString` / `TryGetString` | `string` | ~50 chars |\n| `SetBytes` / `TryGetBytes` | `byte[]` | ~100KB total |\n| `SetVector3` / `TryGetVector3` | `Vector3` | 12 bytes |\n| `SetQuaternion` / `TryGetQuaternion` | `Quaternion` | 16 bytes |\n| `SetColor` / `TryGetColor` | `Color` | 16 bytes |\n\n### Storage Limits\n\n| Limit | Value |\n|------|------|\n| PlayerData per player per world | 100 KB |\n| PlayerObject per player per world | 100 KB |\n| Single UdonBehaviour with VRC Enable Persistence | 108 bytes per variable type |\n\n### Usage Pattern\n\n```csharp\npublic class PersistentScore : UdonSharpBehaviour\n{\n private int highScore = 0;\n private bool dataLoaded = false;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n // Wait for data to be loaded before accessing\n if (PlayerData.TryGetInt(player, \"highScore\", out int saved))\n {\n highScore = saved;\n }\n dataLoaded = true;\n }\n\n public void SaveScore(int score)\n {\n if (!dataLoaded) return; // Not loaded yet\n\n if (score > highScore)\n {\n highScore = score;\n PlayerData.SetInt(Networking.LocalPlayer, \"highScore\", score);\n }\n }\n}\n```\n\n## VRCCameraSettings API (SDK 3.9.0+)\n\nRead-only access to VRChat's built-in camera parameters. Provides two static instances and an event callback when settings change.\n\nNamespace: `VRC.SDK3.Rendering`\n\n### Static Camera Instances\n\n| Instance | Description |\n|----------|-------------|\n| `VRCCameraSettings.ScreenCamera` | The player's main view (desktop window or VR headset) |\n| `VRCCameraSettings.PhotoCamera` | The handheld in-game photo camera |\n\n### Properties\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `PixelWidth` | `int` | Render target width in pixels |\n| `PixelHeight` | `int` | Render target height in pixels |\n| `FieldOfView` | `float` | Horizontal field of view in degrees |\n| `Active` | `bool` | True if this camera is currently active |\n\n```csharp\nusing VRC.SDK3.Rendering;\n\n// Read main screen camera properties\nVRCCameraSettings screen = VRCCameraSettings.ScreenCamera;\nint w = screen.PixelWidth;\nint h = screen.PixelHeight;\nfloat fov = screen.FieldOfView;\nbool active = screen.Active;\n\n// Read photo camera properties\nVRCCameraSettings photo = VRCCameraSettings.PhotoCamera;\nbool photoOpen = photo.Active;\n```\n\n### Event Callback\n\n`OnVRCCameraSettingsChanged` fires whenever any camera property changes (resolution, FOV, active state). Override it on any `UdonSharpBehaviour`.\n\n```csharp\n// Signature\npublic override void OnVRCCameraSettingsChanged(VRCCameraSettings camera) { }\n```\n\n### Usage Example\n\n```csharp\nusing TMPro;\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Rendering;\nusing VRC.SDKBase;\n\npublic class CameraMonitor : UdonSharpBehaviour\n{\n [SerializeField] private TextMeshProUGUI infoText;\n\n void Start()\n {\n // Initialize display with current values\n UpdateDisplay(VRCCameraSettings.ScreenCamera);\n }\n\n public override void OnVRCCameraSettingsChanged(VRCCameraSettings camera)\n {\n // Skip changes from the photo camera\n if (camera != VRCCameraSettings.ScreenCamera) return;\n\n UpdateDisplay(camera);\n }\n\n private void UpdateDisplay(VRCCameraSettings camera)\n {\n infoText.text = $\"Resolution: {camera.PixelWidth}x{camera.PixelHeight}\\n\" +\n $\"FOV: {camera.FieldOfView:F1} deg\\n\" +\n $\"Photo cam: {VRCCameraSettings.PhotoCamera.Active}\";\n }\n}\n```\n\n### Notes\n\n```text\n- All properties are read-only from Udon. Camera settings cannot be set via this API.\n- OnVRCCameraSettingsChanged fires for both ScreenCamera and PhotoCamera changes;\n filter by comparing the parameter with VRCCameraSettings.ScreenCamera.\n- PixelWidth / PixelHeight reflect the actual render resolution, which changes when\n the player resizes the window or toggles the photo camera.\n```\n\n## VRChat Dynamics API (SDK 3.10.0+)\n\nPhysBones, Contacts, and VRC Constraints in worlds.\n\n### VRCContactReceiver\n\nReceives contact events from Contact Senders.\n\n```csharp\n// Get contact receiver component\nVRCContactReceiver receiver = GetComponent\u003cVRCContactReceiver>();\n\n// Configure allowed content types\nstring[] allowedTypes = new string[] { \"Hand\", \"Finger\", \"Custom\" };\nreceiver.UpdateContentTypes(allowedTypes);\n\n// Properties\nbool allowSelf = receiver.allowSelf; // Allow contacts from same avatar\nbool allowOthers = receiver.allowOthers; // Allow contacts from other avatars\nfloat radius = receiver.radius; // Collision radius (both shapes; max 3 m)\nfloat height = receiver.height; // Capsule height along Y, half-spheres included (max 6 m)\nContactBase.ShapeType shapeType = receiver.shapeType; // Sphere or Capsule\n```\n\nShape and dimension properties are read/write from Udon. `height` is only meaningful when `shapeType` is `ContactBase.ShapeType.Capsule`.\n\n### VRCContactSender\n\nSends contact events to receivers.\n\n```csharp\nVRCContactSender sender = GetComponent\u003cVRCContactSender>();\n\n// Properties\nfloat radius = sender.radius; // Collision radius (both shapes; max 3 m)\nfloat height = sender.height; // Capsule height along Y, half-spheres included (max 6 m)\nContactBase.ShapeType shapeType = sender.shapeType; // Sphere or Capsule\nstring contentType = sender.contentType; // Contact tag string (e.g. \"Finger\", \"Hand\", or custom)\n```\n\nShape and dimension properties are read/write from Udon. `height` is only meaningful when `shapeType` is `ContactBase.ShapeType.Capsule`.\n\n### VRCPhysBone\n\nPhysics-based bone system in worlds.\n\n```csharp\nVRCPhysBone physBone = GetComponent\u003cVRCPhysBone>();\n\n// Properties (read-only in most cases)\nbool isGrabbed = physBone.IsGrabbed();\nVRCPlayerApi grabbingPlayer = physBone.GetGrabbingPlayer();\n\n// Get affected transforms\nTransform[] affectedBones = physBone.GetAffectedTransforms();\n```\n\n### Contact Event Info Structs\n\n```csharp\n// ContactEnterInfo\npublic struct ContactEnterInfo\n{\n public string senderName; // Contact sender name\n public bool isAvatar; // True if from avatar\n public VRCPlayerApi player; // Player if isAvatar is true\n public Vector3 position; // Contact position\n public Vector3 normal; // Contact normal\n}\n\n// ContactStayInfo\npublic struct ContactStayInfo\n{\n public string senderName;\n public bool isAvatar;\n public VRCPlayerApi player;\n public Vector3 position;\n public Vector3 normal;\n}\n\n// ContactExitInfo\npublic struct ContactExitInfo\n{\n public string senderName;\n public bool isAvatar;\n public VRCPlayerApi player;\n}\n```\n\n### PhysBone Event Info Structs\n\n```csharp\n// PhysBoneGrabInfo\npublic struct PhysBoneGrabInfo\n{\n public VRCPlayerApi player; // Grabbing player\n public Transform bone; // Grabbed bone\n}\n\n// PhysBoneReleaseInfo\npublic struct PhysBoneReleaseInfo\n{\n public VRCPlayerApi player;\n public Transform bone;\n}\n```\n\n### Usage Example: Interactive Button with Contacts\n\n```csharp\npublic class ContactButton : UdonSharpBehaviour\n{\n public AudioSource clickSound;\n public Animator buttonAnimator;\n\n private bool isPressed = false;\n\n public override void OnContactEnter(ContactEnterInfo info)\n {\n if (isPressed) return;\n\n isPressed = true;\n clickSound.Play();\n buttonAnimator.SetTrigger(\"Press\");\n\n Debug.Log($\"Button pressed by: {(info.isAvatar ? info.player?.displayName : \"world object\")}\");\n }\n\n public override void OnContactExit(ContactExitInfo info)\n {\n isPressed = false;\n buttonAnimator.SetTrigger(\"Release\");\n }\n}\n```\n\n## VRCDroneApi\n\nThe drone that a player controls when in drone mode. Obtain the local player's drone via `Networking.LocalPlayer.GetDrone()`.\n\n### Getting the Drone\n\n```csharp\n// Get the drone associated with the local player\nVRCDroneApi drone = Networking.LocalPlayer.GetDrone();\n\n// Always validate before use — returns null when the player is not in drone mode\nif (Utilities.IsValid(drone))\n{\n // Safe to use drone\n}\n```\n\n### Methods\n\n```csharp\n// Teleport the drone to a world-space position and rotation\ndrone.TeleportTo(Vector3 position, Quaternion rotation);\n\n// Set the drone's current velocity (world space, meters per second)\ndrone.SetVelocity(Vector3 velocity);\n\n// Get the VRCPlayerApi of the player piloting this drone\nVRCPlayerApi pilot = drone.GetPlayer();\n```\n\n### Usage Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.Udon.Common.Interfaces;\n\npublic class DroneCheckpoint : UdonSharpBehaviour\n{\n [SerializeField] private Transform respawnPoint;\n\n public override void OnDroneTriggerEnter(Collider other)\n {\n VRCDroneApi drone = Networking.LocalPlayer.GetDrone();\n if (!Utilities.IsValid(drone)) return;\n\n // Teleport the drone back to the respawn point\n drone.TeleportTo(respawnPoint.position, respawnPoint.rotation);\n drone.SetVelocity(Vector3.zero);\n\n VRCPlayerApi pilot = drone.GetPlayer();\n if (Utilities.IsValid(pilot))\n {\n Debug.Log($\"{pilot.displayName}'s drone hit a checkpoint\");\n }\n }\n}\n```\n\n## VRC Camera Dolly API (SDK 3.9.0+)\n\nDefines camera dolly animations applied to the local player's VRChat user camera.\nThree components work together in a fixed parent-child hierarchy.\n\n**Available since**: SDK 3.9.0\n\n> **No ClientSim preview**: Camera dolly animations do not render in ClientSim.\n> Use **Build and Test** to preview animations at runtime.\n\n### Component Hierarchy\n\n```text\nGameObject (VRC Camera Dolly Animation)\n├── GameObject (VRC Camera Dolly Path)\n│ ├── GameObject (VRC Camera Dolly Point)\n│ └── GameObject (VRC Camera Dolly Point)\n└── GameObject (VRC Camera Dolly Path)\n ├── GameObject (VRC Camera Dolly Point)\n ├── GameObject (VRC Camera Dolly Point)\n └── GameObject (VRC Camera Dolly Point)\n```\n\n### VRC Camera Dolly Animation — Inspector Parameters\n\nConfigure these in the Unity Inspector on the top-level `VRC Camera Dolly Animation` component:\n\n| Parameter | Description |\n|-----------|-------------|\n| `Is Relative To Player` | Anchor animation to the local player's position instead of world origin |\n| `Is Speed Based` | Use speed values per point rather than fixed durations |\n| `Is Using Look At Me` | Enable Look-At-Me horizontal/vertical offsets on points |\n| `Is Using Greenscreen` | Enable Green Screen HSL controls on points |\n| `Is Using Multi Stream` | Enable multi-stream animation mode |\n| `Path Type` | Interpolation method for the path (linear, smooth, etc.) |\n| `Loop Type` | How the animation loops (none, loop, ping-pong) |\n| `Capture Type` | Capture methodology for the animation |\n| `Focus Mode` | Camera focus mode for this animation |\n| `Anchor Mode` | Camera anchor mode for this animation |\n| `Paths` | List of `VRC Camera Dolly Path` children; populate via **Collect Paths & Points** |\n\n### VRC Camera Dolly Path — Inspector Parameters\n\n| Parameter | Description |\n|-----------|-------------|\n| `Points` | List of `VRC Camera Dolly Point` children; populated via **Collect Paths & Points** |\n\n### VRC Camera Dolly Point — Inspector Parameters (Keyframe)\n\n| Parameter | Description |\n|-----------|-------------|\n| `Zoom` | Keyframe zoom value |\n| `Duration` | Duration for this keyframe (time-based mode only) |\n| `Speed` | Speed for this keyframe (speed-based mode only) |\n| `Focal Distance` | Focal distance (manual focus mode) |\n| `Aperture` | Aperture value (manual or semi-auto focus mode) |\n| `Hue` | Greenscreen hue (greenscreen mode) |\n| `Saturation` | Greenscreen saturation (greenscreen mode) |\n| `Lightness` | Greenscreen lightness (greenscreen mode) |\n| `Look At Me X Offset` | Horizontal Look-At-Me offset (Look-At-Me mode) |\n| `Look At Me Y Offset` | Vertical Look-At-Me offset (Look-At-Me mode) |\n\n### UdonSharp API\n\nThe scripting surface for Camera Dolly is intentionally minimal. The primary method is:\n\n```csharp\n// Apply the animation to the local player's VRChat user camera\ndollyAnimation.Import();\n```\n\n`Import()` reads all paths and points collected on the `VRC Camera Dolly Animation` component and applies the resulting animation to the camera of the **local client** only. It has no return value.\n\n### Setup and Usage\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\npublic class DollyController : UdonSharpBehaviour\n{\n // Drag the GameObject that holds VRC Camera Dolly Animation into this field\n [SerializeField] private VRCCameraDollyAnimation dollyAnimation;\n\n // Call this to start the camera dolly animation for the local player\n public void PlayDolly()\n {\n if (!Utilities.IsValid(dollyAnimation)) return;\n dollyAnimation.Import();\n }\n}\n```\n\n> **Important**: Before entering Play mode or building, select the top-level `VRC Camera Dolly Animation` object and click **Collect Paths & Points** to register all child paths and points. Any time you add, remove, or re-order children, repeat this step.\n\n### Limitations\n\n- The API applies the animation to the **local player only**. To trigger it for all players, use `SendCustomNetworkEvent(NetworkEventTarget.All, nameof(PlayDolly))`.\n- No properties of the animation, paths, or points are readable or writable from UdonSharp at runtime.\n- There is no event callback when the animation completes.\n- No ClientSim preview; Build and Test is required to see the animation.\n\n## See Also\n\n- [constraints.md](constraints.md) - C# feature availability in UdonSharp that affects which APIs can be used\n- [events.md](events.md) - Complete event list and execution-order diagrams\n- [networking.md](networking.md) - `Networking` class, ownership, and `RequestSerialization` patterns\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":42037,"content_sha256":"54a423ae38597da6966152fce977568f6a84a82d1c9eb777ff56af01b4e2d386"},{"filename":"references/constraints.md","content":"# UdonSharp Constraints Reference\n\nComplete reference of C# features and their availability in UdonSharp, including SDK version availability,\ncompiler behavior details, and annotated code examples.\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\n> For the quick-reference checklist and code generation rules, see `rules/udonsharp-constraints.md`.\n\n---\n\n## Compiler Behavior Overview\n\nUdonSharp transpiles C# source to Udon Assembly, which runs on VRChat's UdonVM. This has several implications:\n\n- **Static analysis at compile time**: Blocked features cause compile errors, not runtime exceptions.\n- **Field initializers run at compile time**: Values are baked into the serialized asset, not evaluated at scene load.\n- **Struct semantics differ**: UdonVM passes structs by value; mutating methods on structs return new values and do not modify the original.\n- **Method lookup cost**: UdonVM performs string-based method lookup; public methods are visible to Udon's event system and incur slightly higher overhead.\n- **Checked arithmetic**: UdonVM runs with overflow checking enabled by default. Operations that would silently wrap in standard C# will behave as checked.\n- **No JIT**: There is no just-in-time compilation. All method dispatch is interpreted.\n\n---\n\n## Supported Features\n\n### Data Types\n\n| Type | Status | SDK Added | Notes |\n|------|--------|-----------|-------|\n| `int`, `float`, `double`, `bool` | Supported | 3.0+ | Basic value types work normally |\n| `string`, `char` | Supported | 3.0+ | String interpolation (`$\"\"`) available |\n| `Vector3`, `Quaternion`, `Color` | Supported | 3.0+ | Unity struct types; see struct mutation caveat |\n| `GameObject`, `Transform` | Supported | 3.0+ | Unity object types |\n| `T[]` (one-dimensional arrays) | Supported | 3.0+ | Primary collection type |\n| `T[,]` (multi-dimensional arrays) | Blocked | — | Use jagged arrays `T[][]` instead |\n| `T[][]` (jagged arrays) | Supported | 3.0+ | Arrays of arrays |\n\n### Control Flow\n\n| Feature | Status | SDK Added | Notes |\n|---------|--------|-----------|-------|\n| `if`/`else` | Supported | 3.0+ | |\n| `switch` | Supported | 3.0+ | |\n| `for`, `foreach` | Supported | 3.0+ | |\n| `while`, `do-while` | Supported | 3.0+ | |\n| `break`, `continue`, `return` | Supported | 3.0+ | |\n| Ternary operator `? :` | Supported | 3.0+ | |\n| Null-coalescing `??` | Supported | 3.0+ | |\n| Null-conditional `?.` | Supported | 3.0+ | |\n\n### Methods and Properties\n\n| Feature | Status | SDK Added | Notes |\n|---------|--------|-----------|-------|\n| User-defined methods | Supported | 3.0+ | Parameters and return values work |\n| `out`/`ref` parameters | Supported | 3.0+ | |\n| `params` keyword | Supported | 3.0+ | Variable arguments |\n| Extension methods | Supported | 1.0+ | Works in UdonSharp 1.0+; static methods also valid |\n| Properties (get/set) | Supported | 3.0+ | |\n| Virtual methods | Supported | 3.0+ | For inheritance |\n| `[RecursiveMethod]` | Required | 3.0+ | Attribute required for recursive calls |\n\n### Object-Oriented Features\n\n| Feature | Status | SDK Added | Notes |\n|---------|--------|-----------|-------|\n| `UdonSharpBehaviour` inheritance | Supported | 3.0+ | Required base class |\n| Single inheritance | Supported | 3.0+ | One base class only |\n| UdonSharpBehaviour-to-UdonSharpBehaviour inheritance | Supported | 3.0+ | Can inherit from custom UdonSharpBehaviours |\n| Abstract classes | Supported | 3.0+ | |\n| Virtual methods | Supported | 3.0+ | For polymorphism |\n| Static fields/methods | Supported | 3.0+ | |\n| Partial classes | Supported | 3.0+ | Split class across files |\n| `typeof()` | Supported | 3.0+ | |\n\n---\n\n## Unsupported Features\n\n### Collections and Generics\n\n| Feature | Status | SDK Added | Alternative |\n|---------|--------|-----------|-------------|\n| `List\u003cT>` | Blocked | — | Use `T[]` arrays or `DataList` (SDK 3.7.1+) |\n| `Dictionary\u003cT,K>` | Blocked | — | Use `DataDictionary` from VRC SDK (SDK 3.7.1+) |\n| `Queue\u003cT>`, `Stack\u003cT>` | Blocked | — | Implement with arrays |\n| `HashSet\u003cT>` | Blocked | — | Use arrays with manual deduplication |\n| Generic type parameters | Blocked | — | Use concrete types |\n\n**DataList / DataDictionary (SDK 3.7.1+):**\n\n> **When to use:** Prefer fixed-size `T[]` arrays for most cases — they are faster, type-safe at compile time, and work with `[UdonSynced]`. Use `DataList` / `DataDictionary` only when: (1) the collection size is truly unknown at compile time and varies at runtime, (2) you need heterogeneous value types in a single container (via `DataToken`), or (3) you are parsing JSON with `VRCJson` (which returns `DataDictionary` / `DataList` natively). Do not adopt DataList just because it feels more familiar than manual array resizing — the `ArrayUtils` helper pattern (see [patterns-utilities.md](patterns-utilities.md)) covers `Add` / `Remove` / `FindIndex` for typed arrays with no boxing overhead.\n\n```csharp\nusing VRC.SDK3.Data;\n\n// Instead of List\u003cstring>\nDataList stringList = new DataList();\nstringList.Add(\"item1\");\nstringList.Add(\"item2\");\nstring first = stringList[0].String;\nint count = stringList.Count;\n\n// Iterate a DataList\nfor (int i = 0; i \u003c stringList.Count; i++)\n{\n Debug.Log(stringList[i].String);\n}\n\n// Instead of Dictionary\u003cstring, int>\nDataDictionary dict = new DataDictionary();\ndict[\"key1\"] = 100;\ndict[\"key2\"] = 200;\nint value = dict[\"key1\"].Int;\n\n// Check key existence\nif (dict.ContainsKey(\"key1\"))\n{\n Debug.Log(\"Found: \" + dict[\"key1\"].Int);\n}\n```\n\n### Language Features\n\n| Feature | Status | SDK Added | Alternative |\n|---------|--------|-----------|-------------|\n| `interface` | Blocked | — | Use base class or `SendCustomEvent` |\n| Method overloading | Blocked | — | Use distinct method names |\n| Operator overloading | Blocked | — | Use explicit methods |\n| `try`/`catch`/`finally` | Blocked | — | Use defensive null checks |\n| `throw` exceptions | Blocked | — | Use return values for errors |\n| `async`/`await` | Blocked | — | Use `SendCustomEventDelayedSeconds` |\n| `yield return` (coroutines) | Blocked | — | Use delayed events |\n| Delegates | Blocked | — | Use `SendCustomEvent` |\n| `Button.onClick.AddListener()` | Blocked | — | Inspector OnClick -> `SendCustomEvent` |\n| Events (C# events) | Blocked | — | Use UdonSharp events |\n| LINQ | Blocked | — | Use manual loops |\n| Anonymous types | Blocked | — | Define explicit types |\n| Lambda expressions | Blocked | — | Use named methods |\n| Local functions | Blocked | — | Use private methods |\n| Pattern matching | Blocked | — | Use traditional `if`/`switch` |\n\n**Method Overloading Alternative:**\n\n```csharp\n// WRONG - overloading not supported\npublic void DoSomething(int value) { }\npublic void DoSomething(string value) { } // Compile error\n\n// CORRECT - use distinct names\npublic void DoSomethingInt(int value) { }\npublic void DoSomethingString(string value) { }\n```\n\n**Exception Handling Alternative (defensive programming):**\n\n```csharp\n// WRONG - try/catch not supported\ntry {\n ProcessData(data);\n} catch (Exception e) {\n Debug.LogError(e.Message);\n}\n\n// CORRECT - guard clauses and early returns\nif (data == null)\n{\n Debug.LogError(\"[MyScript] Data is null, aborting ProcessData\");\n return;\n}\nProcessData(data);\n```\n\n**Async / Coroutine Alternative:**\n\n```csharp\n// WRONG - async/await and coroutines not supported\nprivate async Task DelayedAction()\n{\n await Task.Delay(2000);\n DoSomething();\n}\n\n// CORRECT - use SendCustomEventDelayedSeconds\npublic void TriggerDelayed()\n{\n SendCustomEventDelayedSeconds(nameof(DoSomething), 2f);\n}\n\npublic void DoSomething()\n{\n // Called 2 seconds later\n}\n```\n\n### System Namespaces\n\n| Namespace | Status | SDK Added | Notes |\n|-----------|--------|-----------|-------|\n| `System.IO` | Blocked | — | Not available (security restriction) |\n| `System.Net` | Blocked | — | Use `VRCStringDownloader`, `VRCImageDownloader` |\n| `System.Reflection` | Blocked | — | Not available |\n| `System.Threading` | Blocked | — | Not available |\n| `System.Linq` | Blocked | — | Use manual loops |\n| `System.Text.StringBuilder` | Available | **3.7.1** | Efficient string concatenation |\n| `System.Text.RegularExpressions` | Available | **3.7.1** | Pattern matching (Regex) |\n| `System.Random` | Available | **3.7.1** | Deterministic random with seed |\n| `System.Type` | Available | **3.7.1** | Runtime type interaction |\n\n**StringBuilder (SDK 3.7.1+):**\n\n```csharp\nusing System.Text;\n\npublic class StringBuilderExample : UdonSharpBehaviour\n{\n public void BuildPlayerList()\n {\n VRCPlayerApi[] players = new VRCPlayerApi[VRCPlayerApi.GetPlayerCount()];\n VRCPlayerApi.GetPlayers(players);\n\n StringBuilder sb = new StringBuilder();\n sb.Append(\"Players:\\n\");\n\n foreach (VRCPlayerApi player in players)\n {\n if (player != null && player.IsValid())\n {\n sb.Append(\"- \");\n sb.Append(player.displayName);\n sb.Append(\"\\n\");\n }\n }\n\n Debug.Log(sb.ToString());\n }\n}\n```\n\n**Regex (SDK 3.7.1+):**\n\n```csharp\nusing System.Text.RegularExpressions;\n\npublic class RegexExample : UdonSharpBehaviour\n{\n public bool IsValidUsername(string username)\n {\n // 3-16 characters, alphanumeric and underscores only\n Regex pattern = new Regex(@\"^[a-zA-Z0-9_]{3,16}$\");\n return pattern.IsMatch(username);\n }\n}\n```\n\n**System.Random with seed (SDK 3.7.1+):**\n\n```csharp\npublic class SeededRandom : UdonSharpBehaviour\n{\n private System.Random _rng;\n\n void Start()\n {\n // Seed with player count for deterministic-per-session results\n _rng = new System.Random(VRCPlayerApi.GetPlayerCount());\n }\n\n public int NextInt(int minInclusive, int maxExclusive)\n {\n return _rng.Next(minInclusive, maxExclusive);\n }\n}\n```\n\n### Unsafe and Low-Level Features\n\n| Feature | Status | SDK Added | Notes |\n|---------|--------|-----------|-------|\n| `unsafe` keyword | Blocked | — | Memory safety restriction |\n| Pointers (`*`, `&`) | Blocked | — | Not available |\n| `fixed` statement | Blocked | — | Not available |\n| `stackalloc` | Blocked | — | Not available |\n\n---\n\n## Behavioral Differences from Standard C#\n\n### Checked Arithmetic (Numeric Overflow)\n\nUdonVM runs with overflow checking enabled. Operations that silently wrap in unchecked C# will behave as if in a `checked` block:\n\n```csharp\n// In standard C# (unchecked), this wraps to int.MinValue\nint max = int.MaxValue;\nint overflow = max + 1;\n// In UdonVM: overflow is detected (avoid relying on wrap-around behavior)\n\n// Safe pattern: guard against overflow explicitly\nif (value \u003c int.MaxValue)\n{\n value++;\n}\n```\n\n### Struct Method Mutation\n\nUdonVM passes structs by value. Methods that appear to mutate a struct (like `Normalize()`) actually return a new value and leave the original unchanged:\n\n```csharp\n// WRONG - Normalize() doesn't change v\nVector3 v = new Vector3(3, 4, 0);\nv.Normalize();\nDebug.Log(v); // Still (3, 4, 0)!\n\n// CORRECT - use the property form, which returns a new value\nVector3 v = new Vector3(3, 4, 0);\nv = v.normalized;\nDebug.Log(v); // (0.6, 0.8, 0)\n\n// Same applies to other struct mutating patterns:\nQuaternion q = transform.rotation;\nq.Normalize(); // WRONG - q is not normalized\nq = q.normalized; // CORRECT\n```\n\n### Field Initializers Are Compile-Time\n\nField initializers are evaluated when UdonSharp compiles the script, not at scene load or instance creation. The baked value is stored in the serialized asset.\n\n```csharp\n// WRONG - Random.Range is evaluated once at compile time\n// All instances of this script get the same value\nprivate int randomValue = Random.Range(0, 100);\n\n// CORRECT - evaluate at runtime in Start()\nprivate int randomValue;\n\nvoid Start()\n{\n randomValue = Random.Range(0, 100); // Different each play\n}\n```\n\n**Lazy Initialization Pattern** (for objects that may be inactive at Start):\n\n```csharp\nprivate Transform _target;\nprivate bool _initialized;\n\nprivate void EnsureInit()\n{\n if (_initialized) return;\n var go = GameObject.Find(\"Target\");\n if (go != null) _target = go.transform;\n _initialized = true;\n}\n\npublic void DoWork()\n{\n EnsureInit();\n if (_target == null) return;\n // use _target\n}\n```\n\n### Array Operations\n\nStandard array utilities work as expected, but note that `Array.Resize` creates a new array:\n\n```csharp\n// Array.Resize creates a new array (ref parameter reflects this)\nint[] arr = new int[5];\nSystem.Array.Resize(ref arr, 10);\n// arr now references the new 10-element array\n\n// Array.Copy works as expected\nint[] source = new int[] { 1, 2, 3 };\nint[] dest = new int[3];\nSystem.Array.Copy(source, dest, 3);\n```\n\n> **Predicate-based APIs** (`Array.FindAll`, `Array.Find`, etc.) are impractical in UdonSharp — see [`patterns-performance.md`](patterns-performance.md) `Array Filtering — Array.FindAll Alternative` for the temp-array filtering pattern.\n\n---\n\n## GetComponent and SDK Version Notes\n\n### Pre-3.8: Cast Syntax Required\n\nBefore SDK 3.8, generic `GetComponent\u003cUdonBehaviour>()` was not exposed:\n\n```csharp\n// WRONG (all SDK versions for raw UdonBehaviour)\nUdonBehaviour ub = GetComponent\u003cUdonBehaviour>();\n\n// CORRECT - cast syntax\nUdonBehaviour ub = (UdonBehaviour)GetComponent(typeof(UdonBehaviour));\n```\n\n### SDK 3.8+: Generic Works for UdonSharpBehaviour Inheritance\n\nSDK 3.8 added proper generic `GetComponent\u003cT>()` support for types that inherit from `UdonSharpBehaviour`:\n\n```csharp\n// CORRECT (SDK 3.8+) - works for direct UdonSharpBehaviour subclasses\nMyScript script = GetComponent\u003cMyScript>();\n\n// CORRECT (SDK 3.8+) - works through inheritance hierarchy\npublic class BaseGimmick : UdonSharpBehaviour { }\npublic class DerivedGimmick : BaseGimmick { }\n\nBaseGimmick gimmick = GetComponent\u003cBaseGimmick>();\nDerivedGimmick derived = GetComponent\u003cDerivedGimmick>();\n\n// GetComponents (plural) also works\nBaseGimmick[] all = GetComponents\u003cBaseGimmick>();\n```\n\n---\n\n## uGUI Button Event Registration\n\n`Button.onClick.AddListener()` uses delegates, which are blocked. Button click events must be wired in the Unity Inspector.\n\n```csharp\n// WRONG - delegates not supported; throws at compile time\nbutton.onClick.AddListener(() => DoSomething());\nbutton.onClick.AddListener(DoSomething);\n\n// CORRECT - wire in the Unity Inspector:\n// 1. Select the Button GameObject\n// 2. In OnClick(), click \"+\"\n// 3. Drag the UdonBehaviour component into the object field\n// 4. Select the dropdown: UdonBehaviour -> SendCustomEvent (string)\n// 5. Enter the exact method name, e.g. \"OnButtonClicked\"\n\npublic void OnButtonClicked()\n{\n // Called by the Inspector-configured OnClick event\n}\n```\n\n---\n\n## Unity Callback Override Rules\n\nUnity lifecycle callbacks (`OnTriggerEnter`, `OnCollisionEnter`, etc.) must **not** use `override`.\nVRChat network events (`OnPlayerJoined`, `OnOwnershipRequest`, etc.) **must** use `override`.\n\n```csharp\n// NG: override on Unity callback causes CS0115\npublic override void OnTriggerEnter(Collider other) { }\n\n// OK: Unity callbacks without override\npublic void OnTriggerEnter(Collider other) { }\npublic void OnCollisionEnter(Collision collision) { }\npublic void OnParticleCollision(GameObject other) { }\n\n// OK: VRChat events require override\npublic override void OnPlayerJoined(VRCPlayerApi player) { }\npublic override void OnPlayerLeft(VRCPlayerApi player) { }\npublic override void OnOwnershipRequest(VRCPlayerApi requester, VRCPlayerApi newOwner) { }\n```\n\n---\n\n## VRChat-Specific Types\n\n| Type | Purpose | SDK Added |\n|------|---------|-----------|\n| `VRCPlayerApi` | Player information and actions | 3.0+ |\n| `VRCStation` | Station/seat management | 3.0+ |\n| `VRCPickup` | Pickup object handling | 3.0+ |\n| `VRCAvatarParameterDriver` | Avatar parameter control | 3.0+ |\n| `VRCUrl` | URL handling for downloads | 3.0+ |\n| `DataList` | Generic-like ordered list | 3.7.1+ |\n| `DataDictionary` | Generic-like key-value map | 3.7.1+ |\n| `DataToken` | Type-safe data container | 3.7.1+ |\n\n---\n\n## Known Limitations and Caveats\n\n### Prefab Field Changes\n\nChanges to serialized fields on prefabs do NOT propagate to scene instances automatically. This is a Unity limitation that affects UdonSharp as well.\n\n**Workaround**: After modifying a prefab, manually update instances or use `PrefabUtility.ApplyPrefabInstance` in editor scripts.\n\n### Public Method Lookup Cost\n\nUdon dispatches events (like `SendCustomEvent`) by searching public method names as strings. Having many public methods increases dispatch time. Keep methods `private` unless they need to be accessible from other UdonBehaviours or from the Inspector.\n\n### String Sync Limit\n\nSynced `string` fields (`[UdonSynced]`) are encoded at 2 bytes per character. There is no separate per-string character limit; the practical limit is set by the sync mode's per-serialization buffer:\n\n- **Continuous sync**: ~200 bytes shared across all synced fields on the behaviour. A single synced string can consume the entire budget quickly (e.g., a 100-character string = 200 bytes), leaving no room for other fields.\n- **Manual sync**: 280,496 bytes (~280KB) per serialization, allowing much larger strings.\n\nFor Continuous sync, keep synced strings very short or switch to Manual sync.\n\n---\n\n## Quick Validation Checklist\n\nBefore compiling UdonSharp code, verify:\n\n- [ ] No `List\u003cT>` or `Dictionary\u003cT,K>` usage\n- [ ] No `interface` declarations\n- [ ] No method overloading (all methods have unique names)\n- [ ] No `try`/`catch` blocks\n- [ ] No `async`/`await` or `yield return`\n- [ ] No LINQ queries (`.Where()`, `.Select()`, etc.)\n- [ ] No lambda expressions (`=>` in variable context)\n- [ ] No `System.IO` or `System.Net` usage\n- [ ] All recursive methods have `[RecursiveMethod]` attribute\n- [ ] Struct methods: using return values, not relying on in-place mutation\n- [ ] Unity callbacks (OnTriggerEnter, etc.) do not have `override`\n- [ ] VRChat event callbacks (OnPlayerJoined, etc.) do have `override`\n- [ ] `Button.onClick` not used; events configured in Inspector\n\n## Advanced Constraint Workarounds\n\n### Object Array Pseudo-Struct (Multi-Field State Container)\n\nUdonSharp lacks custom constructors, user-defined structs, and generics. When you need a reusable data object\nthat holds multiple typed fields per slot (e.g., per-pointer touch state, per-player session data), there is no\ndirect equivalent of a C# struct or class constructor.\n\n> **Cross-reference**: The full pseudo-struct pattern with additional usage examples is also documented in\n> [patterns-utilities.md](patterns-utilities.md).\n\n**Workaround**: Pack multiple typed arrays into an `object[]`, then cast the whole array to an `UdonSharpBehaviour`\ntype. This exploits the fact that UdonVM stores UdonSharpBehaviour references as plain objects at runtime, allowing\nthe cast chain `(MyType)(object)objectArray` to succeed. Each index in the `object[]` represents a \"field\" of the\npseudo-struct, and each element is a typed array where the array index represents the slot (instance).\n\n**Why this works**: UdonVM does not perform strict type checking on `object` casts at the level that standard CLR\ndoes. The `UdonSharpBehaviour` type reference becomes a handle to the underlying `object[]`, which can be cast back\nto access the typed arrays inside.\n\n**Complete Example -- Multi-Pointer Touch State Container:**\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\nusing VRC.SDKBase;\n\n// The \"struct\" type -- the class body contains only the factory method.\n// No fields are declared here; all state lives in the object[] created by New().\n[AddComponentMenu(\"\")]\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class PointerState : UdonSharpBehaviour\n{\n /// \u003csummary>\n /// Creates a new pseudo-struct with per-slot arrays for the given capacity.\n /// Each slot represents one pointer/finger that can interact simultaneously.\n /// \u003c/summary>\n public static PointerState New(int slotCount)\n {\n // Field 0: the UI element each pointer is currently touching\n Graphic[] activeGraphics = new Graphic[slotCount];\n // Field 1: world-space position where the pointer first made contact\n Vector3[] startPositions = new Vector3[slotCount];\n // Field 2: cumulative drag distance for each pointer\n float[] dragDistances = new float[slotCount];\n // Field 3: whether each slot is currently active\n bool[] isActive = new bool[slotCount];\n\n object[] buffer = new object[]\n {\n activeGraphics, // index 0\n startPositions, // index 1\n dragDistances, // index 2\n isActive // index 3\n };\n\n // Cast the object[] to the UdonSharpBehaviour type.\n // This is the key trick: UdonVM allows this reinterpret cast.\n return (PointerState)(object)buffer;\n }\n}\n\n// Typed accessors are defined as static methods in a separate UdonSharpBehaviour.\n// UdonSharp does not support static classes or extension methods (this T syntax),\n// so plain static methods with an explicit first parameter are used instead.\n[AddComponentMenu(\"\")]\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class PointerStateExt : UdonSharpBehaviour\n{\n // --- Field 0: ActiveGraphic ---\n\n public static Graphic GetActiveGraphic(PointerState self, int slot)\n {\n return ((Graphic[])((object[])(object)self)[0])[slot];\n }\n\n public static void SetActiveGraphic(PointerState self, int slot, Graphic graphic)\n {\n ((Graphic[])((object[])(object)self)[0])[slot] = graphic;\n }\n\n // --- Field 1: StartPosition ---\n\n public static Vector3 GetStartPosition(PointerState self, int slot)\n {\n return ((Vector3[])((object[])(object)self)[1])[slot];\n }\n\n public static void SetStartPosition(PointerState self, int slot, Vector3 position)\n {\n ((Vector3[])((object[])(object)self)[1])[slot] = position;\n }\n\n // --- Field 2: DragDistance ---\n\n public static float GetDragDistance(PointerState self, int slot)\n {\n return ((float[])((object[])(object)self)[2])[slot];\n }\n\n public static void SetDragDistance(PointerState self, int slot, float distance)\n {\n ((float[])((object[])(object)self)[2])[slot] = distance;\n }\n\n // --- Field 3: IsActive ---\n\n public static bool GetIsActive(PointerState self, int slot)\n {\n return ((bool[])((object[])(object)self)[3])[slot];\n }\n\n public static void SetIsActive(PointerState self, int slot, bool active)\n {\n ((bool[])((object[])(object)self)[3])[slot] = active;\n }\n\n // --- Lifecycle Methods ---\n\n /// \u003csummary>\n /// Initializes a slot when a pointer begins interaction.\n /// \u003c/summary>\n public static void InitSlot(PointerState self, int slot, Graphic graphic, Vector3 worldPosition)\n {\n ClearSlot(self, slot);\n SetActiveGraphic(self, slot, graphic);\n SetStartPosition(self, slot, worldPosition);\n SetDragDistance(self, slot, 0f);\n SetIsActive(self, slot, true);\n }\n\n /// \u003csummary>\n /// Resets all fields for a slot to default values.\n /// \u003c/summary>\n public static void ClearSlot(PointerState self, int slot)\n {\n SetActiveGraphic(self, slot, null);\n SetStartPosition(self, slot, Vector3.zero);\n SetDragDistance(self, slot, 0f);\n SetIsActive(self, slot, false);\n }\n}\n\n// Usage in a manager script\npublic class InputManager : UdonSharpBehaviour\n{\n private PointerState _pointerState;\n private const int MaxPointers = 16;\n\n void Start()\n {\n // Create the pseudo-struct with capacity for 16 simultaneous pointers\n _pointerState = PointerState.New(MaxPointers);\n }\n\n public void OnPointerDown(int pointerIndex, Graphic hitGraphic, Vector3 worldPos)\n {\n if (pointerIndex \u003c 0 || pointerIndex >= MaxPointers) return;\n PointerStateExt.InitSlot(_pointerState, pointerIndex, hitGraphic, worldPos);\n }\n\n public void OnPointerUp(int pointerIndex)\n {\n if (pointerIndex \u003c 0 || pointerIndex >= MaxPointers) return;\n if (!PointerStateExt.GetIsActive(_pointerState, pointerIndex)) return;\n\n float totalDrag = PointerStateExt.GetDragDistance(_pointerState, pointerIndex);\n Debug.Log($\"[InputManager] Pointer {pointerIndex} released after {totalDrag:F2} units of drag\");\n\n PointerStateExt.ClearSlot(_pointerState, pointerIndex);\n }\n\n public void OnPointerMove(int pointerIndex, Vector3 currentWorldPos)\n {\n if (pointerIndex \u003c 0 || pointerIndex >= MaxPointers) return;\n if (!PointerStateExt.GetIsActive(_pointerState, pointerIndex)) return;\n\n Vector3 startPos = PointerStateExt.GetStartPosition(_pointerState, pointerIndex);\n float distance = Vector3.Distance(startPos, currentWorldPos);\n PointerStateExt.SetDragDistance(_pointerState, pointerIndex, distance);\n }\n}\n```\n\n**Pattern Summary:**\n\n| Element | Purpose |\n|---------|---------|\n| `UdonSharpBehaviour` subclass | Type identity for the pseudo-struct; holds only the `New()` factory |\n| `New(int count)` static method | Factory that creates the `object[]` and casts it to the type |\n| `object[]` buffer | Holds one typed array per \"field\" at each index |\n| `(T)(object)buffer` cast | Reinterprets the `object[]` as the UdonSharpBehaviour type |\n| Static methods in `PointerStateExt` | Provide typed get/set accessors with the reverse cast chain |\n| `InitSlot` / `ClearSlot` | Lifecycle methods to set up and tear down per-slot state |\n\n**Caveats:**\n\n- **Cast chain performance**: Each accessor performs `(object) -> object[] -> T[] -> element`. This involves\n multiple unboxing steps per access. Avoid calling accessors in tight per-frame loops over large arrays.\n If performance is critical, cache the inner typed array locally:\n ```csharp\n // Cache for hot-path iteration\n float[] distances = (float[])((object[])(object)_pointerState)[2];\n for (int i = 0; i \u003c MaxPointers; i++)\n {\n if (distances[i] > threshold) { /* ... */ }\n }\n ```\n- **Debugging difficulty**: The pseudo-struct does not appear in the Unity Inspector. You cannot inspect field\n values through the normal UdonSharp variable display. Add explicit `Debug.Log` calls during development.\n- **No compile-time safety on field indices**: Using integer indices (`[0]`, `[1]`, etc.) for field access is\n error-prone. Keep the mapping documented in the `New()` method comments and never access the `object[]`\n directly outside the static accessor methods.\n- **Not serializable**: The pseudo-struct cannot be saved to the scene or synced over the network. It is\n purely a runtime data structure.\n\n---\n\n### VRCUrl Array Sync Workaround\n\n`VRCUrl[]` arrays cannot be marked with `[UdonSynced]`. UdonSharp's sync system only supports syncing individual\n`VRCUrl` fields, not arrays of them. This is a known limitation of the Udon serialization layer -- the sync\nsystem does not handle `VRCUrl` as a syncable array element type.\n\n**Workaround**: Declare each URL as a separate `[UdonSynced]` field (`SyncedUrl_0` through `SyncedUrl_N`), then\nuse `switch` statements to map a runtime index to the correct field for reading and writing. Metadata (sender\nname, timestamp, content type, etc.) can be synced as a single JSON string via `VRCJson` serialization.\n\n**Complete Example -- Synced URL List with Metadata:**\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Data;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class SyncedUrlList : UdonSharpBehaviour\n{\n private const int MaxUrls = 8;\n\n // Each VRCUrl must be synced individually -- arrays are not supported.\n [UdonSynced] private VRCUrl SyncedUrl_0;\n [UdonSynced] private VRCUrl SyncedUrl_1;\n [UdonSynced] private VRCUrl SyncedUrl_2;\n [UdonSynced] private VRCUrl SyncedUrl_3;\n [UdonSynced] private VRCUrl SyncedUrl_4;\n [UdonSynced] private VRCUrl SyncedUrl_5;\n [UdonSynced] private VRCUrl SyncedUrl_6;\n [UdonSynced] private VRCUrl SyncedUrl_7;\n\n // Metadata for all URLs synced as a single JSON string.\n // Format: [[timestamp, typeId, \"senderName\"], ...]\n [UdonSynced] private string SyncedMetadataJson = \"[]\";\n\n // Local cache of parsed metadata\n private DataList _metadataList;\n\n // Pending operation state (used when ownership transfer is in progress)\n private bool _pendingAdd = false;\n private VRCUrl _pendingAddUrl;\n private int _pendingAddType;\n private bool _pendingRemove = false;\n private int _pendingRemoveIndex;\n\n // --- Index-to-Field Accessor (Set) ---\n\n private void SetUrlAtIndex(int index, VRCUrl url)\n {\n switch (index)\n {\n case 0: SyncedUrl_0 = url; break;\n case 1: SyncedUrl_1 = url; break;\n case 2: SyncedUrl_2 = url; break;\n case 3: SyncedUrl_3 = url; break;\n case 4: SyncedUrl_4 = url; break;\n case 5: SyncedUrl_5 = url; break;\n case 6: SyncedUrl_6 = url; break;\n case 7: SyncedUrl_7 = url; break;\n default:\n Debug.LogWarning($\"[SyncedUrlList] Index {index} out of range (max {MaxUrls - 1})\");\n break;\n }\n }\n\n // --- Index-to-Field Accessor (Get) ---\n\n private VRCUrl GetUrlAtIndex(int index)\n {\n switch (index)\n {\n case 0: return SyncedUrl_0;\n case 1: return SyncedUrl_1;\n case 2: return SyncedUrl_2;\n case 3: return SyncedUrl_3;\n case 4: return SyncedUrl_4;\n case 5: return SyncedUrl_5;\n case 6: return SyncedUrl_6;\n case 7: return SyncedUrl_7;\n default: return VRCUrl.Empty;\n }\n }\n\n // --- Public API ---\n\n /// \u003csummary>\n /// Adds a URL with metadata. Takes ownership, updates synced state, and requests serialization.\n /// \u003c/summary>\n public bool AddUrl(VRCUrl url, int contentType)\n {\n if (!ParseMetadata()) return false;\n\n if (_metadataList.Count >= MaxUrls)\n {\n Debug.LogWarning(\"[SyncedUrlList] URL list is full\");\n return false;\n }\n\n if (!Networking.IsOwner(gameObject))\n {\n // Defer until ownership is confirmed\n _pendingAdd = true;\n _pendingAddUrl = url;\n _pendingAddType = contentType;\n _pendingRemove = false;\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n return true;\n }\n\n ExecuteAddUrl(url, contentType);\n return true;\n }\n\n private void ExecuteAddUrl(VRCUrl url, int contentType)\n {\n if (!ParseMetadata()) return;\n if (_metadataList.Count >= MaxUrls) return;\n\n // Build metadata entry: [timestamp, contentType, senderName]\n long timestamp = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n string senderName = Networking.LocalPlayer.displayName;\n\n DataList entry = new DataList();\n entry.Add(timestamp);\n entry.Add(contentType);\n entry.Add(senderName);\n\n _metadataList.Add(entry);\n\n // Store URL at the new index\n int newIndex = _metadataList.Count - 1;\n SetUrlAtIndex(newIndex, url);\n\n // Serialize metadata to JSON\n if (!SerializeMetadata()) return;\n\n RequestSerialization();\n }\n\n /// \u003csummary>\n /// Removes a URL by index. Shifts subsequent URLs down to fill the gap.\n /// \u003c/summary>\n public bool RemoveUrl(int removeIndex)\n {\n if (!ParseMetadata()) return false;\n if (removeIndex \u003c 0 || removeIndex >= _metadataList.Count) return false;\n\n if (!Networking.IsOwner(gameObject))\n {\n // Defer until ownership is confirmed\n _pendingRemove = true;\n _pendingRemoveIndex = removeIndex;\n _pendingAdd = false;\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n return true;\n }\n\n ExecuteRemoveUrl(removeIndex);\n return true;\n }\n\n private void ExecuteRemoveUrl(int removeIndex)\n {\n if (!ParseMetadata()) return;\n if (removeIndex \u003c 0 || removeIndex >= _metadataList.Count) return;\n\n _metadataList.RemoveAt(removeIndex);\n\n // Shift URL fields down to fill the gap\n for (int i = removeIndex; i \u003c _metadataList.Count; i++)\n {\n SetUrlAtIndex(i, GetUrlAtIndex(i + 1));\n }\n // Clear the last slot\n SetUrlAtIndex(_metadataList.Count, VRCUrl.Empty);\n\n if (!SerializeMetadata()) return;\n\n RequestSerialization();\n }\n\n public override void OnOwnershipTransferred(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n if (_pendingAdd)\n {\n _pendingAdd = false;\n ExecuteAddUrl(_pendingAddUrl, _pendingAddType);\n }\n else if (_pendingRemove)\n {\n _pendingRemove = false;\n ExecuteRemoveUrl(_pendingRemoveIndex);\n }\n }\n\n /// \u003csummary>\n /// Returns the VRCUrl at the given index.\n /// \u003c/summary>\n public VRCUrl GetUrl(int index)\n {\n return GetUrlAtIndex(index);\n }\n\n /// \u003csummary>\n /// Returns the current number of stored URLs.\n /// \u003c/summary>\n public int GetCount()\n {\n if (!ParseMetadata()) return 0;\n return _metadataList.Count;\n }\n\n // --- Deserialization (receiving sync from owner) ---\n\n public override void OnDeserialization()\n {\n if (!ParseMetadata())\n {\n Debug.LogWarning(\"[SyncedUrlList] Failed to parse metadata on deserialization\");\n return;\n }\n\n // URLs are automatically synced via their individual [UdonSynced] fields.\n // Metadata is parsed from the JSON string above.\n // Notify listeners or update UI here.\n Debug.Log($\"[SyncedUrlList] Received {_metadataList.Count} URLs\");\n }\n\n // --- Internal Helpers ---\n\n private bool ParseMetadata()\n {\n if (VRCJson.TryDeserializeFromJson(SyncedMetadataJson, out DataToken token))\n {\n _metadataList = token.DataList;\n return true;\n }\n Debug.LogError(\"[SyncedUrlList] Failed to deserialize metadata JSON\");\n return false;\n }\n\n private bool SerializeMetadata()\n {\n if (VRCJson.TrySerializeToJson(_metadataList, JsonExportType.Minify, out DataToken token))\n {\n SyncedMetadataJson = token.String;\n return true;\n }\n Debug.LogError(\"[SyncedUrlList] Failed to serialize metadata JSON\");\n return false;\n }\n}\n```\n\n**Pattern Summary:**\n\n| Element | Purpose |\n|---------|---------|\n| Individual `[UdonSynced] VRCUrl` fields | Each URL synced as its own field (array sync not supported) |\n| `switch`-based get/set methods | Maps runtime index to the correct field |\n| `[UdonSynced] string` for metadata | JSON-encoded array of `[timestamp, type, sender]` entries |\n| `VRCJson.TrySerializeToJson` / `TryDeserializeFromJson` | Serialization of structured metadata into a single synced string |\n| `OnDeserialization` | Handles incoming sync data on non-owner clients |\n| Pending-operation + `OnOwnershipTransferred` | Carries multi-step deferred operation parameters (Add vs Remove) from the request site to the callback. |\n\n> *Note: `Networking.SetOwner` is locally immediate (post-2021.2.2), so this pattern is not required for ownership timing — it survives because it cleanly carries the operation context from the request site into the callback. See [networking-antipatterns.md §1](networking-antipatterns.md#1-ownership-race-condition) for the immediate-after-SetOwner alternative when no parameter passing is needed.*\n\n**Bandwidth and Performance Considerations:**\n\n- **Each `[UdonSynced]` field contributes to sync payload size.** VRCUrl fields are serialized as strings\n (the full URL text). With Manual sync mode, all synced fields are sent together in one `RequestSerialization`\n call, even if only one URL changed.\n- **Recommended capacity: 8-16 fields** for most use cases. Going beyond 16 significantly increases the per-sync\n payload. At 64 fields, the sync payload can become large enough to cause noticeable latency, especially in\n worlds with many synced objects.\n- **Metadata as JSON is bandwidth-efficient**: A single `[UdonSynced] string` holding structured JSON is far\n cheaper than syncing individual metadata fields (sender, timestamp, type) per URL slot.\n- **Deletion requires shifting**: When removing a URL from the middle, all subsequent URL fields must be\n reassigned (shifted down). This is an O(n) operation on synced fields. For frequently modified lists, consider\n using a \"soft delete\" flag in the metadata instead of physically shifting.\n- **Late-joiner sync**: All `[UdonSynced]` fields are automatically sent to late joiners. No special handling\n is needed beyond calling `RequestSerialization()` in `OnPlayerJoined` if the owner needs to push current state.\n\n---\n\n## See Also\n\n- [api.md](api.md) - VRChat-specific types and VRC Constraint API available in UdonSharp\n- [troubleshooting.md](troubleshooting.md) - Common compile and runtime errors with UdonSharp constraints\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":37315,"content_sha256":"209cc00b456058bc9c35a4350acf719e5e25760c915f1b332b5afe3b7a39dad6"},{"filename":"references/context-preservation.md","content":"# Context Preservation for UdonSharp Agent Work\n\nLightweight guide for keeping task-specific design intent available across long or resumed UdonSharp work.\n\n**Applies to:** all supported SDK versions\n\nThis guide is optional and intended for complex work only. Use the format as a reusable note inside an existing task note, issue comment, scratchpad, or other user-approved location; it is not needed for trivial edits, and it does not ask for a new project file.\n\n## Table of Contents\n\n- [Why this exists](#why-this-exists)\n- [Rule loss vs design-context loss](#rule-loss-vs-design-context-loss)\n- [When to use a context-preservation note](#when-to-use-a-context-preservation-note)\n- [When not to use one](#when-not-to-use-one)\n- [Minimal task context note](#minimal-task-context-note)\n- [Concrete example](#concrete-example)\n- [Privacy notes](#privacy-notes)\n- [Resume checklist after compaction](#resume-checklist-after-compaction)\n- [See Also](#see-also)\n\n---\n\n## Why this exists\n\nThe reusable UdonSharp constraints already live in rules, references, cheatsheets, templates, and validation hooks. Those files preserve shared knowledge: which APIs are available, how ownership works, what late joiners receive, and how to test networking behavior.\n\nLong agent tasks can lose a different kind of context: why this particular design was chosen. A later pass may remember that networking matters, yet miss that this door state is owner-written, restored from a synced variable, and intentionally not replayed from an event. The note below preserves that task-specific reasoning so resumed work does not rely only on conversation memory.\n\nThe goal is small and practical. Capture the few decisions that would be expensive to rediscover: source of truth, event transport, sync mode, storage, ownership behavior, late joiner behavior, and validation status.\n\n---\n\n## Rule loss vs design-context loss\n\n### Rule loss\n\nRule loss happens when an agent forgets reusable UdonSharp facts already documented elsewhere.\n\nExamples:\n\n- Treating a non-owner write to a synced field as authoritative.\n- Calling `RequestSerialization()` for Continuous sync, where automatic transmission is already expected.\n- Reading PlayerData or PlayerObject values before `OnPlayerRestored` has fired.\n- Expecting a network event to rebuild state for a player who joined after the event.\n\nUse the existing references for this class of problem. This note format is not a replacement for `networking.md`, `persistence.md`, or `testing.md`.\n\n### Design-context loss\n\nDesign-context loss happens when the reusable rules are remembered, but the task-specific reason is gone.\n\nExamples:\n\n- A synced field exists, but the next pass cannot tell whether it is the source of truth or a transport copy.\n- A non-owner request path exists, but the ownership-transfer trigger is unclear.\n- A PlayerObject is involved, but the note does not say which reads wait for `OnPlayerRestored`.\n- A late-joiner bug was fixed, but the validation that proved it is not recorded.\n\nUse a context-preservation note to keep this local reasoning visible during complex work.\n\n---\n\n## When to use a context-preservation note\n\nConsider a note for work that includes any of these traits:\n\n- New synced systems or changes to existing synced state.\n- Ownership-sensitive interactions, late-joiner-sensitive behavior, or PlayerData/PlayerObject persistence.\n- Complex multi-file refactors where the design reason is easy to lose.\n- Work resumed after context compaction, restart, handoff, or split across multiple agent runs.\n\n---\n\n## When not to use one\n\nSkip the note for work where the design context is obvious from the diff:\n\n- Typo fixes.\n- Small documentation edits or formatting-only changes.\n- One-file mechanical updates or metadata-only maintenance.\n\n---\n\n## Minimal task context note\n\nThis is a lightweight prompt, not a heavy planning framework. Keep only the fields that help the next pass continue safely.\n\n### Goal\n\n- Behavior being added, fixed, or preserved.\n- Existing behavior that should remain unchanged.\n\n### Relevant files\n\n- Edited files, reference files, validation files, scenes, or assets.\n\n### UdonSharp constraints relevant to this task\n\n- Constraints that shaped this task: ownership writes, sync mode, restore timing, unsupported APIs, or unsupported language features.\n\n### Source of truth\n\nChoose the authoritative state holder for this task:\n\n- Local-only state, scene object owner, instance master, or per-player owner.\n- PlayerObject state with automatic per-player ownership, PlayerData storage, or derived state.\n- Hybrid model; name the authoritative field or object for each part.\n\nWhy this was chosen:\n\n- Record the reason this source is authoritative, and note which state is only cached or derived locally.\n\n### Transport (events)\n\nChoose how one-shot messages are transported, if any:\n\n- No network event, or local-only `SendCustomEvent`.\n- `SendCustomNetworkEvent`, `[NetworkCallable]`, or a hybrid event path.\n\nWhy this was chosen:\n\n- Record whether the event is a request, notification, effect trigger, or convenience call.\n- Capture that network events are not replayed to late joiners; persistent state should be restored from synced or stored state instead.\n\n### Sync mode (Manual vs Continuous)\n\nChoose how state replication works, if any:\n\n- No synced variables.\n- `[UdonSynced]` with Manual sync, Continuous sync, or a mixed split.\n- Synced transport copy with local derived presentation state.\n\nWhy this was chosen:\n\n- For Manual sync, record where `RequestSerialization()` is called after changed synced fields.\n- For Continuous sync, record why automatic transmission fits and why `RequestSerialization()` is not part of the path.\n- Capture that synced variables are automatically sent to late joiners.\n\n### Storage & persistence\n\nChoose whether state persists beyond the current instance:\n\n- No persistence, synced state only for the live instance, PlayerData, or PlayerObject.\n- Hybrid storage; list which fields persist and which reset.\n\nWhy this was chosen:\n\n- Record the lifecycle: per-session, per-player, per-object, or durable player preference.\n- For PlayerObject, note that ownership is auto-assigned and `Networking.SetOwner()` is not needed for the PlayerObject itself.\n- For PlayerData or PlayerObject reads, record which code waits for `OnPlayerRestored`.\n\n### Ownership model\n\n- Record the initial owner, ownership transfer trigger, non-owner request path, owner-left behavior, and concurrent interaction behavior.\n\nCapture owner-left handling as an observable design choice. Ownership transfers automatically; if state needs to be re-broadcast or presentation needs to be re-applied, record the `OnOwnershipTransferred` follow-up.\n\n### Late joiner behavior\n\n- Record what the late joiner should see, which synced fields or stored values restore that state, which event-only effects are intentionally not replayed, and which callback applies the received state locally.\n\n### Synced fields & bandwidth\n\n- List synced fields, derived-locally fields, intentionally-not-synced fields, and throttling, batching, or packing decisions.\n\n### Decisions & rejected alternatives\n\n- Record the decision made, the alternative considered, and the reason the alternative was left out for this task.\n\n### Validation\n\n- Repo checks run and validation hooks triggered.\n- Unity Editor, ClientSim, Build & Test multi-client, late-joiner, and owner-left checks planned or completed.\n\n---\n\n## Concrete example\n\n```text\nTask context note: shared door controller\n\nGoal:\n- Preserve open/closed state for all players.\n\nRelevant files:\n- DoorController.cs\n- references/networking.md\n- references/testing.md\n\nSource of truth:\n- Door object's synced bool.\n- Why: late joiners need open/closed state without replaying past events.\n\nTransport (events):\n- SendCustomNetworkEvent only for the click sound.\n- Why: sound is momentary and does not need late-joiner replay.\n\nSync mode (Manual vs Continuous):\n- Manual sync for the open/closed bool.\n- Why: value changes only on interaction; owner writes the bool and requests serialization.\n\nStorage & persistence:\n- No PlayerData or PlayerObject storage.\n- Why: the door resets between instances.\n\nOwnership model:\n- Default scene owner initially; non-owner interaction requests ownership before changing the bool.\n- OnOwnershipTransferred reapplies the current bool and re-serializes if this client became owner after a handoff.\n\nLate joiner behavior:\n- Late joiners receive the synced bool and apply the animator state from it.\n\nValidation:\n- Multi-client interaction test.\n- Late-joiner test after opening and closing.\n- Owner-left test while open.\n```\n\n---\n\n## Privacy notes\n\nKeep the note focused on decisions and evidence. Avoid storing secrets, API keys, tokens, private URLs, private client information, raw private email contents, full conversation transcripts, or unnecessary personal data.\n\nSummarize the relevant decision, file, test, or observed behavior instead.\n\n---\n\n## Resume checklist after compaction\n\n1. Re-read the current user instruction or task issue.\n2. Re-read the relevant UdonSharp rules for the edited file type.\n3. Read the existing context-preservation note.\n4. Re-open every file named in the note's Relevant files section.\n5. Confirm the recorded source of truth and transport path against the current code.\n6. Confirm whether any event-only behavior affects late joiners.\n7. Confirm the sync mode and the current `RequestSerialization()` or Continuous-sync path.\n8. Confirm PlayerData or PlayerObject reads wait for `OnPlayerRestored` when persistence is involved.\n9. Confirm ownership transfer and owner-left behavior in the current code.\n10. Confirm late-joiner restoration from synced or stored state.\n11. Confirm the validation status by reading the most recent command output or running the next listed check.\n12. Update the note when a design decision changes.\n\n---\n\n## See Also\n\n- [networking.md](networking.md) - Ownership, network events, sync modes, late joiners, and owner-left behavior\n- [persistence.md](persistence.md) - PlayerData and PlayerObject restore timing\n- [testing.md](testing.md) - ClientSim, Build & Test, multi-client, late-joiner, and owner-left validation\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10238,"content_sha256":"f8785197aab513ffd1971b73bbfeff839670afc91a3e9bd596ffac67f70b73c5"},{"filename":"references/dynamics.md","content":"# VRChat Dynamics for Worlds Reference\n\nComprehensive guide to PhysBones, Contacts, and VRC Constraints in VRChat worlds (SDK 3.10.0+).\n\n**Supported SDK Versions**: 3.10.0+ (2025 onward)\n\n## Overview\n\nSDK 3.10.0 made **VRChat Dynamics** available in worlds:\n\n| Component | Purpose | Use Cases |\n|-----------|---------|-----------|\n| **PhysBones** | Physics-based bone animation | Ropes, chains, flags, interactive objects |\n| **Contacts** | Collision detection system | Buttons, triggers, touch interaction |\n| **VRC Constraints** | Constraint system | Rigging, following, look-at |\n\n## Contacts\n\n### Basic Concept\n\nContacts provide a collision detection system between **Senders** and **Receivers**:\n\n- **Contact Sender**: Emits contact signals (like a finger or projectile)\n- **Contact Receiver**: Detects contact signals and triggers Udon events\n\n### Contacts Setup\n\n#### Contact Sender\n\n1. Add `VRC Contact Sender` component to a GameObject\n2. Choose `Shape Type`: `Sphere` or `Capsule`\n3. Configure `Radius` (and `Height` for Capsule)\n4. Set `Content Type` (identifies what kind of contact this is)\n\n```text\nVRC Contact Sender (Sphere)\n├── Shape Type: Sphere\n├── Radius: 0.02 (finger-sized; max 3 m)\n└── Content Type: \"Finger\"\n\nVRC Contact Sender (Capsule)\n├── Shape Type: Capsule\n├── Radius: 0.05 (max 3 m)\n├── Height: 0.3 (Y-axis, half-spheres included; max 6 m)\n└── Content Type: \"Custom\"\n```\n\n**Shape comparison:**\n\n| Shape | Fields | Typical use |\n|-------|--------|-------------|\n| `Sphere` | `Radius` | Point contacts (fingers, small projectiles) |\n| `Capsule` | `Radius` + `Height` | Elongated contacts (arms, props, area triggers) |\n\n#### Contact Receiver\n\n1. Add `VRC Contact Receiver` component to a GameObject\n2. Add UdonBehaviour to the **same** GameObject\n3. Choose `Shape Type` and configure its dimensions\n4. Configure allowed content types\n5. Implement contact events in Udon\n\n```text\nVRC Contact Receiver (Sphere)\n├── Shape Type: Sphere\n├── Radius: 0.05 (max 3 m)\n├── Allow Self: true (contacts from same avatar)\n├── Allow Others: true (contacts from other avatars)\n└── Content Types: [\"Finger\", \"Hand\"]\n\nVRC Contact Receiver (Capsule)\n├── Shape Type: Capsule\n├── Radius: 0.05 (max 3 m)\n├── Height: 0.5 (Y-axis, half-spheres included; max 6 m)\n├── Allow Self: true\n├── Allow Others: true\n└── Content Types: [\"Hand\"]\n```\n\n### Contact Events\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\npublic class ContactButton : UdonSharpBehaviour\n{\n public AudioSource clickSound;\n public Material normalMaterial;\n public Material pressedMaterial;\n public Renderer buttonRenderer;\n\n private bool isPressed = false;\n\n public override void OnContactEnter(ContactEnterInfo info)\n {\n if (isPressed) return;\n\n isPressed = true;\n buttonRenderer.material = pressedMaterial;\n clickSound.Play();\n\n // Check if from avatar or world object\n if (info.isAvatar)\n {\n Debug.Log($\"Pressed by: {info.player?.displayName}\");\n }\n else\n {\n Debug.Log(\"Pressed by world object\");\n }\n\n // Perform button action\n OnButtonPressed();\n }\n\n public override void OnContactStay(ContactStayInfo info)\n {\n // Called every frame while contact is maintained\n }\n\n public override void OnContactExit(ContactExitInfo info)\n {\n isPressed = false;\n buttonRenderer.material = normalMaterial;\n }\n\n private void OnButtonPressed()\n {\n // Your button logic here\n }\n}\n```\n\n### ContactEnterInfo Struct\n\n```csharp\npublic struct ContactEnterInfo\n{\n public string senderName; // Name of the Contact Sender\n public bool isAvatar; // True if from avatar, false if from world\n public VRCPlayerApi player; // Player reference (only valid if isAvatar)\n public Vector3 position; // World position of contact point\n public Vector3 normal; // Contact normal direction\n}\n```\n\n### Dynamic Content Types\n\n> **Breaking Change (SDK 3.10.1)**: The signature of `VRCContactReceiver.UpdateContentTypes()` changed from `IEnumerable\u003cstring>` to **`string[]`**. If you were passing `List\u003cstring>` directly, you would need `.ToArray()`, but since `List\u003cT>` is not available in UdonSharp, the correct pattern is to pass `string[]` directly as shown below.\n\n```csharp\npublic class DynamicReceiver : UdonSharpBehaviour\n{\n private VRCContactReceiver receiver;\n\n void Start()\n {\n receiver = GetComponent\u003cVRCContactReceiver>();\n }\n\n public void EnableHandsOnly()\n {\n // Only respond to hand contacts\n string[] types = new string[] { \"Hand\", \"Finger\" };\n receiver.UpdateContentTypes(types); // Pass string[] directly\n }\n\n public void EnableAll()\n {\n // Respond to any contact\n string[] types = new string[] { \"Hand\", \"Finger\", \"Head\", \"Foot\", \"Custom\" };\n receiver.UpdateContentTypes(types);\n }\n}\n```\n\n### Contact Sender Control from Udon\n\n```csharp\npublic class ProjectileContact : UdonSharpBehaviour\n{\n private VRCContactSender sender;\n\n void Start()\n {\n sender = GetComponent\u003cVRCContactSender>();\n }\n\n public void Launch()\n {\n // The contact sender will trigger OnContactEnter\n // on any receiver it collides with\n GetComponent\u003cRigidbody>().AddForce(transform.forward * 10f, ForceMode.Impulse);\n }\n}\n```\n\n## PhysBones\n\n### Basic Concept\n\nPhysBones provide physics-based bone animation:\n\n- Simulate gravity, wind, and inertia on bones\n- Support **grabbing** interaction\n- Can be used for ropes, chains, hair, cloth\n\n### PhysBones Setup\n\n1. Add `VRC Phys Bone` component to a root bone\n2. Configure physics parameters\n3. Add UdonBehaviour for grab events (optional)\n\n```text\nVRC Phys Bone\n├── Root Transform: RopeStart\n├── End Bone: (auto-detected or manual)\n├── Integration Type: Simplified\n├── Pull: 0.2\n├── Spring: 0.8\n├── Gravity: 0.5\n└── Grab Movement: 1.0\n```\n\n### PhysBone Events\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\npublic class GrabbableRope : UdonSharpBehaviour\n{\n public AudioSource grabSound;\n public AudioSource releaseSound;\n\n private VRCPlayerApi currentGrabber;\n\n public override void OnPhysBoneGrab(PhysBoneGrabInfo info)\n {\n currentGrabber = info.player;\n grabSound.Play();\n\n if (info.player != null)\n {\n Debug.Log($\"Rope grabbed by: {info.player.displayName}\");\n }\n }\n\n public override void OnPhysBoneRelease(PhysBoneReleaseInfo info)\n {\n currentGrabber = null;\n releaseSound.Play();\n\n Debug.Log(\"Rope released\");\n }\n\n public bool IsGrabbed()\n {\n VRCPhysBone physBone = GetComponent\u003cVRCPhysBone>();\n return physBone.IsGrabbed();\n }\n\n public VRCPlayerApi GetGrabber()\n {\n return currentGrabber;\n }\n}\n```\n\n### PhysBone API\n\n```csharp\nVRCPhysBone physBone = GetComponent\u003cVRCPhysBone>();\n\n// Check if grabbed\nbool grabbed = physBone.IsGrabbed();\n\n// Get grabbing player\nVRCPlayerApi grabber = physBone.GetGrabbingPlayer();\n\n// Get affected transforms\nTransform[] bones = physBone.GetAffectedTransforms();\n\n// Force release (SDK 3.10.0+)\nphysBone.ForceReleaseGrab(); // Force release the grab\nphysBone.ForceReleasePose(); // Force release the pose (reset bent PhysBone)\n```\n\n### PhysBone Udon Callbacks (Collider Events)\n\nIn addition to grab/release, VRC PhysBone fires Udon callbacks when **PhysBone Colliders** interact with the bone chain. These three events are raised on any UdonBehaviour attached to the **same GameObject as the VRC Phys Bone** component.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPhysBoneColliderEnter(PhysBoneColliderInfo info)` | A PhysBone collider starts intersecting the bone chain |\n| `void OnPhysBoneColliderStay(PhysBoneColliderInfo info)` | A PhysBone collider continues to intersect the bone chain |\n| `void OnPhysBoneColliderExit(PhysBoneColliderInfo info)` | A PhysBone collider stops intersecting the bone chain |\n\n#### PhysBoneColliderInfo Struct\n\n```csharp\npublic struct PhysBoneColliderInfo\n{\n public VRCPhysBoneCollider collider; // The collider that intersected the bone chain\n public bool isAvatar; // True if the collider belongs to an avatar\n public VRCPlayerApi player; // Player reference (only valid if isAvatar is true)\n public Transform bone; // The specific bone transform that was hit\n}\n```\n\n#### Callback Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\npublic class PhysBoneColliderReactor : UdonSharpBehaviour\n{\n public AudioSource touchSound;\n public ParticleSystem touchEffect;\n\n private int activeColliderCount = 0;\n\n public override void OnPhysBoneColliderEnter(PhysBoneColliderInfo info)\n {\n activeColliderCount++;\n\n if (info.isAvatar && info.player != null)\n {\n Debug.Log($\"PhysBone touched by avatar: {info.player.displayName}, bone: {info.bone?.name}\");\n }\n else\n {\n Debug.Log($\"PhysBone touched by world collider, bone: {info.bone?.name}\");\n }\n\n if (activeColliderCount == 1)\n {\n // First contact — play feedback\n touchSound.Play();\n touchEffect.Play();\n }\n }\n\n public override void OnPhysBoneColliderStay(PhysBoneColliderInfo info)\n {\n // Called every frame while the collider continues to intersect.\n // Avoid heavy per-frame logic here; use OnPhysBoneColliderEnter\n // and OnPhysBoneColliderExit for state changes instead.\n }\n\n public override void OnPhysBoneColliderExit(PhysBoneColliderInfo info)\n {\n activeColliderCount--;\n if (activeColliderCount \u003c 0) activeColliderCount = 0;\n\n if (activeColliderCount == 0)\n {\n touchEffect.Stop();\n }\n\n Debug.Log($\"PhysBone collider exited, bone: {info.bone?.name}\");\n }\n}\n```\n\n> **Note:** `OnPhysBoneColliderStay` fires every frame while a collider remains in contact and can generate significant callback overhead. Keep stay handlers lightweight or leave the body empty when you only need enter/exit transitions.\n\n### PhysBone Dependency Sorting (SDK 3.8.0+)\n\nSince SDK 3.8.0, PhysBone components are **automatically sorted based on dependencies**. PhysBone chains with parent-child relationships are evaluated in the correct order, resolving the unstable behavior seen in previous versions.\n\n### Instantiated PhysBones (Caution)\n\nPhysBones contained in objects created with `Instantiate()` **may not be network-synced**. Place objects that use PhysBones directly in the scene, or use VRChat Object Pool.\n\n## VRC Constraints\n\n### Basic Concept\n\nVRC Constraints replace Unity's built-in Constraints with a VRChat-optimized version:\n\n| Constraint | Purpose |\n|------------|---------|\n| **Position Constraint** | Follow position of target(s) |\n| **Rotation Constraint** | Follow rotation of target(s) |\n| **Scale Constraint** | Follow scale of target(s) |\n| **Parent Constraint** | Follow position and rotation (like parenting) |\n| **Aim Constraint** | Point at target |\n| **Look At Constraint** | Look at target (optimized for eyes) |\n\n### Constraints Setup\n\n```text\nVRC Position Constraint\n├── Sources: [Transform1 (weight 0.5), Transform2 (weight 0.5)]\n├── Constraint Active: true\n├── Lock: X, Y, Z\n└── At Rest: (0, 0, 0)\n```\n\n### Accessing Constraints from Udon\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Dynamics.Constraint.Components;\n\npublic class ConstraintController : UdonSharpBehaviour\n{\n public VRCPositionConstraint posConstraint;\n public VRCAimConstraint aimConstraint;\n\n public void EnableConstraint()\n {\n posConstraint.IsActive = true;\n }\n\n public void DisableConstraint()\n {\n posConstraint.IsActive = false;\n }\n\n public void SetWeight(float weight)\n {\n // Set source weight (index 0)\n posConstraint.SetSourceWeight(0, weight);\n }\n}\n```\n\n### VRC Constraints vs Unity Constraints\n\nVRChat provides its own constraint components that replace Unity's built-in constraints. **Unity Constraints are disabled on Quest/Android**, making VRC Constraints the only cross-platform option.\n\n#### Comparison Table\n\n| Feature | VRC Constraints | Unity Constraints |\n|---------|----------------|-------------------|\n| **Quest/Android** | Supported | Disabled |\n| **PC** | Supported | Supported |\n| **Network sync** | Compatible with VRChat networking (sync via UdonSynced/VRC_ObjectSync) | No VRChat network integration |\n| **Performance** | Optimized for VRChat | Standard Unity performance |\n| **Udon API access** | Full (`IsActive`, `SetSourceWeight`, etc.) | Not all properties exposed to Udon |\n| **PhysBone compatibility** | Full | Not guaranteed |\n\n#### Formal Component Names\n\nWhen referencing VRC Constraints in code or Inspector, use the exact component names:\n\n| VRC Constraint | Unity Equivalent | Namespace |\n|----------------|-----------------|-----------|\n| `VRCPositionConstraint` | `PositionConstraint` | `VRC.SDK3.Dynamics.Constraint.Components` |\n| `VRCRotationConstraint` | `RotationConstraint` | `VRC.SDK3.Dynamics.Constraint.Components` |\n| `VRCScaleConstraint` | `ScaleConstraint` | `VRC.SDK3.Dynamics.Constraint.Components` |\n| `VRCParentConstraint` | `ParentConstraint` | `VRC.SDK3.Dynamics.Constraint.Components` |\n| `VRCAimConstraint` | `AimConstraint` | `VRC.SDK3.Dynamics.Constraint.Components` |\n| `VRCLookAtConstraint` | `LookAtConstraint` | `VRC.SDK3.Dynamics.Constraint.Components` |\n\n```csharp\nusing VRC.SDK3.Dynamics.Constraint.Components;\n\n// Correct: Use VRC Constraint components\npublic VRCParentConstraint parentConstraint;\npublic VRCPositionConstraint positionConstraint;\npublic VRCRotationConstraint rotationConstraint;\n\n// Wrong: Unity constraints are disabled on Quest\n// public UnityEngine.Animations.ParentConstraint parentConstraint;\n```\n\n#### Decision Guide\n\n| Scenario | Recommendation |\n|----------|---------------|\n| World targets PC + Quest | **VRC Constraints** (mandatory) |\n| World targets PC only | VRC Constraints preferred (future-proof) |\n| Need Udon API control | **VRC Constraints** (better API) |\n| Migrating from Unity Constraints | Replace with VRC equivalents |\n| Avatar constraints | Follow VRChat avatar documentation |\n\n## VRC Constraints Udon API\n\nThis section covers the full Udon API for controlling VRC Constraints at runtime. For component type selection and cross-platform considerations, see the [VRC Constraints vs Unity Constraints comparison table](#vrc-constraints-vs-unity-constraints) above.\n\n### Constraint Types Overview\n\nAll six VRC Constraint types share a common base API. The table below summarises each type's purpose and the namespace required in UdonSharp:\n\n| Component Class | Purpose | Typical Use Case |\n|-----------------|---------|-----------------|\n| `VRCPositionConstraint` | Constrains world/local position | Object that follows a target's position |\n| `VRCRotationConstraint` | Constrains world/local rotation | Object that mirrors a target's rotation |\n| `VRCScaleConstraint` | Constrains world/local scale | Object that scales with a target |\n| `VRCParentConstraint` | Constrains position **and** rotation (like re-parenting) | Attaching props to moving targets |\n| `VRCAimConstraint` | Rotates so a chosen axis points at the target | Turrets, eye tracking |\n| `VRCLookAtConstraint` | Rotates so the forward axis looks at the target (Y-up preserved) | Billboards, simplified eye tracking |\n\nAll types live in `VRC.SDK3.Dynamics.Constraint.Components`.\n\n### Udon-Accessible Properties\n\nThe properties below are available on every VRC Constraint component from Udon. Properties marked **read/write** can be set at runtime.\n\n#### Common Properties (All Types)\n\n| Property | Type | Access | Description |\n|----------|------|--------|-------------|\n| `IsActive` | `bool` | R/W | Enables or disables the constraint evaluation |\n| `GlobalWeight` | `float` | R/W | Master blend weight (0.0 – 1.0) applied on top of per-source weights |\n| `FreezeToWorld` | `bool` | R/W | When `true`, locks the constrained object in world space (ignores sources) |\n| `AffectsPositionX/Y/Z` | `bool` | R/W | (Position / Parent) Toggle per-axis position constraint |\n| `AffectsRotationX/Y/Z` | `bool` | R/W | (Rotation / Parent) Toggle per-axis rotation constraint |\n| `AffectsScaleX/Y/Z` | `bool` | R/W | (Scale) Toggle per-axis scale constraint |\n\n#### Source List Methods\n\n| Method | Returns | Description |\n|--------|---------|-------------|\n| `GetSourceCount()` | `int` | Returns the current number of constraint sources |\n| `GetSource(int index)` | `VRCConstraintSource` | Returns the source struct at the given index |\n| `SetSource(int index, VRCConstraintSource source)` | `void` | Overwrites the source at the given index |\n| `AddSource(VRCConstraintSource source)` | `void` | Appends a new source to the list |\n| `RemoveSource(int index)` | `void` | Removes the source at the given index |\n| `SetSourceWeight(int index, float weight)` | `void` | Convenience method – sets only the weight of source at index |\n\n#### VRCConstraintSource Struct\n\n`VRCConstraintSource` is a value-type struct. Always assign the full struct back after modifying it (struct-mutation rule applies — see [UdonSharp constraints reference](../rules/udonsharp-constraints.md)):\n\n```csharp\n// Fields\npublic Transform sourceTransform; // The target Transform (can be null to disable source)\npublic float weight; // Per-source blend weight (0.0 – 1.0)\n```\n\n### Code Examples\n\n#### Example 1: Enable / Disable a Constraint at Runtime\n\nToggle a `VRCPositionConstraint` on and off, for example when a player picks up an object.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Dynamics.Constraint.Components;\nusing VRC.SDKBase;\n\npublic class ConstraintToggle : UdonSharpBehaviour\n{\n [Header(\"Constraint to control\")]\n public VRCPositionConstraint positionConstraint;\n\n [Header(\"Optional: animate global weight instead of hard on/off\")]\n public bool useWeightFade = false;\n\n // Called from a UI button or InteractEvent\n public void EnableConstraint()\n {\n if (positionConstraint == null) return;\n\n if (useWeightFade)\n {\n positionConstraint.GlobalWeight = 1f;\n }\n else\n {\n positionConstraint.IsActive = true;\n }\n }\n\n public void DisableConstraint()\n {\n if (positionConstraint == null) return;\n\n if (useWeightFade)\n {\n positionConstraint.GlobalWeight = 0f;\n }\n else\n {\n positionConstraint.IsActive = false;\n }\n }\n\n // Toggle helper – safe to call from network events\n public void ToggleConstraint()\n {\n if (positionConstraint == null) return;\n positionConstraint.IsActive = !positionConstraint.IsActive;\n }\n}\n```\n\n**Notes**:\n- Setting `IsActive = false` stops constraint evaluation entirely (cheapest option).\n- Setting `GlobalWeight = 0` keeps evaluation running but blends to zero; useful for smooth transitions.\n- No ownership transfer is needed to change constraint properties on the local client. If the result must be visible to all players, combine with `[UdonSynced]` state and `RequestSerialization()`.\n\n#### Example 2: Modify Constraint Sources at Runtime\n\nDynamically swap or blend between multiple target transforms — for example, switching which player a spotlight follows.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Dynamics.Constraint.Components;\nusing VRC.SDKBase;\n\npublic class ConstraintSourceSwapper : UdonSharpBehaviour\n{\n [Header(\"The aim constraint that points a spotlight\")]\n public VRCAimConstraint spotlightAim;\n\n [Header(\"A pool of potential target transforms\")]\n public Transform[] targetPool;\n\n // Switch the single source to point at a new target\n public void SetTarget(int targetIndex)\n {\n if (spotlightAim == null) return;\n if (targetIndex \u003c 0 || targetIndex >= targetPool.Length) return;\n\n // Build a new source struct pointing at the chosen transform\n VRCConstraintSource newSource = new VRCConstraintSource();\n newSource.sourceTransform = targetPool[targetIndex];\n newSource.weight = 1f;\n\n if (spotlightAim.GetSourceCount() == 0)\n {\n spotlightAim.AddSource(newSource);\n }\n else\n {\n // Overwrite the first (and only) source\n spotlightAim.SetSource(0, newSource);\n }\n }\n\n // Blend equally between two targets by adjusting their weights\n public void BlendBetweenTargets(int indexA, int indexB, float weightA)\n {\n if (spotlightAim == null) return;\n if (spotlightAim.GetSourceCount() \u003c 2) return;\n\n float weightB = 1f - weightA;\n\n // SetSourceWeight is a convenience wrapper; equivalent to reading the source,\n // updating weight, and calling SetSource() back.\n spotlightAim.SetSourceWeight(indexA, weightA);\n spotlightAim.SetSourceWeight(indexB, weightB);\n }\n\n // Remove all sources except the first, then clear it\n public void ClearAllSources()\n {\n if (spotlightAim == null) return;\n\n int count = spotlightAim.GetSourceCount();\n // Remove from the end to avoid index shifting\n for (int i = count - 1; i > 0; i--)\n {\n spotlightAim.RemoveSource(i);\n }\n\n if (spotlightAim.GetSourceCount() > 0)\n {\n VRCConstraintSource empty = new VRCConstraintSource();\n empty.sourceTransform = null;\n empty.weight = 0f;\n spotlightAim.SetSource(0, empty);\n }\n }\n}\n```\n\n**Notes**:\n- `VRCConstraintSource` is a **struct** — always construct a new instance and assign it; do not attempt to mutate the value returned by `GetSource()` in place (see [struct mutation caveat](../rules/udonsharp-constraints.md#struct-mutation)).\n- Remove sources from the **highest index downward** when removing multiple at once, to avoid index shifting mid-loop.\n- `SetSourceWeight(index, weight)` is a shorthand for read-modify-write with `GetSource` / `SetSource`; both approaches are equivalent.\n\n### Per-Type Additional Properties\n\nSome constraint types expose extra writable properties beyond the common set:\n\n| Type | Extra Properties | Description |\n|------|-----------------|-------------|\n| `VRCAimConstraint` | `AimVector`, `UpVector`, `WorldUpVector`, `WorldUpType` | Axis vectors for the aim calculation |\n| `VRCLookAtConstraint` | `Roll` | Roll angle offset around the look axis |\n| `VRCParentConstraint` | `AffectsPositionX/Y/Z`, `AffectsRotationX/Y/Z` | Independent axis masking for position and rotation |\n\n### Runtime Source Management — Pattern Summary\n\n```csharp\nusing VRC.SDK3.Dynamics.Constraint.Components;\n\n// --- Read ---\nint count = constraint.GetSourceCount();\nVRCConstraintSource src = constraint.GetSource(0);\nfloat w = src.weight;\nTransform t = src.sourceTransform;\n\n// --- Write (weight only) ---\nconstraint.SetSourceWeight(0, 0.5f);\n\n// --- Write (full source) ---\n// IMPORTANT: structs must be fully reassigned — do not mutate GetSource() result in place\nVRCConstraintSource updated = constraint.GetSource(0);\nupdated.sourceTransform = someNewTransform; // modify copy\nupdated.weight = 0.75f;\nconstraint.SetSource(0, updated); // write back\n\n// --- Add ---\nVRCConstraintSource newSrc = new VRCConstraintSource();\nnewSrc.sourceTransform = targetTransform;\nnewSrc.weight = 1f;\nconstraint.AddSource(newSrc);\n\n// --- Remove (always remove from highest index first in loops) ---\nconstraint.RemoveSource(count - 1);\n```\n\n### Performance Considerations\n\n| Tip | Reason |\n|-----|--------|\n| Disable (`IsActive = false`) when not needed | Stops CPU evaluation of the constraint entirely |\n| Prefer `SetSourceWeight` over removing/re-adding sources | Avoids internal list reallocation |\n| Avoid modifying sources every `Update()` frame | Batch changes and apply only when the target actually changes |\n| Use `GlobalWeight` for smooth fades | Cheaper than adding/removing sources for blending |\n\n## Common Patterns\n\n### Interactive Button with Feedback\n\n```csharp\npublic class PhysicalButton : UdonSharpBehaviour\n{\n [Header(\"References\")]\n public Transform buttonTop;\n public AudioSource pressSound;\n public AudioSource releaseSound;\n\n [Header(\"Settings\")]\n public float pressDepth = 0.02f;\n public float pressSpeed = 10f;\n\n [UdonSynced] private bool isPressed = false;\n private Vector3 originalPosition;\n private Vector3 pressedPosition;\n\n void Start()\n {\n originalPosition = buttonTop.localPosition;\n pressedPosition = originalPosition - new Vector3(0, pressDepth, 0);\n }\n\n void Update()\n {\n Vector3 target = isPressed ? pressedPosition : originalPosition;\n buttonTop.localPosition = Vector3.Lerp(\n buttonTop.localPosition,\n target,\n Time.deltaTime * pressSpeed\n );\n }\n\n public override void OnContactEnter(ContactEnterInfo info)\n {\n if (isPressed) return;\n\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n isPressed = true;\n RequestSerialization();\n\n pressSound.Play();\n OnButtonPressed();\n }\n\n public override void OnContactExit(ContactExitInfo info)\n {\n if (!isPressed) return;\n\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n isPressed = false;\n RequestSerialization();\n\n releaseSound.Play();\n }\n\n private void OnButtonPressed()\n {\n // Your button action\n SendCustomNetworkEvent(\n VRC.Udon.Common.Interfaces.NetworkEventTarget.All,\n nameof(DoButtonAction)\n );\n }\n\n public void DoButtonAction()\n {\n Debug.Log(\"Button action executed!\");\n }\n}\n```\n\n### Grabbable Lever\n\n```csharp\npublic class GrabbableLever : UdonSharpBehaviour\n{\n [Header(\"Settings\")]\n public float minAngle = -45f;\n public float maxAngle = 45f;\n public float threshold = 30f;\n\n [UdonSynced, FieldChangeCallback(nameof(LeverState))]\n private bool _leverState = false;\n\n public bool LeverState\n {\n get => _leverState;\n set\n {\n _leverState = value;\n OnLeverStateChanged();\n }\n }\n\n public override void OnPhysBoneGrab(PhysBoneGrabInfo info)\n {\n // Take ownership when grabbed\n Networking.SetOwner(info.player, gameObject);\n }\n\n void Update()\n {\n // Check lever angle\n float angle = transform.localEulerAngles.x;\n if (angle > 180) angle -= 360;\n\n bool newState = angle > threshold;\n\n if (newState != _leverState && Networking.IsOwner(gameObject))\n {\n LeverState = newState;\n RequestSerialization();\n }\n }\n\n private void OnLeverStateChanged()\n {\n Debug.Log($\"Lever is now: {(_leverState ? \"ON\" : \"OFF\")}\");\n }\n}\n```\n\n### Touch-Sensitive Surface\n\n```csharp\npublic class TouchSurface : UdonSharpBehaviour\n{\n public Material idleMaterial;\n public Material touchMaterial;\n public Renderer surfaceRenderer;\n\n private int touchCount = 0;\n\n public override void OnContactEnter(ContactEnterInfo info)\n {\n touchCount++;\n UpdateVisual();\n\n // Spawn effect at touch point\n SpawnTouchEffect(info.position);\n }\n\n public override void OnContactExit(ContactExitInfo info)\n {\n touchCount--;\n if (touchCount \u003c 0) touchCount = 0;\n UpdateVisual();\n }\n\n private void UpdateVisual()\n {\n surfaceRenderer.material = touchCount > 0 ? touchMaterial : idleMaterial;\n }\n\n private void SpawnTouchEffect(Vector3 position)\n {\n // Spawn particle or visual effect\n }\n}\n```\n\n## Important Notes\n\n### Avatar vs World Contacts\n\n```csharp\npublic override void OnContactEnter(ContactEnterInfo info)\n{\n if (info.isAvatar)\n {\n // Contact from an avatar (player)\n // - \"Allow Self\" setting applies\n // - \"Allow Others\" setting applies\n VRCPlayerApi player = info.player;\n }\n else\n {\n // Contact from a world object (VRC Contact Sender in world)\n // - \"Allow Self\" and \"Allow Others\" are IGNORED\n // - Always triggers regardless of settings\n }\n}\n```\n\n### Performance Considerations\n\n| Tip | Description |\n|-----|-------------|\n| Limit PhysBones | Each PhysBone chain has CPU cost |\n| Minimize receivers | Only add where needed |\n| Use appropriate radii | Larger = more collision checks |\n| Disable when not needed | Disable components when inactive |\n\n### Debugging\n\n```csharp\npublic class DynamicsDebug : UdonSharpBehaviour\n{\n public override void OnContactEnter(ContactEnterInfo info)\n {\n Debug.Log($\"[Contact] Enter - Sender: {info.senderName}, \" +\n $\"Avatar: {info.isAvatar}, Player: {info.player?.displayName}, \" +\n $\"Position: {info.position}\");\n }\n\n public override void OnPhysBoneGrab(PhysBoneGrabInfo info)\n {\n Debug.Log($\"[PhysBone] Grab - Player: {info.player?.displayName}, \" +\n $\"Bone: {info.bone?.name}\");\n }\n}\n```\n\n## Best Practices\n\n1. **Test with multiple players** - Contacts behave differently with network latency\n2. **Use appropriate content types** - Be specific to avoid unwanted triggers\n3. **Handle null players** - `info.player` can be null for world objects\n4. **Sync state, not events** - Use `[UdonSynced]` for persistent state\n5. **Debounce rapid contacts** - Add cooldown to prevent spam\n6. **Clean up on player leave** - Reset state in `OnPlayerLeft`\n\n## See Also\n\n- [events.md](events.md) - Full reference for `OnContactEnter/Stay/Exit` and `OnPhysBoneGrab/Release` signatures\n- [patterns-core.md](patterns-core.md) - Common patterns for interactive objects using Contacts and PhysBones\n- [networking.md](networking.md) - Syncing contact/grab state across players with `[UdonSynced]`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":30355,"content_sha256":"dc9c366795c995c92e429a45f436bd38a2e389e87468bcb3a4dc32a36de43da1"},{"filename":"references/editor-scripting.md","content":"# UdonSharp Editor Scripting Reference\n\nGuide to editor scripts, custom inspectors, and working with the UdonSharp proxy system.\n\n## Overview\n\nUdonSharp uses a **proxy system** where C# `UdonSharpBehaviour` scripts act as proxies for the underlying `UdonBehaviour` components. Understanding this system is essential for editor scripting.\n\n## Preprocessor Directives\n\nUsed for conditional compilation:\n\n```csharp\n#if UNITY_EDITOR\n// Code only compiled in Unity Editor\nusing UnityEditor;\n#endif\n\n#if COMPILER_UDONSHARP\n// Code compiled by UdonSharp compiler (for runtime)\n#endif\n\n#if !COMPILER_UDONSHARP\n// Code NOT compiled by UdonSharp (editor-only utilities)\n#endif\n```\n\n**Common Pattern:**\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UnityEditor;\nusing UdonSharpEditor;\n#endif\n\npublic class MyScript : UdonSharpBehaviour\n{\n public float speed = 5f;\n\n void Update()\n {\n transform.Translate(Vector3.forward * speed * Time.deltaTime);\n }\n\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\n // Editor-only code here\n public void EditorOnlyMethod()\n {\n Debug.Log(\"This only exists in editor!\");\n }\n#endif\n}\n```\n\n## Proxy System\n\n### How It Works\n\n1. Your `UdonSharpBehaviour` C# class is a **proxy**\n2. The actual data lives in the `UdonBehaviour` component\n3. Changes must be synchronized between proxy and underlying UdonBehaviour\n\n### Proxy Extension Methods\n\nFrom the `UdonSharpEditor` namespace:\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UdonSharpEditor;\n#endif\n\n// Get the proxy C# object from UdonBehaviour\nUdonBehaviour udonBehaviour = GetComponent\u003cUdonBehaviour>();\nMyScript proxy = UdonSharpEditorUtility.GetProxyBehaviour(udonBehaviour) as MyScript;\n\n// Get UdonBehaviour from proxy\nUdonBehaviour underlying = UdonSharpEditorUtility.GetBackingUdonBehaviour(proxy);\n```\n\n### Updating Proxy Values\n\nWhen changing values in editor scripts, use the following pattern:\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\n// After modifying the proxy, sync to UdonBehaviour\nproxy.speed = 10f;\nUdonSharpEditorUtility.CopyProxyToUdon(proxy);\n\n// After modifying UdonBehaviour directly, sync to proxy\nUdonSharpEditorUtility.CopyUdonToProxy(proxy);\n#endif\n```\n\n## Adding Components\n\n### AddUdonSharpComponent\n\nUse this instead of `AddComponent` when adding UdonSharpBehaviours in the editor:\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UdonSharpEditor;\n\n// Add UdonSharpBehaviour component\nMyScript script = gameObject.AddUdonSharpComponent\u003cMyScript>();\n\n// This creates the UdonBehaviour, assigns its programSource (.asset), and creates the proxy.\n// Skipping this helper and calling AddComponent\u003cUdonBehaviour>() directly produces a component\n// with empty programSource — silently does nothing at runtime. See troubleshooting.md\n// \"UdonBehaviour with Empty Program Source\" for the diagnostic walk-through.\n#endif\n```\n\n### GetUdonSharpComponent\n\nGet a typed UdonSharpBehaviour from a GameObject:\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\n// In editor, this returns the proxy object\nMyScript script = gameObject.GetUdonSharpComponent\u003cMyScript>();\n\n// Get all of a type\nMyScript[] scripts = gameObject.GetUdonSharpComponentsInChildren\u003cMyScript>();\n#endif\n```\n\n## Custom Inspectors\n\n### Basic Custom Inspector\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UnityEditor;\nusing UdonSharpEditor;\n\n[CustomEditor(typeof(MyScript))]\npublic class MyScriptEditor : Editor\n{\n public override void OnInspectorGUI()\n {\n MyScript script = (MyScript)target;\n\n // REQUIRED: Draw the default UdonSharp header\n if (UdonSharpGUI.DrawDefaultUdonSharpBehaviourHeader(target))\n {\n return; // Returns true if the script is not valid\n }\n\n // Your custom inspector code here\n EditorGUILayout.Space();\n EditorGUILayout.LabelField(\"Custom Settings\", EditorStyles.boldLabel);\n\n // Use SerializedProperty for proper Undo support\n SerializedProperty speedProp = serializedObject.FindProperty(\"speed\");\n EditorGUILayout.PropertyField(speedProp);\n\n // Apply changes\n if (serializedObject.ApplyModifiedProperties())\n {\n // Sync changes to UdonBehaviour\n UdonSharpEditorUtility.CopyProxyToUdon(script);\n }\n\n // Custom buttons\n if (GUILayout.Button(\"Reset Speed\"))\n {\n Undo.RecordObject(script, \"Reset Speed\");\n script.speed = 5f;\n UdonSharpEditorUtility.CopyProxyToUdon(script);\n }\n }\n}\n#endif\n```\n\n### Important: DrawDefaultUdonSharpBehaviourHeader\n\n**Always call this at the beginning of the inspector.** It handles:\n- Drawing the UdonSharp script reference field\n- Displaying compile errors\n- Showing the sync mode indicator\n- Returning `true` when the component is invalid (skip drawing)\n\n```csharp\nif (UdonSharpGUI.DrawDefaultUdonSharpBehaviourHeader(target))\n{\n return; // Don't draw the rest if invalid\n}\n```\n\n## Scene Handles and Gizmos\n\n### Custom Handles\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UnityEditor;\n\n[CustomEditor(typeof(TeleportPoint))]\npublic class TeleportPointEditor : Editor\n{\n private void OnSceneGUI()\n {\n TeleportPoint point = (TeleportPoint)target;\n\n // Draw position handle\n EditorGUI.BeginChangeCheck();\n Vector3 newPosition = Handles.PositionHandle(\n point.targetPosition,\n Quaternion.identity\n );\n if (EditorGUI.EndChangeCheck())\n {\n Undo.RecordObject(point, \"Move Teleport Target\");\n point.targetPosition = newPosition;\n UdonSharpEditorUtility.CopyProxyToUdon(point);\n }\n\n // Draw label\n Handles.Label(point.targetPosition, \"Teleport Target\");\n\n // Draw line from object to target\n Handles.color = Color.cyan;\n Handles.DrawDottedLine(\n point.transform.position,\n point.targetPosition,\n 5f\n );\n }\n}\n#endif\n```\n\n### Gizmos\n\n```csharp\npublic class TriggerZone : UdonSharpBehaviour\n{\n public float radius = 5f;\n\n#if UNITY_EDITOR\n // Gizmos don't need COMPILER_UDONSHARP check\n private void OnDrawGizmos()\n {\n Gizmos.color = new Color(0, 1, 0, 0.3f);\n Gizmos.DrawSphere(transform.position, radius);\n }\n\n private void OnDrawGizmosSelected()\n {\n Gizmos.color = Color.green;\n Gizmos.DrawWireSphere(transform.position, radius);\n }\n#endif\n}\n```\n\n## Editor Windows\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UnityEditor;\nusing UdonSharpEditor;\n\npublic class MyToolWindow : EditorWindow\n{\n [MenuItem(\"Tools/My UdonSharp Tool\")]\n public static void ShowWindow()\n {\n GetWindow\u003cMyToolWindow>(\"My Tool\");\n }\n\n private void OnGUI()\n {\n EditorGUILayout.LabelField(\"UdonSharp Tool\", EditorStyles.boldLabel);\n\n if (GUILayout.Button(\"Find All MyScript Components\"))\n {\n MyScript[] scripts = FindObjectsOfType\u003cMyScript>();\n foreach (var script in scripts)\n {\n Debug.Log($\"Found: {script.gameObject.name}\");\n }\n }\n\n if (GUILayout.Button(\"Reset All Speeds\"))\n {\n MyScript[] scripts = FindObjectsOfType\u003cMyScript>();\n foreach (var script in scripts)\n {\n Undo.RecordObject(script, \"Reset All Speeds\");\n script.speed = 5f;\n UdonSharpEditorUtility.CopyProxyToUdon(script);\n }\n }\n }\n}\n#endif\n```\n\n## Property Drawers\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UnityEditor;\n\n// Custom attribute\npublic class MinMaxAttribute : PropertyAttribute\n{\n public float min;\n public float max;\n\n public MinMaxAttribute(float min, float max)\n {\n this.min = min;\n this.max = max;\n }\n}\n\n// Property drawer\n[CustomPropertyDrawer(typeof(MinMaxAttribute))]\npublic class MinMaxDrawer : PropertyDrawer\n{\n public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)\n {\n MinMaxAttribute attr = (MinMaxAttribute)attribute;\n\n if (property.propertyType == SerializedPropertyType.Float)\n {\n property.floatValue = EditorGUI.Slider(\n position,\n label,\n property.floatValue,\n attr.min,\n attr.max\n );\n }\n }\n}\n#endif\n\n// Usage in UdonSharpBehaviour\npublic class MyScript : UdonSharpBehaviour\n{\n [MinMax(0f, 100f)]\n public float health = 100f;\n}\n```\n\n## DefaultExecutionOrder\n\nThe `[DefaultExecutionOrder]` attribute controls the order in which Unity calls `Awake`, `OnEnable`, and `Start` across different `MonoBehaviour` (and `UdonSharpBehaviour`) scripts.\n\nLower numbers run **earlier**. The default order is `0`. Use negative values for scripts that must initialize before everything else, and positive values for scripts that should run last.\n\n```csharp\n// Runs before all default-order scripts — ideal for world settings and global state\n[DefaultExecutionOrder(-1000)]\npublic class WorldSettingsInitializer : UdonSharpBehaviour\n{\n [SerializeField] private float gravityScale = 0.5f;\n\n void Start()\n {\n // Apply world-wide gravity before any other script reads it\n Networking.LocalPlayer.SetGravityStrength(gravityScale);\n }\n}\n\n// Runs after default-order scripts — safe to read values set by initializers\n[DefaultExecutionOrder(100)]\npublic class PlayerController : UdonSharpBehaviour\n{\n void Start()\n {\n // WorldSettingsInitializer.Start() has already run\n Debug.Log(\"Player controller initialized\");\n }\n}\n```\n\n**When to use `[DefaultExecutionOrder]`:**\n\n| Scenario | Recommended Order |\n|----------|-------------------|\n| World configuration (gravity, spawn zones, global flags) | `-1000` or lower |\n| Managers that other scripts depend on | `-500` to `-100` |\n| Default scripts | `0` (omit the attribute) |\n| Scripts that consume manager output | `100` to `1000` |\n\n> **Note**: `Awake()` is not available in UdonSharp. `Start()` is the first lifecycle hook; `[DefaultExecutionOrder]` controls when `Start()` runs relative to other behaviours.\n\n## Best Practices\n\n### 1. Always Use Undo\n\n```csharp\nUndo.RecordObject(target, \"Description of change\");\n// Make changes\nUdonSharpEditorUtility.CopyProxyToUdon(target);\n```\n\n### 2. Check for Null Proxy\n\n```csharp\nMyScript script = target as MyScript;\nif (script == null) return;\n```\n\n### 3. Use SerializedProperty When Possible\n\n```csharp\nSerializedProperty prop = serializedObject.FindProperty(\"fieldName\");\nEditorGUILayout.PropertyField(prop);\nserializedObject.ApplyModifiedProperties();\n```\n\n### 4. Batch Proxy Updates\n\n```csharp\n// Don't call CopyProxyToUdon after every change\nscript.value1 = 1;\nscript.value2 = 2;\nscript.value3 = 3;\nUdonSharpEditorUtility.CopyProxyToUdon(script); // Once at the end\n```\n\n### 5. Mark Scene Dirty When Needed\n\n```csharp\nif (GUI.changed)\n{\n EditorUtility.SetDirty(target);\n if (!Application.isPlaying)\n {\n UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(\n UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene()\n );\n }\n}\n```\n\n## Common Issues\n\n### Changes Not Persisting\n\nIf changes made in a custom inspector are not persisting:\n\n1. Verify you are calling `UdonSharpEditorUtility.CopyProxyToUdon()`\n2. Verify you are using `Undo.RecordObject()` before making changes\n3. Verify you are calling `EditorUtility.SetDirty()` on the target\n\n### Serialization Errors\n\nIf you see \"SerializedObject target has been destroyed\":\n\n```csharp\nif (serializedObject == null || serializedObject.targetObject == null)\n{\n return;\n}\n```\n\n### Play Mode Changes Lost\n\nChanges made during Play Mode are lost when exiting. This is standard Unity behavior. For testing purposes, use:\n\n```csharp\n[ExecuteInEditMode]\npublic class MyScript : UdonSharpBehaviour\n{\n // Script runs in edit mode too\n}\n```\n\nNote: `ExecuteInEditMode` can cause issues with UdonSharp. Use with caution.\n\n## See Also\n\n- [api.md](api.md) - VRChat API reference including types used in editor scripts\n- [constraints.md](constraints.md) - C# feature constraints that affect editor-time validation\n- [patterns-performance.md](patterns-performance.md) - DefaultExecutionOrder and performance patterns\n\n## UdonSharpProgramAsset Auto-Generation\n\n### The Problem\n\nWhen AI creates a new `.cs` UdonSharp script file, the corresponding `.asset` (UdonSharpProgramAsset) is **not auto-generated**. Without this asset file, Unity cannot associate the C# script with an UdonBehaviour, resulting in \"The associated script cannot be loaded\" errors.\n\n### The `.cs` to `.asset` Relationship\n\nEvery UdonSharp script requires a paired UdonSharpProgramAsset:\n\n```text\nMyScript.cs → Source code (UdonSharpBehaviour)\nMyScript.asset → UdonSharpProgramAsset (links script to Udon compiler)\n```\n\nWhen creating scripts through the Unity Editor (Assets > Create > U# Script), both files are generated automatically. However, when AI creates `.cs` files directly on the filesystem, the `.asset` file is missing.\n\n### Auto-Generation via AssetPostprocessor\n\nUse `AssetPostprocessor.OnPostprocessAllAssets()` to detect newly imported UdonSharp scripts and auto-generate their program assets. The implementation below handles edge cases such as abstract classes, compile errors, and concurrent asset creation:\n\n```csharp\n#if UNITY_EDITOR\nusing System;\nusing System.IO;\nusing System.Reflection;\nusing UdonSharp;\nusing UdonSharp.Compiler;\nusing UdonSharpEditor;\nusing UnityEditor;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Automatically generates UdonSharpProgramAsset for newly imported UdonSharpBehaviour scripts.\n/// Place this file in an Editor folder (e.g., Assets/Editor/).\n/// \u003c/summary>\npublic class UdonSharpProgramAssetAutoGenerator : AssetPostprocessor\n{\n /// \u003csummary>\n /// Reads UdonSharpSettings.autoCompileOnModify via reflection\n /// to avoid hard dependency on internal editor types.\n /// \u003c/summary>\n private static bool GetAutoCompileOnModify()\n {\n try\n {\n Assembly udonSharpEditorAssembly = typeof(UdonSharpEditorUtility).Assembly;\n Type settingsType = udonSharpEditorAssembly.GetType(\n \"UdonSharpEditor.UdonSharpSettings\");\n if (settingsType == null) return false;\n\n MethodInfo getSettingsMethod = settingsType.GetMethod(\n \"GetSettings\", BindingFlags.Public | BindingFlags.Static);\n if (getSettingsMethod == null) return false;\n\n object settingsInstance = getSettingsMethod.Invoke(null, null);\n if (settingsInstance == null) return false;\n\n FieldInfo autoCompileField = settingsType.GetField(\n \"autoCompileOnModify\",\n BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);\n if (autoCompileField == null) return false;\n\n object fieldValue = autoCompileField.GetValue(settingsInstance);\n return fieldValue is bool enabled && enabled;\n }\n catch (Exception ex)\n {\n Debug.LogError(\n $\"[UdonSharp AutoGenerator] Failed to read autoCompileOnModify.\\n{ex}\");\n return false;\n }\n }\n\n private static void OnPostprocessAllAssets(\n string[] importedAssets,\n string[] deletedAssets,\n string[] movedAssets,\n string[] movedFromAssetPaths,\n bool didDomainReload)\n {\n // Only run after domain reload (avoids running on every asset import)\n if (!didDomainReload) return;\n\n bool createdAny = false;\n\n foreach (string importedAssetPath in importedAssets)\n {\n if (string.IsNullOrEmpty(importedAssetPath)) continue;\n\n MonoScript script =\n AssetDatabase.LoadAssetAtPath\u003cMonoScript>(importedAssetPath);\n if (script == null) continue;\n\n Type scriptClass = script.GetClass();\n\n // Skip null (compile errors), abstract classes, non-UdonSharpBehaviour\n if (scriptClass == null\n || scriptClass.IsAbstract\n || !typeof(UdonSharpBehaviour).IsAssignableFrom(scriptClass))\n continue;\n\n // Check Udon registration (not just file existence)\n if (UdonSharpEditorUtility.GetUdonSharpProgramAsset(scriptClass) != null)\n continue;\n\n string programAssetPath = Path.ChangeExtension(importedAssetPath, \".asset\")\n ?.Replace('\\\\', '/');\n if (string.IsNullOrEmpty(programAssetPath)\n || !programAssetPath.StartsWith(\"Assets/\", StringComparison.Ordinal))\n continue;\n\n if (AssetDatabase.LoadMainAssetAtPath(programAssetPath) != null)\n continue;\n\n UdonSharpProgramAsset programAsset =\n ScriptableObject.CreateInstance\u003cUdonSharpProgramAsset>();\n programAsset.sourceCsScript = script;\n\n try\n {\n AssetDatabase.CreateAsset(programAsset, programAssetPath);\n AssetDatabase.ImportAsset(\n programAssetPath, ImportAssetOptions.ForceSynchronousImport);\n\n if (AssetDatabase.LoadAssetAtPath\u003cUdonSharpProgramAsset>(\n programAssetPath) == null)\n {\n Debug.LogError(\n $\"[UdonSharp AutoGenerator] Failed to create asset at \" +\n $\"'{programAssetPath}' for '{importedAssetPath}'.\");\n continue;\n }\n\n Debug.Log(\n $\"[UdonSharp AutoGenerator] Created ProgramAsset: {programAssetPath}\");\n }\n catch (Exception ex)\n {\n Debug.LogError(\n $\"[UdonSharp AutoGenerator] Exception creating asset at \" +\n $\"'{programAssetPath}' for '{importedAssetPath}'.\\n{ex}\");\n continue;\n }\n\n createdAny = true;\n }\n\n if (!createdAny) return;\n\n AssetDatabase.Refresh();\n\n // Trigger UdonSharp compilation if auto-compile is enabled\n if (!GetAutoCompileOnModify()) return;\n\n try\n {\n UdonSharpCompilerV1.CompileSync();\n }\n catch (Exception ex)\n {\n Debug.LogError(\n $\"[UdonSharp AutoGenerator] Compile failed after generating assets.\\n{ex}\");\n }\n }\n}\n#endif\n```\n\n> **Credit**: Based on [nemurigi's AssetPostprocessor pattern](https://gist.github.com/nemurigi/dea7c0a1fb94f7b9cf1c36481a459ded) (MIT License).\n> **Template**: A ready-to-use version is available at `assets/templates/UdonSharpProgramAssetAutoGenerator.cs`.\n\n### Setup Instructions\n\n1. Create an `Editor` folder in your Unity project (e.g., `Assets/Editor/`)\n2. Copy `UdonSharpProgramAssetAutoGenerator.cs` into the `Editor` folder\n3. New UdonSharp scripts will automatically get their `.asset` files after domain reload\n4. If `autoCompileOnModify` is enabled in UdonSharp settings, compilation triggers automatically\n\n### Limitations\n\n- The script must compile successfully before the asset can be generated (`GetClass()` returns `null` for scripts with compile errors)\n- Abstract classes are intentionally skipped (they cannot have their own UdonSharpProgramAsset)\n- The `Editor` folder placement is required (scripts in `Editor` are not compiled by UdonSharp)\n- Generation only runs after domain reload (`didDomainReload`), not on every asset import\n- **Asset generation does not wire `UdonBehaviour.programSource`** — this generator only creates the `.asset` file. Assigning it to a `UdonBehaviour` on a GameObject is a separate step; use [`AddUdonSharpComponent`](#addudonsharpcomponent) for new components, or see \"UdonBehaviour with Empty Program Source\" in `troubleshooting.md` for diagnosing existing components\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":20090,"content_sha256":"8d25c299538f9e75db390c56c4b09ae2a82165a084483cf050001ee531c99420"},{"filename":"references/events.md","content":"# UdonSharp Event Reference\n\nComplete reference of all events available in UdonSharp. Override these methods to respond to events.\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\n## Important: override vs Non-override\n\nUdonSharp events include those that **require override** and those that **do not**. Using the wrong one causes a compile error (CS0115).\n\n### override Required (VRChat/Udon-Specific Events)\n\n`OnPlayerJoined`, `OnPlayerLeft`, `OnPlayerRespawn`, `OnMasterTransferred`, `OnPlayerSuspendChanged`, `OnAvatarChanged`, `OnAvatarEyeHeightChanged`, `OnLanguageChanged`, `OnVRCPlusMassGift`, `OnDeserialization`, `OnPreSerialization`, `OnPostSerialization`, `OnOwnershipTransferred`, `OnOwnershipRequest`, `Interact`, `OnPickup`, `OnDrop`, `OnPickupUseDown`, `OnPickupUseUp`, `OnPlayerTriggerEnter/Stay/Exit`, `OnPlayerCollisionEnter/Stay/Exit`, `OnPlayerParticleCollision`, `OnControllerColliderHitPlayer`, `OnStationEntered/Exited`, `OnPlayerRestored`, `OnPersistenceUsageUpdated`, `OnPlayerDataStorageExceeded/Warning`, `OnPlayerObjectStorageExceeded/Warning`, `OnContactEnter/Stay/Exit`, `OnPhysBoneGrab/Release`, `OnPhysBoneColliderEnter/Stay/Exit`, `OnPhysBonePosed`, `OnPhysBoneUnPosed`, `OnDroneTriggerEnter/Stay/Exit`, `InputJump`, `InputUse`, `InputGrab`, `InputDrop`, `InputMoveHorizontal/Vertical`, `InputLookHorizontal/Vertical`, `OnInputMethodChanged`, `MidiNoteOn/Off`, `MidiControlChange`, `OnVideo*`, `OnStringLoad*`, `OnImageLoad*`, `OnSpawn`, `OnVRCQualitySettingsChanged`, `OnVRCCameraSettingsChanged`, `OnScreenUpdate`, `OnAsyncGpuReadbackComplete`, `OnPurchaseConfirmed`, `OnPurchaseConfirmedMultiple`, `OnPurchaseExpired`, `OnPurchasesLoaded`, `OnListAvailableProducts`, `OnListProductOwners`, `OnListPurchases`, `OnProductEvent`\n\n### override Not Required (Standard Unity Callbacks)\n\n`Start`, `Update`, `LateUpdate`, `FixedUpdate`, `OnEnable`, `OnDisable`, `OnDestroy`, `OnTriggerEnter/Stay/Exit`, `OnCollisionEnter/Stay/Exit`, `OnControllerColliderHit`, `OnAnimatorMove`, `OnAnimatorIK`, `OnWillRenderObject`, `OnBecameVisible/Invisible`\n\n```csharp\n// WRONG: CS0115 error\npublic override void OnTriggerEnter(Collider other) { }\n// CORRECT\npublic void OnTriggerEnter(Collider other) { }\n\n// CORRECT: VRChat events require override\npublic override void OnPlayerJoined(VRCPlayerApi player) { }\n```\n\n---\n\n## Update Events\n\nCalled every frame or physics tick.\n\n| Event | When Called |\n|-------|-------------|\n| `void Update()` | Every frame |\n| `void LateUpdate()` | After all Update calls |\n| `void FixedUpdate()` | Every physics tick (~50Hz) |\n| `void PostLateUpdate()` | After LateUpdate (VRChat-specific) |\n\n```csharp\nvoid Update()\n{\n // Called every frame\n transform.Rotate(Vector3.up, rotationSpeed * Time.deltaTime);\n}\n\nvoid FixedUpdate()\n{\n // Called at fixed intervals for physics\n rb.AddForce(Vector3.up * force);\n}\n```\n\n### SendCustomEventDelayed and EventTiming (SDK 3.10.2+)\n\nThe third argument of `SendCustomEventDelayedSeconds` / `SendCustomEventDelayedFrames` specifies the execution timing.\n\n| EventTiming | Description | Added In |\n|-------------|------|--------------|\n| `EventTiming.Update` | Within the Update loop (default) | 3.7.1+ |\n| `EventTiming.LateUpdate` | Within LateUpdate | 3.7.1+ |\n| `EventTiming.FixedUpdate` | Within physics tick | **3.10.2** |\n| `EventTiming.PostLateUpdate` | After LateUpdate | **3.10.2** |\n\n```csharp\n// Default (Update timing)\nSendCustomEventDelayedSeconds(nameof(MyMethod), 2.0f);\n\n// Execute at FixedUpdate timing (SDK 3.10.2+)\nSendCustomEventDelayedSeconds(nameof(PhysicsAction), 1.0f, EventTiming.FixedUpdate);\n\n// Frame delay + PostLateUpdate timing (SDK 3.10.2+)\nSendCustomEventDelayedFrames(nameof(CameraFollow), 1, EventTiming.PostLateUpdate);\n```\n\n> **Note**: `EventTiming.FixedUpdate` is suitable for processing that needs to sync with physics calculations, and `EventTiming.PostLateUpdate` is suitable for camera following and post-IK corrections.\n\n---\n\n## Input Events\n\nVRChat-specific input events. Called when the player presses/releases buttons.\n\n### Action Events\n\n| Event | When Called |\n|-------|-------------|\n| `InputJump(bool value, UdonInputEventArgs args)` | Jump button |\n| `InputUse(bool value, UdonInputEventArgs args)` | Use/Interact button |\n| `InputGrab(bool value, UdonInputEventArgs args)` | Grab button |\n| `InputDrop(bool value, UdonInputEventArgs args)` | Drop button |\n\n### Movement Events\n\n| Event | When Called |\n|-------|-------------|\n| `InputMoveHorizontal(float value, UdonInputEventArgs args)` | Left/right movement |\n| `InputMoveVertical(float value, UdonInputEventArgs args)` | Forward/back movement |\n| `InputLookHorizontal(float value, UdonInputEventArgs args)` | Look left/right |\n| `InputLookVertical(float value, UdonInputEventArgs args)` | Look up/down |\n\n### Input Method Events\n\n| Event | When Called |\n|-------|-------------|\n| `void OnInputMethodChanged(VRCInputMethod inputMethod)` | Player switches input method (keyboard, controller, touch) |\n\n```csharp\npublic override void OnInputMethodChanged(VRCInputMethod inputMethod)\n{\n // Adjust UI layout when player switches between controller, keyboard, or touch\n UpdateControlHints(inputMethod);\n}\n```\n\n```csharp\npublic override void InputJump(bool value, VRC.Udon.Common.UdonInputEventArgs args)\n{\n if (value) // Button pressed (not released)\n {\n Debug.Log(\"Jump pressed!\");\n }\n}\n\npublic override void InputMoveHorizontal(float value, VRC.Udon.Common.UdonInputEventArgs args)\n{\n // value is -1 to 1\n Debug.Log($\"Horizontal input: {value}\");\n}\n```\n\n## Interact Event\n\nCalled when a player interacts with the object (requires a Collider).\n\n```csharp\npublic override void Interact()\n{\n Debug.Log($\"{Networking.LocalPlayer.displayName} interacted with this!\");\n}\n```\n\n**Requirements:**\n- GameObject must have a Collider\n- Collider must NOT be set to \"Is Trigger\" (for default interact)\n- Set \"Interact Text\" in UdonBehaviour component to customize prompt\n\n## Pickup Events\n\nCalled on objects with a VRC_Pickup component.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPickup()` | When picked up |\n| `void OnDrop()` | When dropped |\n| `void OnPickupUseDown()` | When use button pressed while holding |\n| `void OnPickupUseUp()` | When use button released while holding |\n\n```csharp\npublic override void OnPickup()\n{\n Debug.Log(\"Picked up!\");\n}\n\npublic override void OnDrop()\n{\n Debug.Log(\"Dropped!\");\n}\n\npublic override void OnPickupUseDown()\n{\n // Fire weapon, activate tool, etc.\n DoAction();\n}\n\npublic override void OnPickupUseUp()\n{\n // Release trigger, stop action\n StopAction();\n}\n```\n\n## Player Events\n\nCalled when players join, leave, or change state.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPlayerJoined(VRCPlayerApi player)` | Player joins instance |\n| `void OnPlayerLeft(VRCPlayerApi player)` | Player leaves instance |\n| `void OnPlayerRespawn(VRCPlayerApi player)` | Player respawns |\n| `void OnMasterTransferred(VRCPlayerApi newMaster)` | Instance master role transfers to a new player |\n| `void OnPlayerSuspendChanged(VRCPlayerApi player)` | Player suspend state changes (headset removed, app backgrounded) |\n| `void OnAvatarChanged(VRCPlayerApi player)` | Player changes their avatar |\n| `void OnAvatarEyeHeightChanged(VRCPlayerApi player, float prevEyeHeightAsMeters)` | Player avatar eye height changes |\n| `void OnLanguageChanged(string language)` | Player changes their display language |\n| `void OnVRCPlusMassGift(VRCPlayerApi gifter, int numGifts)` | VRC+ mass gift event occurs in the instance |\n\n```csharp\npublic override void OnPlayerJoined(VRCPlayerApi player)\n{\n Debug.Log($\"{player.displayName} joined!\");\n\n // Sync state for new player if we own the object\n if (Networking.IsOwner(gameObject))\n {\n RequestSerialization();\n }\n}\n\npublic override void OnPlayerLeft(VRCPlayerApi player)\n{\n Debug.Log($\"{player.displayName} left!\");\n // Clean up player-specific data\n}\n```\n\n## Persistence Events (SDK 3.7.4+)\n\nCalled during PlayerData persistence operations.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPlayerRestored(VRCPlayerApi player)` | Player's saved data has been loaded |\n| `void OnPlayerDataUpdated(VRCPlayerApi player, PlayerData.Info[] infos)` | PlayerData was updated |\n| `void OnPersistenceUsageUpdated()` | Persistence usage statistics are updated |\n| `void OnPlayerDataStorageExceeded(VRCPlayerApi player)` | Player data storage limit exceeded |\n| `void OnPlayerDataStorageWarning(VRCPlayerApi player)` | Player data storage approaching limit |\n| `void OnPlayerObjectStorageExceeded(VRCPlayerApi player)` | Player object storage limit exceeded |\n| `void OnPlayerObjectStorageWarning(VRCPlayerApi player)` | Player object storage approaching limit |\n\n> **SDK Note**: `OnPersistenceUsageUpdated` and the storage warning/exceeded events require SDK 3.10.0+. The `OnPlayerRestored` and `OnPlayerDataUpdated` events above are available from SDK 3.7.4+.\n\n```csharp\npublic override void OnPlayerRestored(VRCPlayerApi player)\n{\n if (!player.isLocal) return;\n\n Debug.Log($\"{player.displayName}'s data restored!\");\n\n // Access PlayerData to load saved data\n if (PlayerData.TryGetInt(player, \"highScore\", out int score))\n {\n highScoreDisplay.text = $\"High Score: {score}\";\n }\n}\n```\n\n**Important:** Do not access PlayerData until `OnPlayerRestored` has been called.\n\n## VRChat Dynamics Events (SDK 3.10.0+)\n\nCalled for PhysBones and Contacts in worlds.\n\n### Contact Events\n\n| Event | When Called |\n|-------|-------------|\n| `void OnContactEnter(ContactEnterInfo info)` | Contact sender starts contacting receiver |\n| `void OnContactStay(ContactStayInfo info)` | Contact ongoing |\n| `void OnContactExit(ContactExitInfo info)` | Contact ends |\n\n```csharp\npublic override void OnContactEnter(ContactEnterInfo info)\n{\n Debug.Log($\"Contact from: {info.senderName}\");\n\n // Determine if contact is from avatar or world object\n if (info.isAvatar)\n {\n // Contact from avatar\n VRCPlayerApi player = info.player;\n if (player != null && player.IsValid())\n {\n Debug.Log($\"Touched by: {player.displayName}\");\n }\n }\n else\n {\n // Contact from world object\n Debug.Log(\"Touched by world object\");\n }\n}\n\npublic override void OnContactExit(ContactExitInfo info)\n{\n Debug.Log($\"Contact ended: {info.senderName}\");\n}\n```\n\n### PhysBones Events\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPhysBoneGrab(PhysBoneGrabInfo info)` | PhysBone grabbed |\n| `void OnPhysBoneRelease(PhysBoneReleaseInfo info)` | PhysBone released |\n| `void OnPhysBoneColliderEnter(PhysBoneColliderInfo info)` | A PhysBone collider starts intersecting the bone chain |\n| `void OnPhysBoneColliderStay(PhysBoneColliderInfo info)` | A PhysBone collider continues to intersect the bone chain |\n| `void OnPhysBoneColliderExit(PhysBoneColliderInfo info)` | A PhysBone collider stops intersecting the bone chain |\n| `void OnPhysBonePosed(PhysBonePosedInfo physBoneInfo)` | A PhysBone is manually posed by a player |\n| `void OnPhysBoneUnPosed(PhysBoneUnPosedInfo physBoneInfo)` | A PhysBone pose is released |\n\n```csharp\npublic override void OnPhysBoneGrab(PhysBoneGrabInfo info)\n{\n Debug.Log($\"PhysBone grabbed by {info.player?.displayName}\");\n}\n\npublic override void OnPhysBoneRelease(PhysBoneReleaseInfo info)\n{\n Debug.Log($\"PhysBone released\");\n}\n\npublic override void OnPhysBoneColliderEnter(PhysBoneColliderInfo info)\n{\n // info.isAvatar — true if the collider belongs to an avatar\n // info.player — player reference (valid when isAvatar is true)\n // info.bone — the specific bone transform that was hit\n Debug.Log($\"PhysBone collider entered — bone: {info.bone?.name}, \" +\n $\"avatar: {info.isAvatar}, player: {info.player?.displayName}\");\n}\n\npublic override void OnPhysBoneColliderStay(PhysBoneColliderInfo info)\n{\n // Called every frame while the collider intersects. Keep this lightweight.\n}\n\npublic override void OnPhysBoneColliderExit(PhysBoneColliderInfo info)\n{\n Debug.Log($\"PhysBone collider exited — bone: {info.bone?.name}\");\n}\n```\n\n**Note:** Contact/PhysBone events are triggered on all UdonBehaviours attached to the same GameObject as the receiver.\n\n## Player Trigger/Collision Events\n\nCalled when players enter/exit triggers or collide.\n\n### Trigger Events\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPlayerTriggerEnter(VRCPlayerApi player)` | Player enters trigger |\n| `void OnPlayerTriggerStay(VRCPlayerApi player)` | Player stays in trigger |\n| `void OnPlayerTriggerExit(VRCPlayerApi player)` | Player exits trigger |\n\n### Collision Events\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPlayerCollisionEnter(VRCPlayerApi player)` | Player collision starts |\n| `void OnPlayerCollisionStay(VRCPlayerApi player)` | Player collision ongoing |\n| `void OnPlayerCollisionExit(VRCPlayerApi player)` | Player collision ends |\n\n### CharacterController Collision Events\n\nCalled when a CharacterController on this GameObject collides with a player or another collider.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnControllerColliderHitPlayer(ControllerColliderPlayerHit hit)` | CharacterController on this GameObject hits a player |\n\n**`ControllerColliderPlayerHit` fields** (`VRC.SDK3.ControllerColliderPlayerHit`):\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `player` | `VRCPlayerApi` | The player who was hit |\n| `moveDirection` | `Vector3` | Direction the CharacterController was moving |\n| `moveLength` | `float` | Distance the CharacterController moved |\n| `normal` | `Vector3` | Surface normal at the collision point |\n| `point` | `Vector3` | World-space collision point |\n\n```csharp\npublic override void OnControllerColliderHitPlayer(ControllerColliderPlayerHit hit)\n{\n VRCPlayerApi hitPlayer = hit.player;\n if (hitPlayer == null || !hitPlayer.IsValid()) return;\n\n // Apply knockback force based on collision direction\n float knockbackStrength = hit.moveLength * 2.0f;\n Vector3 knockbackDir = -hit.normal;\n // Use the collision data to drive gameplay logic\n}\n```\n\n**Difference from OnPlayerCollisionEnter**: `OnPlayerCollisionEnter` fires when a player's capsule collider enters a Collider on this GameObject. `OnControllerColliderHitPlayer` fires when a CharacterController on this GameObject actively moves into a player — the direction of contact is reversed.\n\n**Non-player variant**: `OnControllerColliderHit(ControllerColliderHit hit)` is a standard Unity callback (no `override`) that fires when the CharacterController hits a non-player collider. VRChat internally routes the Unity `OnControllerColliderHit` callback to either the player or non-player event based on whether the hit object belongs to a player.\n\n**Requirements:**\n- GameObject must have a `CharacterController` component\n- Event fires on the GameObject that owns the CharacterController, not on the hit target\n\n### Particle Collision Event\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPlayerParticleCollision(VRCPlayerApi player)` | Particle hits player |\n\n```csharp\npublic override void OnPlayerTriggerEnter(VRCPlayerApi player)\n{\n if (player.isLocal)\n {\n // Only affect local player\n ShowWelcomeMessage();\n }\n}\n```\n\n**Requirements:**\n- GameObject must have Collider with \"Is Trigger\" checked\n- For collision events, Collider must NOT be trigger\n\n## Drone Events\n\nCalled when a player's drone enters or exits a trigger collider attached to the same GameObject as this behaviour.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnDroneTriggerEnter(Collider other)` | Drone enters the trigger |\n| `void OnDroneTriggerStay(Collider other)` | Drone stays in the trigger (called each frame) |\n| `void OnDroneTriggerExit(Collider other)` | Drone exits the trigger |\n\n```csharp\npublic override void OnDroneTriggerEnter(Collider other)\n{\n VRCDroneApi drone = Networking.LocalPlayer.GetDrone();\n if (!Utilities.IsValid(drone)) return;\n\n VRCPlayerApi pilot = drone.GetPlayer();\n if (Utilities.IsValid(pilot))\n {\n Debug.Log($\"{pilot.displayName}'s drone entered the zone\");\n }\n}\n\npublic override void OnDroneTriggerExit(Collider other)\n{\n Debug.Log(\"Drone exited the trigger zone\");\n}\n```\n\n**Requirements:**\n- GameObject must have a Collider with \"Is Trigger\" checked\n- Events fire only on the local client\n\n## Networking Events\n\nCalled during sync and ownership changes.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPreSerialization()` | Before data is serialized (owner only) |\n| `void OnDeserialization()` | After receiving synced data |\n| `void OnDeserialization(DeserializationResult result)` | With result info |\n| `void OnPostSerialization(SerializationResult result)` | After serialization complete |\n| `void OnOwnershipTransferred(VRCPlayerApi player)` | Ownership changed |\n| `bool OnOwnershipRequest(VRCPlayerApi requestingPlayer, VRCPlayerApi requestedOwner)` | Ownership requested (return `true` to accept, `false` to reject) |\n\n```csharp\npublic override void OnPreSerialization()\n{\n // Prepare data before sending (owner only)\n // Good place to pack data or update timestamps\n}\n\npublic override void OnDeserialization()\n{\n // Data received from owner\n UpdateDisplay();\n}\n\npublic override void OnPostSerialization(SerializationResult result)\n{\n if (!result.success)\n {\n Debug.LogError($\"Serialization failed!\");\n }\n Debug.Log($\"Sent {result.byteCount} bytes\");\n}\n\npublic override void OnOwnershipTransferred(VRCPlayerApi player)\n{\n Debug.Log($\"New owner: {player.displayName}\");\n\n if (player.isLocal)\n {\n // We are now the owner\n OnBecameOwner();\n }\n}\n```\n\n### Ownership Request Arbitration\n\n`OnOwnershipRequest` allows the current owner to accept or reject ownership transfers. The callback runs locally on **both the requester and the current owner**, so the logic must return the same result on both sides to avoid desync.\n\n```csharp\n/// \u003csummary>\n/// Called on both the requester's and the current owner's clients\n/// when another player requests ownership. Return true to accept,\n/// false to reject the transfer. The result MUST agree on both sides.\n/// \u003c/summary>\npublic override bool OnOwnershipRequest(\n VRCPlayerApi requestingPlayer,\n VRCPlayerApi requestedOwner)\n{\n if (requestingPlayer == null || !requestingPlayer.IsValid()) return true;\n\n // Example: Only allow ownership transfer when game is in lobby phase\n if (gamePhase != 0)\n {\n return false; // Reject during active gameplay\n }\n\n return true;\n}\n```\n\n> **Important**: This callback runs locally on **both the requester and the current owner**. The logic must return the same result on both sides to avoid desync. If the current owner has disconnected, the callback is not invoked — VRChat auto-assigns a new owner directly.\n\n## Station Events\n\nCalled when a player enters/exits a VRCStation (seat, vehicle).\n\n| Event | When Called |\n|-------|-------------|\n| `void OnStationEntered(VRCPlayerApi player)` | Player sat down |\n| `void OnStationExited(VRCPlayerApi player)` | Player stood up |\n\n```csharp\npublic override void OnStationEntered(VRCPlayerApi player)\n{\n Debug.Log($\"{player.displayName} sat down\");\n\n if (player.isLocal)\n {\n // Start vehicle controls\n EnableVehicleControls();\n }\n}\n\npublic override void OnStationExited(VRCPlayerApi player)\n{\n if (player.isLocal)\n {\n DisableVehicleControls();\n }\n}\n```\n\n## Video Player Events\n\nCalled by VRCUnityVideoPlayer or AVProVideoPlayer.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnVideoStart()` | Video starts playing |\n| `void OnVideoEnd()` | Video ends |\n| `void OnVideoPause()` | Video paused |\n| `void OnVideoPlay()` | Video resumed |\n| `void OnVideoReady()` | Video loaded and ready |\n| `void OnVideoError(VideoError error)` | Error occurred |\n| `void OnVideoLoop()` | Video looped |\n\n```csharp\npublic override void OnVideoReady()\n{\n Debug.Log(\"Video ready to play\");\n}\n\npublic override void OnVideoError(VideoError videoError)\n{\n Debug.LogError($\"Video error: {videoError}\");\n}\n```\n\n## String/Image Loading Events\n\nCalled after VRCStringDownloader or VRCImageDownloader requests.\nFor API details, constraints, and practical patterns, see `references/web-loading.md`.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnStringLoadSuccess(IVRCStringDownload result)` | String download succeeded |\n| `void OnStringLoadError(IVRCStringDownload result)` | String download failed |\n| `void OnImageLoadSuccess(IVRCImageDownload result)` | Image download succeeded |\n| `void OnImageLoadError(IVRCImageDownload result)` | Image download failed |\n\n**IVRCStringDownload**: `Result` (string), `ResultBytes` (byte[]), `Error` (string), `ErrorCode` (int), `Url` (VRCUrl)\n\n**IVRCImageDownload**: `Result` (Texture2D), `SizeInMemoryBytes` (int), `Error` (string), `ErrorCode` (int), `Material`, `TextureInfo`\n\n```csharp\npublic override void OnStringLoadSuccess(IVRCStringDownload result)\n{\n string data = result.Result;\n Debug.Log($\"Downloaded: {data}\");\n}\n\npublic override void OnStringLoadError(IVRCStringDownload result)\n{\n Debug.LogError($\"Download failed ({result.ErrorCode}): {result.Error}\");\n}\n```\n\n## MIDI Events\n\nCalled when MIDI input is received (PC only).\n\n| Event | When Called |\n|-------|-------------|\n| `void MidiNoteOn(int channel, int note, int velocity)` | Note pressed |\n| `void MidiNoteOff(int channel, int note, int velocity)` | Note released |\n| `void MidiControlChange(int channel, int number, int value)` | Control changed |\n\n```csharp\npublic override void MidiNoteOn(int channel, int note, int velocity)\n{\n Debug.Log($\"Note on: channel={channel}, note={note}, velocity={velocity}\");\n PlayNote(note, velocity / 127f);\n}\n```\n\n## Object Pool Events\n\nCalled on GameObjects spawned from a VRCObjectPool.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnSpawn()` | This object is spawned from a VRCObjectPool |\n\n```csharp\npublic override void OnSpawn()\n{\n // Initialize state when this pooled object becomes active\n _isActive = true;\n _spawnTime = Time.time;\n}\n```\n\n**Requirements:**\n- GameObject must be part of a VRCObjectPool's Pool array\n- Called after the object is enabled by `Pool.TryToSpawn()`\n\n## Settings & Rendering Events\n\nCalled when VRChat settings or rendering state changes. These events require SDK 3.10.0+.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnVRCQualitySettingsChanged()` | VRChat quality settings change |\n| `void OnVRCCameraSettingsChanged(VRCCameraSettings cameraSettings)` | VRChat camera settings change |\n| `void OnScreenUpdate(ScreenUpdateData data)` | Screen update data is available |\n| `void OnAsyncGpuReadbackComplete(VRCAsyncGPUReadbackRequest request)` | Async GPU readback operation completes |\n\n```csharp\npublic override void OnVRCQualitySettingsChanged()\n{\n // Adapt visual effects when the player changes quality settings\n UpdateParticleEffects();\n}\n\npublic override void OnAsyncGpuReadbackComplete(VRCAsyncGPUReadbackRequest request)\n{\n if (request.hasError) return;\n // Process GPU readback data\n}\n```\n\n## VRC Economy Events\n\nCalled in response to VRChat Creator Economy operations. Requires Udon Products configured in the world.\n\n| Event | When Called |\n|-------|-------------|\n| `void OnPurchaseConfirmed(IProduct product, VRCPlayerApi player, bool purchasedNow)` | A product purchase is confirmed |\n| `void OnPurchaseConfirmedMultiple(IProduct product, VRCPlayerApi player, bool purchasedNow, int quantity)` | Multiple product purchases confirmed |\n| `void OnPurchaseExpired(IProduct product, VRCPlayerApi player)` | A product purchase expires |\n| `void OnPurchasesLoaded(IProduct[] products, VRCPlayerApi player)` | Player's purchase data finishes loading |\n| `void OnListAvailableProducts(IProduct[] products)` | Available products list is returned |\n| `void OnListProductOwners(IProduct product, string[] owners)` | Product owner list is returned |\n| `void OnListPurchases(IProduct[] products, VRCPlayerApi player)` | Player's purchase list is returned |\n| `void OnProductEvent(IProduct product, VRCPlayerApi player)` | A product-related event occurs |\n\n```csharp\npublic override void OnPurchaseConfirmed(IProduct product, VRCPlayerApi player, bool purchasedNow)\n{\n if (!player.isLocal) return;\n // Grant access to purchased content\n UnlockContent(product);\n}\n\npublic override void OnPurchasesLoaded(IProduct[] products, VRCPlayerApi player)\n{\n if (!player.isLocal) return;\n // Restore previously purchased items on join\n foreach (var product in products)\n {\n UnlockContent(product);\n }\n}\n```\n\n**Requirements:**\n- World must have Udon Products configured in the VRChat Creator Economy dashboard\n- Types `IProduct` are from `VRC.Economy` namespace\n\n## Standard Unity Events\n\nStandard Unity events that work in UdonSharp.\n\n### Lifecycle\n\n| Event | When Called |\n|-------|-------------|\n| `void Start()` | First frame (after all Awake) |\n| `void OnEnable()` | When enabled |\n| `void OnDisable()` | When disabled |\n| `void OnDestroy()` | When destroyed |\n\n### Physics\n\n| Event | When Called |\n|-------|-------------|\n| `void OnTriggerEnter(Collider other)` | Object enters trigger |\n| `void OnTriggerStay(Collider other)` | Object stays in trigger |\n| `void OnTriggerExit(Collider other)` | Object exits trigger |\n| `void OnCollisionEnter(Collision collision)` | Collision starts |\n| `void OnCollisionStay(Collision collision)` | Collision ongoing |\n| `void OnCollisionExit(Collision collision)` | Collision ends |\n\n### Rendering\n\n| Event | When Called |\n|-------|-------------|\n| `void OnWillRenderObject()` | Before rendering |\n| `void OnBecameVisible()` | Became visible to camera |\n| `void OnBecameInvisible()` | No longer visible |\n\n### Animation\n\n| Event | When Called |\n|-------|-------------|\n| `void OnAnimatorMove()` | Animator root motion update |\n| `void OnAnimatorIK(int layerIndex)` | IK pass |\n\n```csharp\nvoid Start()\n{\n // Initialize after all Awake calls\n InitializeComponents();\n}\n\nvoid OnTriggerEnter(Collider other)\n{\n // Non-player object entered trigger\n if (other.CompareTag(\"Projectile\"))\n {\n TakeDamage();\n }\n}\n```\n\n## Event Execution Order\n\nReference: [VRChat Official Docs — Event Execution Order](https://creators.vrchat.com/worlds/udon/event-execution-order/)\n\n### Per-Frame Lifecycle\n\nThe Unity/VRChat per-frame execution order (steady state, every frame):\n\n| Step | Event | Notes |\n|------|-------|-------|\n| 1 | `OnEnable()` | Only on the frame the behaviour becomes enabled |\n| 2 | `Start()` | Only on the first frame the behaviour is active |\n| 3 | `FixedUpdate()` | Physics tick (~50 Hz); may run 0 or more times per frame |\n| 4 | `Update()` | Every render frame |\n| 5 | `LateUpdate()` | After all `Update` calls |\n| 6 | `PostLateUpdate()` | VRChat-specific; after `LateUpdate`, before render |\n\n> **Note**: `Awake()` is **not available** in UdonSharp. Use `Start()` for one-time initialization instead.\n\n**Networking events** (`OnDeserialization`, `OnPreSerialization`, etc.) are dispatched between frames and can fire at any point outside the per-frame order above.\n\n---\n\n### Initialization Guarantee\n\n`OnEnable` and `Start` are guaranteed to run **before any other event** fires on the behaviour, and they run with **no gap between them** on the initial activation. This means:\n\n- You can safely access component references set up in `Start()` from any event handler.\n- No VRChat event (player join, deserialization, etc.) will interrupt `OnEnable`/`Start`.\n\n---\n\n### Scenario: Instance Creator (First Player)\n\nWhen you are the first player to enter an instance:\n\n```text\n_onEnable → _start\n ↓\nOnPlayerJoined(self) ← fires for yourself\n ↓\nOnMasterChanged ← master transferred from nobody to you\n```\n\n- You are immediately both Master and Owner of scene objects.\n- No `OnDeserialization` fires because there is no prior state to receive.\n\n---\n\n### Scenario: Late Joiner\n\nWhen you join an existing instance:\n\n```text\n_onEnable → _start\n ↓\nOnPlayerJoined(player A) ← for each player already in the instance\nOnPlayerJoined(player B) ← (order matches instance join order)\nOnPlayerJoined(self) ← last: your own join event\n ↓\nOnDeserialization ← receives synced variable state from owner\n```\n\n> **Note**: `OnPlayerJoined` fires for **every** player currently in the instance, including yourself. You will always be the last entry in this sequence.\n\n> **Note**: Synced variable values are **not guaranteed to be initialized** before `OnDeserialization` fires. Do not read synced variables in `Start()` for late joiners — they may still be at default values.\n\n#### Edge Case: Owner Calls RequestSerialization Near OnPlayerJoined\n\nIf the current owner calls `RequestSerialization()` at or very close to the time a late joiner's `OnPlayerJoined` fires (for example, in their own `OnPlayerJoined` handler), the following race condition can occur on the **late joiner's client**:\n\n1. Synced variable value arrives and changes.\n2. `OnVariableChanged` fires for the changed variable.\n3. `OnDeserialization` fires immediately after.\n\nIn this specific edge case (most likely when the late joiner is the **first instance** on its client), `OnVariableChanged` can fire **before** `Start()` has returned. Guard against this with an initialization flag (see pattern below).\n\n---\n\n### Scenario: Another Player Joins Your Instance\n\nWhen a new player joins while you are already in the instance:\n\n```text\nOnPlayerJoined(newPlayer) ← fires only for the newly joined player\n```\n\nIf you are the owner of synced objects, this is the correct place to call `RequestSerialization()` to push current state to the late joiner:\n\n```csharp\npublic override void OnPlayerJoined(VRCPlayerApi player)\n{\n if (Networking.IsOwner(gameObject))\n {\n RequestSerialization();\n }\n}\n```\n\n---\n\n### Practical Patterns\n\n#### Do Not Access Synced State in Start()\n\n```csharp\n// WRONG: syncedScore may still be 0 (default) for a late joiner\nvoid Start()\n{\n UpdateScoreDisplay(syncedScore);\n}\n\n// CORRECT: wait for OnDeserialization before reading synced state\npublic override void OnDeserialization()\n{\n UpdateScoreDisplay(syncedScore);\n}\n```\n\n#### Initialization Flag Guard\n\nUse a `_isInitialized` flag to ensure setup code runs exactly once after the first `OnDeserialization`, and to guard against `OnVariableChanged` firing before `Start()` completes:\n\n```csharp\n[UdonSynced] private int _syncedScore;\nprivate bool _isInitialized;\n\nvoid Start()\n{\n _isInitialized = false;\n}\n\npublic override void OnDeserialization()\n{\n if (!_isInitialized)\n {\n _isInitialized = true;\n InitializeFromSyncedState();\n }\n UpdateDisplay();\n}\n\n// OnVariableChanged can fire before Start() in edge cases — guard with the flag\npublic override void OnVariableChanged()\n{\n if (!_isInitialized) return;\n UpdateDisplay();\n}\n\nprivate void InitializeFromSyncedState()\n{\n UpdateDisplay();\n // Perform any one-time setup that depends on synced variables\n}\n```\n\n> **Note**: On the **instance creator's client**, `OnDeserialization` never fires on initial load (there is no prior state). Initialize with default values in `Start()` and let `OnDeserialization` handle updates from that point on.\n\n## Best Practices\n\n### Player Validity Check\n\n```csharp\npublic override void OnPlayerTriggerEnter(VRCPlayerApi player)\n{\n if (player == null || !player.IsValid())\n {\n return;\n }\n // Safe to use player\n}\n```\n\n### Local vs All Players\n\n```csharp\npublic override void OnPlayerJoined(VRCPlayerApi player)\n{\n // This runs for ALL players in the instance\n\n if (player.isLocal)\n {\n // Only runs for the joining player themselves\n ShowTutorial();\n }\n}\n```\n\n### Ownership Check Before Sync\n\n```csharp\npublic override void OnPlayerJoined(VRCPlayerApi player)\n{\n // Only owner should trigger sync for late joiners\n if (Networking.IsOwner(gameObject))\n {\n RequestSerialization();\n }\n}\n```\n\n## See Also\n\n- [api.md](api.md) - VRCPlayerApi and Networking class reference for types used in event handlers\n- [dynamics.md](dynamics.md) - PhysBone and Contact component setup for the events listed here\n- [networking.md](networking.md) - Serialization and ownership events in depth (`OnDeserialization`, `OnOwnershipTransferred`)\n- [patterns-video.md](patterns-video.md) - Video player event handling patterns (`OnVideoReady`, `OnVideoError`, `OnVideoStart`, `OnVideoEnd`)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":32813,"content_sha256":"39656dcb5ef5edd2dbe581dfa2a0c9b8c3643fe61765caeb4e0bedfdceea66f4"},{"filename":"references/image-loading-vram.md","content":"# Image Loading — VRAM & Memory Management\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\nExtended memory management guide for `VRCImageDownloader`. Covers GPU memory lifecycle,\nsafe texture cleanup, double-buffer fade, stock vs. streaming mode, mipmap bias control,\nand multi-instance staggering. For the base API reference see [web-loading.md](web-loading.md).\n\n## Overview\n\n| Topic | Summary |\n|-------|---------|\n| **VRAM accumulation** | Why textures stay in GPU memory and how leaks occur |\n| **Dispose vs Destroy** | What each frees, when to use which |\n| **Double-buffer fade** | Smooth crossfade between images without destroying visible textures |\n| **Stock vs streaming mode** | Cache-all vs download-every-cycle tradeoff |\n| **Mipmap bias control** | Distance-based sharpness tuning to reduce VRAM at range |\n| **Multi-instance staggering** | Spreading download start times to avoid rate-limit pile-ups |\n| **Anti-patterns** | Common mistakes and their correct alternatives |\n\n---\n\n## VRAM Accumulation Mechanism\n\n### Managed Memory vs GPU Memory\n\nC# memory is managed by the .NET garbage collector (GC). When you stop holding a reference\nto a C# object, the GC can reclaim it. However, `Texture2D` is a **Unity engine object**:\nit has a small C# wrapper, but the actual pixel data lives in **unmanaged GPU memory** (VRAM).\nThe GC sees only the tiny C# wrapper and cannot reclaim the GPU allocation.\n\n```text\nC# heap (managed): GPU VRAM (unmanaged):\n┌──────────────────────────┐ ┌───────────────────────────────┐\n│ Texture2D wrapper (40 B)│──────> │ Pixel data (width×height×bpp)│\n└──────────────────────────┘ └───────────────────────────────┘\n GC can free this GC cannot touch this\n```\n\nWhen `Texture2D` goes out of scope in C#, the wrapper is eventually collected, but the\nGPU allocation persists until `UnityEngine.Object.Destroy(texture)` is explicitly called.\n\n### What Happens Without Cleanup\n\nEach call to `VRCImageDownloader.DownloadImage()` creates a new `Texture2D` in VRAM.\nIf you overwrite a reference without destroying the old texture, the old VRAM allocation\nis silently abandoned:\n\n```csharp\n// This creates a leak every time a new image loads\npublic override void OnImageLoadSuccess(IVRCImageDownload result)\n{\n // result.Result is a NEW Texture2D in VRAM.\n // The texture that was in targetMaterial.mainTexture before is still in VRAM\n // and now has no reference — it will never be freed.\n targetMaterial.mainTexture = result.Result;\n}\n```\n\nOver time, VRAM grows without bound. In a world that cycles images every 30 seconds,\na 2048x2048 RGBA32 texture consumes about 16 MB per download. After 20 cycles:\n**320 MB of leaked VRAM**. This causes degraded performance and eventually crashes.\n\n### VRAM Cost Reference\n\n| Resolution | Format | VRAM (no mipmaps) | VRAM (with mipmaps) |\n|------------|--------|-------------------|---------------------|\n| 512 x 512 | RGBA32 | ~1 MB | ~1.3 MB |\n| 1024 x 1024 | RGBA32 | ~4 MB | ~5.3 MB |\n| 2048 x 2048 | RGBA32 | ~16 MB | ~21.3 MB |\n| 2048 x 2048 | RGB24 | ~12 MB | ~16 MB |\n\n> Mipmaps add approximately 33% to the base texture size.\n\n---\n\n## `Dispose()` vs `Destroy()` — Critical Distinction\n\nThese two operations are often confused. They free **different things**.\n\n| Operation | What It Frees | What It Does NOT Free |\n|-----------|--------------|----------------------|\n| `IVRCImageDownload.Dispose()` | The download result wrapper and its internal state | The `Texture2D` already assigned to a material |\n| `VRCImageDownloader.Dispose()` | All pending and completed download result wrappers | Textures already assigned to materials |\n| `UnityEngine.Object.Destroy(texture)` | The GPU VRAM allocation for that `Texture2D` | Nothing else — only this one texture |\n\n### The Golden Rule\n\nAfter you assign `result.Result` to a material, the `VRCImageDownloader` no longer owns\nthat texture — **you do**. Calling `Dispose()` on the downloader or the download result\ndoes not free a texture you have applied to a material. You must call\n`UnityEngine.Object.Destroy(oldTexture)` yourself.\n\n### Correct Cleanup Sequence\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.ImageLoading;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class SafeImageLoader : UdonSharpBehaviour\n{\n [SerializeField] private VRCUrl imageUrl;\n [SerializeField] private Renderer targetRenderer;\n\n private VRCImageDownloader _downloader;\n private IVRCImageDownload _currentDownload;\n\n /** Texture currently displayed on the renderer — we own this VRAM allocation */\n private Texture2D _activeTexture;\n\n void Start()\n {\n _downloader = new VRCImageDownloader();\n }\n\n public void LoadImage()\n {\n // Dispose the previous download result wrapper (frees VRC internal state)\n if (_currentDownload != null)\n {\n _currentDownload.Dispose();\n _currentDownload = null;\n }\n\n TextureInfo info = new TextureInfo();\n info.GenerateMipmaps = true;\n info.FilterMode = FilterMode.Trilinear;\n\n _currentDownload = _downloader.DownloadImage(\n imageUrl,\n null, // pass null — we apply the texture manually\n (IUdonEventReceiver)this,\n info\n );\n }\n\n public override void OnImageLoadSuccess(IVRCImageDownload result)\n {\n // Step 1: Destroy the OLD texture to free its VRAM\n if (_activeTexture != null)\n {\n Destroy(_activeTexture);\n _activeTexture = null;\n }\n\n // Step 2: Take ownership of the new texture\n _activeTexture = result.Result;\n\n // Step 3: Apply to renderer\n if (targetRenderer != null)\n {\n targetRenderer.material.mainTexture = _activeTexture;\n }\n }\n\n public override void OnImageLoadError(IVRCImageDownload result)\n {\n Debug.LogError($\"[SafeImageLoader] Error {result.ErrorCode}: {result.Error}\");\n }\n\n void OnDestroy()\n {\n // Clean up everything when this behaviour is destroyed\n if (_downloader != null)\n {\n _downloader.Dispose();\n }\n\n // The downloader.Dispose() above freed the VRC wrappers.\n // We still need to destroy the texture we own separately.\n if (_activeTexture != null)\n {\n Destroy(_activeTexture);\n _activeTexture = null;\n }\n }\n}\n```\n\n---\n\n## Double-Buffer Fade Pattern\n\n### Why a Single Texture Is Not Enough\n\nWhen you want a smooth crossfade between images, you cannot destroy the old texture the\nmoment the new one arrives — it is still visible on screen during the fade animation.\nDestroying it mid-fade produces a black flash or missing texture.\n\nThe solution is a **double-buffer**: two Renderer slots (A and B) that alternate roles.\nThe new image loads into the \"back\" slot, a fade animation plays, and once the fade is\ncomplete the old texture is safe to destroy.\n\n```text\nState 0 — Image X is displayed on Renderer A (front)\n Renderer B is idle (back)\n\nState 1 — Download Image Y completes\n Apply Image Y to Renderer B (back)\n Begin fade: A fades out, B fades in\n\nState 2 — Fade complete\n Renderer B is now front; Renderer A is back\n NOW safe to Destroy Image X texture\n Swap front/back references for next cycle\n```\n\n### Important: No Coroutines in UdonSharp\n\nUdonSharp does not support C# coroutines (`IEnumerator` / `yield return`). For\ntime-delayed operations, use `SendCustomEventDelayedSeconds(nameof(MethodName), delay)`.\n\n### Full Double-Buffer Implementation\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.ImageLoading;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class DoubleBufferImageDisplay : UdonSharpBehaviour\n{\n [Header(\"Renderers (A/B double buffer)\")]\n [SerializeField] private Renderer rendererA;\n [SerializeField] private Renderer rendererB;\n\n [Header(\"Fade settings\")]\n [SerializeField] private float fadeDuration = 1.0f;\n [SerializeField] private string alphaProperty = \"_Alpha\";\n\n [Header(\"Image URLs\")]\n [SerializeField] private VRCUrl[] imageUrls;\n [SerializeField] private float displayDuration = 10.0f;\n\n private VRCImageDownloader _downloader;\n private IVRCImageDownload _pendingDownload;\n\n /** Index into imageUrls for the next image to load */\n private int _nextUrlIndex = 0;\n\n /** Which renderer is currently showing (front) */\n private bool _frontIsA = true;\n\n /** Textures we own; must be Destroyed when no longer needed */\n private Texture2D _textureA;\n private Texture2D _textureB;\n\n /** Texture scheduled for destruction after fade completes */\n private Texture2D _textureToDispose;\n\n /** True while a fade animation is in progress */\n private bool _isFading = false;\n\n /** Elapsed seconds since fade started — driven by Update */\n private float _fadeElapsed = 0f;\n\n void Start()\n {\n _downloader = new VRCImageDownloader();\n\n // Initialize both renderers fully transparent\n SetRendererAlpha(rendererA, 0f);\n SetRendererAlpha(rendererB, 0f);\n\n // Load the first image\n LoadNextImage();\n }\n\n // ── Download ────────────────────────────────────────────────────────────\n\n private void LoadNextImage()\n {\n if (imageUrls == null || imageUrls.Length == 0) return;\n\n // Dispose previous pending download result wrapper\n if (_pendingDownload != null)\n {\n _pendingDownload.Dispose();\n _pendingDownload = null;\n }\n\n TextureInfo info = new TextureInfo();\n info.GenerateMipmaps = true;\n info.FilterMode = FilterMode.Trilinear;\n\n _pendingDownload = _downloader.DownloadImage(\n imageUrls[_nextUrlIndex],\n null,\n (IUdonEventReceiver)this,\n info\n );\n\n _nextUrlIndex = (_nextUrlIndex + 1) % imageUrls.Length;\n }\n\n public override void OnImageLoadSuccess(IVRCImageDownload result)\n {\n if (_isFading) return; // Ignore if a fade is already running\n\n Texture2D newTexture = result.Result;\n\n // Apply new texture to the BACK renderer\n Renderer backRenderer = _frontIsA ? rendererB : rendererA;\n backRenderer.material.mainTexture = newTexture;\n SetRendererAlpha(backRenderer, 0f);\n\n // Store reference so we can track which slot gets the new texture\n if (_frontIsA)\n {\n // New texture goes to B; remember current A texture for later disposal\n _textureToDispose = _textureA;\n _textureB = newTexture;\n }\n else\n {\n _textureToDispose = _textureB;\n _textureA = newTexture;\n }\n\n // Start the fade animation\n _isFading = true;\n _fadeElapsed = 0f;\n }\n\n public override void OnImageLoadError(IVRCImageDownload result)\n {\n Debug.LogError($\"[DoubleBuffer] Error {result.ErrorCode}: {result.Error}\");\n // Retry after display duration to keep cycling\n SendCustomEventDelayedSeconds(nameof(LoadNextImage), displayDuration);\n }\n\n // ── Fade animation via Update ────────────────────────────────────────────\n\n void Update()\n {\n if (!_isFading) return;\n\n _fadeElapsed += Time.deltaTime;\n float t = Mathf.Clamp01(_fadeElapsed / fadeDuration);\n\n Renderer front = _frontIsA ? rendererA : rendererB;\n Renderer back = _frontIsA ? rendererB : rendererA;\n\n SetRendererAlpha(front, 1f - t); // front fades out\n SetRendererAlpha(back, t); // back fades in\n\n if (t >= 1f)\n {\n OnFadeComplete();\n }\n }\n\n private void OnFadeComplete()\n {\n _isFading = false;\n\n // Swap front/back\n _frontIsA = !_frontIsA;\n\n // Hide the (now back) old renderer\n Renderer newBack = _frontIsA ? rendererB : rendererA;\n SetRendererAlpha(newBack, 0f);\n\n // The old texture is no longer visible — safe to destroy VRAM now\n // Use a small delay to ensure the render frame has fully committed\n SendCustomEventDelayedSeconds(nameof(DestroyOldTexture), 0.1f);\n\n // Schedule the next download after the display duration\n SendCustomEventDelayedSeconds(nameof(LoadNextImage), displayDuration);\n }\n\n /** Called via SendCustomEventDelayedSeconds after the fade is complete.\n * Must be public because SendCustomEventDelayedSeconds requires a public method.\n * Do not call directly from other behaviours. */\n public void DestroyOldTexture()\n {\n if (_textureToDispose != null)\n {\n Destroy(_textureToDispose);\n _textureToDispose = null;\n }\n }\n\n // ── Helpers ──────────────────────────────────────────────────────────────\n\n private void SetRendererAlpha(Renderer r, float alpha)\n {\n if (r == null) return;\n r.material.SetFloat(alphaProperty, alpha);\n }\n\n void OnDestroy()\n {\n if (_downloader != null) _downloader.Dispose();\n if (_textureA != null) Destroy(_textureA);\n if (_textureB != null) Destroy(_textureB);\n if (_textureToDispose != null) Destroy(_textureToDispose);\n }\n}\n```\n\n> **Shader requirement**: The `_Alpha` property used above must exist in the material's\n> shader. Standard transparent shaders expose `_Color.a`. For an opaque material you\n> would typically use two stacked renderers whose `_Color.a` or a custom blend parameter\n> is animated. Adjust `alphaProperty` to match your shader.\n\n---\n\n## Stock Mode vs Streaming Mode\n\n### Streaming Mode (Low VRAM, Repeated Bandwidth)\n\nDownload the next image each cycle. After the fade, destroy the old texture. VRAM holds\nat most two textures at once regardless of how many images are in the playlist.\n\n```text\nCycle 1: Download A → display A → VRAM: A\nCycle 2: Download B → fade A→B → Destroy A → VRAM: B\nCycle 3: Download C → fade B→C → Destroy B → VRAM: C\n...\n```\n\n**VRAM cost**: constant at 2× texture size (front + back during fade).\n**Bandwidth cost**: one download per cycle, every display cycle.\n\n### Stock Mode (High VRAM, No Repeat Downloads)\n\nDownload all images once, store the textures in a `Material[]` array. On repeat visits\nto the same image, use the cached texture — no download, instant transition.\n\n```text\nStartup: Download A, B, C, D → VRAM: A + B + C + D\nRuntime: Cycle through cached textures → no further downloads\n```\n\n**VRAM cost**: N × texture size, fixed.\n**Bandwidth cost**: one download per image, ever.\n\n### Decision Table\n\n| Criterion | Use Streaming | Use Stock |\n|-----------|:------------:|:---------:|\n| Playlist has many images (10+) | Yes | |\n| Playlist is small (2–6 images) | | Yes |\n| VRAM budget is tight (Quest) | Yes | |\n| VRAM budget is generous (PC) | | Yes |\n| Transitions must feel instant | | Yes |\n| Bandwidth conservation matters | | Yes |\n| Images change frequently (daily) | Yes | |\n| Users will revisit images repeatedly | | Yes |\n\n### Stock Mode Implementation\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.ImageLoading;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class StockModeGallery : UdonSharpBehaviour\n{\n [Header(\"Gallery URLs (all downloaded at startup)\")]\n [SerializeField] private VRCUrl[] imageUrls;\n\n [Header(\"Materials — one per image slot\")]\n [SerializeField] private Material[] galleryMaterials;\n\n [Header(\"Display renderer\")]\n [SerializeField] private Renderer displayRenderer;\n\n private VRCImageDownloader _downloader;\n\n /** Number of valid slots (min of imageUrls.Length and galleryMaterials.Length) */\n private int _count = 0;\n\n /** Which downloads have completed (cached flag per slot) */\n private bool[] _loaded;\n\n /** How many downloads are complete */\n private int _loadedCount = 0;\n\n /** Which slot is currently displayed */\n private int _displayIndex = 0;\n\n /** Whether startup downloads are still in progress */\n private bool _isLoading = true;\n\n /** Index of the next image to download in the sequential chain */\n private int _nextDownloadIndex = 0;\n\n void Start()\n {\n if (imageUrls == null || galleryMaterials == null) return;\n\n int count = Mathf.Min(imageUrls.Length, galleryMaterials.Length);\n _count = count;\n _loaded = new bool[count];\n _downloader = new VRCImageDownloader();\n\n // Start the sequential download chain (no loop — UdonSharp has no closures)\n DownloadNext();\n }\n\n public void DownloadNext()\n {\n int idx = _nextDownloadIndex;\n if (idx \u003c 0 || idx >= _count) return;\n\n TextureInfo info = new TextureInfo();\n info.GenerateMipmaps = true;\n info.FilterMode = FilterMode.Trilinear;\n\n _downloader.DownloadImage(\n imageUrls[idx],\n galleryMaterials[idx], // downloader applies texture to this material\n (IUdonEventReceiver)this,\n info\n );\n\n _nextDownloadIndex++;\n\n // Schedule the next download 5.5 s later to respect the 5-second rate limit\n if (_nextDownloadIndex \u003c _count)\n {\n SendCustomEventDelayedSeconds(nameof(DownloadNext), 5.5f);\n }\n }\n\n public override void OnImageLoadSuccess(IVRCImageDownload result)\n {\n // Find which slot matches the material the downloader filled\n for (int i = 0; i \u003c _count; i++)\n {\n if (galleryMaterials[i] == result.Material)\n {\n _loaded[i] = true;\n _loadedCount++;\n break;\n }\n }\n\n if (_loadedCount >= _count)\n {\n _isLoading = false;\n Debug.Log(\"[StockGallery] All images cached\");\n }\n }\n\n public override void OnImageLoadError(IVRCImageDownload result)\n {\n Debug.LogError($\"[StockGallery] Load error: {result.Error}\");\n // Note: the failed slot remains unloaded (_loaded[idx] stays false).\n // A production implementation should track and retry failed indices.\n }\n\n /** Show the image at the given index (instant swap from cache) */\n public void ShowImage(int index)\n {\n if (index \u003c 0 || index >= _count) return;\n if (!_loaded[index]) return; // Not ready yet\n\n _displayIndex = index;\n displayRenderer.material = galleryMaterials[index];\n }\n\n public void ShowNext()\n {\n int next = (_displayIndex + 1) % _count;\n ShowImage(next);\n }\n\n public void ShowPrev()\n {\n int prev = (_displayIndex - 1 + _count) % _count;\n ShowImage(prev);\n }\n\n public bool IsLoading => _isLoading;\n\n void OnDestroy()\n {\n // Note: textures are owned by the materials, not by us directly.\n // Dispose the downloader to release VRC wrapper objects.\n if (_downloader != null) _downloader.Dispose();\n }\n}\n```\n\n> **Quest note**: On Quest, each 2048x2048 RGBA texture costs ~16 MB of VRAM.\n> Caching 6 such textures consumes ~96 MB — test on Quest hardware before shipping stock mode.\n\n---\n\n## Distance-Based Mipmap Bias Control\n\n### What Is Mipmap Bias?\n\nMipmaps are pre-generated lower-resolution copies of a texture (1/2, 1/4, 1/8 ... size).\nThe GPU selects a mip level based on how many screen pixels the texture covers. A\n**mipmap bias** shifts that selection:\n\n| Bias | Effect | VRAM impact |\n|------|--------|-------------|\n| Negative (e.g. −2) | Forces a sharper (larger) mip level | Higher bandwidth, more cache pressure |\n| 0 | Default automatic selection | Neutral |\n| Positive (e.g. +2) | Forces a blurrier (smaller) mip level | Lower bandwidth, less cache pressure |\n\nBy computing bias from player distance you can save GPU memory and bandwidth when the\nplayer is far from an image display, while keeping it sharp up close.\n\n### Distance-to-Bias Mapping\n\nA logarithmic curve gives a natural feel: the bias increases slowly near the object\nand accelerates as distance grows.\n\n```text\nbias = log2(distance / sharpDistance)\n```\n\nClamped to a useful range: −1 (slightly sharper than default) to +4 (very blurry).\n\n### When Update() Polling Is Acceptable\n\nGenerally, polling in `Update()` is an anti-pattern (use events instead). Mipmap bias is\nan **exception**: it must track the player's continuous movement through space, and there\nis no VRChat event that fires on position change. The operation is cheap (one distance\ncalculation and one material property set per frame), so per-frame polling is acceptable here.\n\nConsider the Update Handler Pattern from [patterns-performance.md](patterns-performance.md)\nto disable this Update loop when no players are in range.\n\n### Shader Requirement\n\nStandard Unity shaders respect `material.mipMapBias` on sampler state, but `tex2Dlod`\nin a custom shader gives explicit per-sample mip control. A minimal custom surface shader:\n\n```hlsl\n// Minimal custom shader supporting manual mipmap level via _MipmapBias\nShader \"Custom/MipmapBiasDisplay\"\n{\n Properties\n {\n _MainTex (\"Texture\", 2D) = \"white\" {}\n _MipmapBias (\"Mipmap Bias\", Range(-4, 8)) = 0\n }\n SubShader\n {\n Tags { \"RenderType\"=\"Opaque\" }\n LOD 100\n Pass\n {\n CGPROGRAM\n #pragma vertex vert\n #pragma fragment frag\n #include \"UnityCG.cginc\"\n\n sampler2D _MainTex;\n float4 _MainTex_ST;\n float _MipmapBias;\n\n struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; };\n struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; };\n\n v2f vert(appdata v)\n {\n v2f o;\n o.pos = UnityObjectToClipPos(v.vertex);\n o.uv = TRANSFORM_TEX(v.uv, _MainTex);\n return o;\n }\n\n fixed4 frag(v2f i) : SV_Target\n {\n // tex2Dlod: explicit mip level selection via w component\n float mip = clamp(_MipmapBias, -4, 8);\n return tex2Dlod(_MainTex, float4(i.uv, 0, mip));\n }\n ENDCG\n }\n }\n}\n```\n\n### UdonSharp Behaviour\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class MipmapBiasController : UdonSharpBehaviour\n{\n [Header(\"Display to control\")]\n [SerializeField] private Renderer displayRenderer;\n\n [Header(\"Bias curve settings\")]\n [Tooltip(\"Distance at which bias = 0 (full sharpness)\")]\n [SerializeField] private float sharpDistance = 3.0f;\n\n [Tooltip(\"Minimum bias (never go sharper than this)\")]\n [SerializeField] private float minBias = -1.0f;\n\n [Tooltip(\"Maximum bias applied at extreme distance\")]\n [SerializeField] private float maxBias = 4.0f;\n\n [Tooltip(\"Object scale factor — larger objects need a higher sharpDistance\")]\n [SerializeField] private float objectScaleFactor = 1.0f;\n\n private static readonly int MipmapBiasId = Shader.PropertyToID(\"_MipmapBias\");\n\n private Material _material;\n\n void Start()\n {\n if (displayRenderer != null)\n {\n // Instance the material so we do not affect other renderers sharing it\n _material = displayRenderer.material;\n }\n }\n\n void Update()\n {\n if (_material == null) return;\n\n VRCPlayerApi localPlayer = Networking.LocalPlayer;\n if (localPlayer == null || !localPlayer.IsValid()) return;\n\n // Use head bone position for accurate VR head distance\n Vector3 headPos = localPlayer.GetBonePosition(HumanBodyBones.Head);\n if (headPos == Vector3.zero)\n {\n // Fallback: tracking data position\n headPos = localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head).position;\n }\n\n float distance = Vector3.Distance(headPos, displayRenderer.transform.position);\n\n // Account for object scale — a large display panel is \"closer\" perceptually\n float scaledSharp = sharpDistance * objectScaleFactor;\n\n // Logarithmic mapping: bias = log2(distance / scaledSharp)\n float rawBias = scaledSharp > 0f\n ? Mathf.Log(distance / scaledSharp, 2f)\n : 0f;\n\n float bias = Mathf.Clamp(rawBias, minBias, maxBias);\n _material.SetFloat(MipmapBiasId, bias);\n }\n}\n```\n\n> **Mipmap prerequisite**: `TextureInfo.GenerateMipmaps = true` must be set when calling\n> `DownloadImage`. A texture without mipmaps has only mip level 0; applying a positive\n> bias on a non-mipmapped texture has no visible effect and wastes shader cycles.\n\n---\n\n## Multi-Instance Delay Staggering\n\n### The Problem\n\nVRChat's image download rate limit is **one download per 5 seconds, shared across the\nentire scene**. When multiple image loaders in the same world all call `DownloadImage()`\nat startup (or on world join), they compete for the same slot. All but the first are\nqueued. Worse, if all loaders retry on error simultaneously, they create a burst pattern\nthat keeps them competing forever.\n\n### The Solution: Inspector-Assigned Delay Order\n\nGive each instance a serialized `_delayOrder` field. Each instance multiplies its order\nby a base delay to stagger when it first downloads.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.ImageLoading;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class StaggeredImageLoader : UdonSharpBehaviour\n{\n [Header(\"Image URL\")]\n [SerializeField] private VRCUrl imageUrl;\n\n [Header(\"Target material\")]\n [SerializeField] private Material targetMaterial;\n\n [Header(\"Stagger settings\")]\n [Tooltip(\"Set to 0 for the first loader, 1 for the second, 2 for the third, etc.\")]\n [SerializeField] private int _delayOrder = 0;\n\n [Tooltip(\"Seconds between each loader's first download (must be >= 5.0)\")]\n [SerializeField] private float _baseDelaySeconds = 5.5f;\n\n [Tooltip(\"How often to refresh the image (seconds)\")]\n [SerializeField] private float _refreshInterval = 60.0f;\n\n private VRCImageDownloader _downloader;\n private IVRCImageDownload _currentDownload;\n private Texture2D _activeTexture;\n\n void Start()\n {\n _downloader = new VRCImageDownloader();\n\n // Stagger initial download by order × base delay\n float initialDelay = _delayOrder * _baseDelaySeconds;\n SendCustomEventDelayedSeconds(nameof(BeginDownload), initialDelay);\n }\n\n public void BeginDownload()\n {\n if (_currentDownload != null)\n {\n _currentDownload.Dispose();\n _currentDownload = null;\n }\n\n TextureInfo info = new TextureInfo();\n info.GenerateMipmaps = true;\n info.FilterMode = FilterMode.Trilinear;\n\n _currentDownload = _downloader.DownloadImage(\n imageUrl,\n null,\n (IUdonEventReceiver)this,\n info\n );\n }\n\n public override void OnImageLoadSuccess(IVRCImageDownload result)\n {\n // Swap textures and free old VRAM\n Texture2D previousTexture = _activeTexture;\n _activeTexture = result.Result;\n\n if (targetMaterial != null)\n {\n targetMaterial.mainTexture = _activeTexture;\n }\n\n if (previousTexture != null)\n {\n Destroy(previousTexture);\n }\n\n // Schedule next refresh — staggered so each instance refreshes at its own offset\n SendCustomEventDelayedSeconds(nameof(BeginDownload), _refreshInterval);\n }\n\n public override void OnImageLoadError(IVRCImageDownload result)\n {\n Debug.LogError($\"[StaggeredLoader #{_delayOrder}] Error {result.ErrorCode}: {result.Error}\");\n // Retry after a full base delay to avoid colliding with other instances\n SendCustomEventDelayedSeconds(nameof(BeginDownload), _baseDelaySeconds);\n }\n\n void OnDestroy()\n {\n if (_downloader != null) _downloader.Dispose();\n if (_activeTexture != null) Destroy(_activeTexture);\n }\n}\n```\n\n### Inspector Setup for Three Loaders\n\n| Instance | `_delayOrder` | First download at |\n|----------|:-------------:|:-----------------:|\n| Loader A | 0 | 0.0 s |\n| Loader B | 1 | 5.5 s |\n| Loader C | 2 | 11.0 s |\n\n> Choose `_baseDelaySeconds` of at least **5.5 s** (the 5 s rate limit plus 0.5 s margin).\n> Reduce the margin to 0.1 s only if you have verified the rate limit in your specific SDK version.\n\n---\n\n## Anti-Patterns\n\n| Anti-Pattern | Problem | Correct Approach |\n|---|---|---|\n| `new VRCImageDownloader()` every download | Each new downloader is never disposed; creates a permanent leak of VRC internal state and any completed textures it still holds | Create one downloader in `Start()` and reuse it for all downloads |\n| Overwriting `_currentDownload` without `Dispose()` | The old `IVRCImageDownload` wrapper leaks; its internal state is never freed | Call `_currentDownload.Dispose()` before overwriting the reference |\n| Relying on `Dispose()` to free a texture applied to a material | `Dispose()` only frees VRC wrapper objects; the `Texture2D` GPU allocation survives | After assigning a texture to a material, track it separately and call `Destroy(texture)` when done |\n| Destroying a texture while it is still visible during a fade | Causes a black flash or missing-texture visual artifact | Store a reference to the old texture, delay `Destroy()` until after the fade completes using `SendCustomEventDelayedSeconds` |\n| Not handling `OnImageLoadError` | Failures are silent; `_currentDownload` may be in an undefined state; the refresh cycle stops permanently | Always implement `OnImageLoadError`, log the error, and schedule a retry |\n| Polling download status in `Update()` | `IVRCImageDownload` does not expose a reliable completion flag for polling; this is fragile and wastes CPU every frame | Use the `OnImageLoadSuccess` / `OnImageLoadError` callbacks that VRChat provides |\n| Starting all loaders at the same time in a multi-loader world | All instances compete for the shared 5 s rate limit slot; downloads queue unpredictably and initial load time balloons | Assign each instance a `delayOrder` and stagger their start times by `delayOrder × 5.5f` seconds |\n| Using mipmaps without enabling `GenerateMipmaps = true` | Applying mipmap bias or `FilterMode.Trilinear` to a non-mipmapped texture has no effect; the image appears the same regardless of distance or bias settings | Set `TextureInfo.GenerateMipmaps = true` whenever you intend to use mipmaps |\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely Cause | Solution |\n|---------|-------------|---------|\n| VRAM grows without bound over time | Old textures are not being destroyed after each image swap | Track the previous texture reference; call `Destroy(oldTexture)` in `OnImageLoadSuccess` before storing the new one |\n| Black flash or missing texture during crossfade | Texture destroyed while still visible (before fade completes) | Delay `Destroy()` using `SendCustomEventDelayedSeconds` until after `fadeDuration` has elapsed |\n| Textures appear blurry even at close range | Mipmaps not generated, or positive mipmap bias applied at short distance | Set `TextureInfo.GenerateMipmaps = true`; review your bias curve — ensure bias is near 0 at close range |\n| Texture is sharp far away but crashes GPU memory on Quest | Negative mipmap bias forcing high-resolution mip at distance | Clamp minimum bias to 0 or higher on Quest; disable the bias controller on mobile platforms |\n| World crashes after many image cycles | VRAM exhausted from accumulated undestroyed textures | Audit every `OnImageLoadSuccess` callback; verify `Destroy(oldTexture)` is called before the new texture is stored |\n| Images in a multi-loader world load very slowly | All loaders downloading simultaneously, saturating the shared rate limit | Assign staggered `delayOrder` values to each loader |\n| `Dispose()` call does not seem to free memory | `Dispose()` frees VRC wrappers, not GPU texture memory | Call `Destroy(texture)` explicitly on textures you own; `Dispose()` is not a substitute |\n| Download cycle stops after an error | `OnImageLoadError` not implemented; no retry scheduled | Implement `OnImageLoadError` and call `SendCustomEventDelayedSeconds(nameof(BeginDownload), retryDelay)` |\n| Texture pop-in visible when switching display images | New texture not fully applied before fade begins | Apply texture to back renderer first, then start the fade in the same callback frame |\n| Material instance shared between objects causes unexpected texture changes | Using `renderer.sharedMaterial` instead of `renderer.material` | Use `renderer.material` to get an instanced material; always apply textures to the instance |\n\n---\n\n## See Also\n\n- [web-loading.md](web-loading.md) — `VRCImageDownloader` API overview, rate limits, trusted URL list, and basic pattern\n- [api.md](api.md) — Quick reference for `VRCUrl`, `TextureInfo`, `IVRCImageDownload` properties\n- [troubleshooting.md](troubleshooting.md) — Web loading error table and HTTP error code reference\n- [patterns-performance.md](patterns-performance.md) — Update Handler Pattern (disable per-frame polling when not needed)\n- [web-loading-advanced.md](web-loading-advanced.md) - Advanced data loading via StringDownloader with Base64 textures\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":34393,"content_sha256":"b2fd96b70faf7e3b7f4f995554c05e9ceff5eb4f2efd3d50aeb229440d5a1f92"},{"filename":"references/networking-antipatterns.md","content":"# UdonSharp Networking Anti-Patterns and Advanced Patterns\n\nCommon networking mistakes that cause silent failures or data loss, plus advanced techniques for packed sync, rate limiting, dual-copy variables, batching, and congestion retry.\n\n## Networking Anti-Patterns\n\nCommon mistakes in UdonSharp networking that cause silent failures, data loss, or undefined behavior. Each pattern includes the problem, a wrong implementation, and the correct fix.\n\n---\n\n### 1. Ownership Race Condition\n\n**Problem**: `Networking.SetOwner` is **locally immediate** on the calling client — `Networking.IsOwner(gameObject)` returns `true` synchronously after the call returns, and `OnOwnershipTransferred` fires synchronously inside the `SetOwner` stack on that client. When two clients call `SetOwner` for the same object at the same moment, **both succeed locally** and may write `[UdonSynced]` variables; VRChat's network resolves the durable owner by network arrival order, and the loser's write is overwritten when the winner's serialization arrives. There is no client-side arbitration. Treat \"loser overwrite\" as a property of the network, not a bug to engineer around with callback gating.\n\n**Wrong:**\n\n```csharp\n// Mutating [UdonSynced] without an IsOwner guard — when SetOwner has not\n// been called for the local client (e.g., owner is someone else), the\n// write is purely local and is silently reverted on the next deserialization.\npublic void TryCapture()\n{\n capturedBy = Networking.LocalPlayer.playerId; // No IsOwner guard — may be a non-owner write\n RequestSerialization(); // No-op when called by a non-owner\n}\n```\n\n**Correct:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class CapturePoint : UdonSharpBehaviour\n{\n [UdonSynced] private int capturedBy = -1;\n\n public void TryCapture()\n {\n // SetOwner is locally immediate. After it returns, IsOwner is true\n // for this client. Two clients racing to capture will both write\n // locally and serialize; the network resolves the durable owner by\n // arrival order, and the loser's write is overwritten when the\n // winner's serialization arrives. This is acceptable for capture-\n // point semantics.\n if (!Networking.IsOwner(gameObject))\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n\n capturedBy = Networking.LocalPlayer.playerId;\n RequestSerialization();\n }\n\n public override void OnDeserialization()\n {\n // Update visuals from synced state (runs on non-owner clients).\n UpdateVisualsFromCapturedBy();\n }\n\n private void UpdateVisualsFromCapturedBy() { /* ... */ }\n}\n```\n\n**Explanation**: `Networking.SetOwner` is **locally immediate**. After the call returns, `Networking.IsOwner(gameObject)` is `true` and `OnOwnershipTransferred` has already fired synchronously inside the `SetOwner` stack on the calling client. Writing `[UdonSynced]` fields and calling `RequestSerialization()` immediately afterwards is safe under an `IsOwner` guard. Concurrent calls from multiple clients are *not* arbitrated client-side — VRChat's network resolves the durable owner by arrival order, and the loser's local write is overwritten when the winner's serialization arrives. See the [Transfer Events Diagram](https://creators.vrchat.com/worlds/udon/networking/ownership/#transfer-events-diagram) on creators.vrchat.com.\n\n**When `OnOwnershipRequest` fits.** If the *current owner* needs to reject ownership transfers during a critical action (turn-based logic, mid-transaction state), use `OnOwnershipRequest`. That is a different problem class — owner-side protection — not arbitration among concurrent requesters. See [networking.md §\"Ownership Arbitration with OnOwnershipRequest\"](networking.md#ownership-arbitration-with-onownershiprequest).\n\n> *Footnote: Pre-2021.2.2 SDKs were asynchronous; this skill targets SDK 3.7.1+ where the locally-immediate behavior is in effect.*\n\n---\n\n### 2. Synced String Silent Truncation\n\n**Problem**: A synced `string` in a `Continuous` sync behaviour is bounded by the ~200-byte serialization budget. UTF-16 encodes each character as 2 bytes, so a string exceeding ~100 characters will be silently truncated with no runtime error or warning. The receiving client sees a shorter, corrupted string.\n\n**Wrong:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]\npublic class PlayerTag : UdonSharpBehaviour\n{\n // A user could enter a 200-character message — truncated silently\n [UdonSynced] public string displayMessage;\n\n public void SetMessage(string msg)\n {\n displayMessage = msg; // No length check\n }\n}\n```\n\n**Correct:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]\npublic class PlayerTag : UdonSharpBehaviour\n{\n // UTF-16: 2 bytes/char. Budget ~200 bytes total for ALL synced vars.\n // Reserve headroom for other fields; keep strings short.\n private const int MaxMessageBytes = 80; // ~40 characters safe margin\n private const int BytesPerChar = 2; // UTF-16\n private const int MaxMessageChars = MaxMessageBytes / BytesPerChar;\n\n [UdonSynced] public string displayMessage;\n\n public void SetMessage(string msg)\n {\n if (msg == null) msg = string.Empty;\n\n // Detect potential truncation before it happens\n if (msg.Length > MaxMessageChars)\n {\n Debug.LogWarning(\n $\"[PlayerTag] Message too long ({msg.Length} chars, max {MaxMessageChars}). Truncating.\");\n msg = msg.Substring(0, MaxMessageChars);\n }\n\n displayMessage = msg;\n }\n}\n```\n\n**Explanation**: VRChat does not throw an error when a synced string exceeds the serialization budget — it simply stops writing at the byte limit. Always enforce a character budget before assigning to `[UdonSynced] string`, especially in `Continuous` mode where the per-behaviour budget is only ~200 bytes shared among all synced variables. Use `Manual` sync mode if you need longer strings (up to ~280KB).\n\n---\n\n### 3. Sync Buffer Overflow\n\n**Problem**: `Manual` sync allows up to ~280KB per serialization, but it is still possible to exceed the buffer with large arrays. When the serialized payload exceeds the limit, VRChat silently drops the entire sync packet — no partial delivery, no error. Recipients see stale data.\n\n**Wrong:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class MapData : UdonSharpBehaviour\n{\n // 512 x 512 grid as ints = 512 * 512 * 4 bytes = 1,048,576 bytes (~1MB) — exceeds limit!\n [UdonSynced] private int[] tiles = new int[512 * 512];\n\n public void SaveAndSync()\n {\n // Packet silently dropped — recipients never update\n RequestSerialization();\n }\n}\n```\n\n**Correct:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class MapData : UdonSharpBehaviour\n{\n // Manual sync budget: ~280KB = 286,720 bytes\n // byte[] uses 1 byte/element vs int[] at 4 bytes/element\n // 256 x 256 as byte = 65,536 bytes — well within budget\n private const int MapSize = 256;\n private const int MaxSyncBytes = 280 * 1024; // 286,720 bytes\n\n [UdonSynced] private byte[] tiles = new byte[MapSize * MapSize];\n\n public void SaveAndSync()\n {\n // Check estimated size before serializing\n int estimatedBytes = tiles.Length; // 1 byte per element\n if (estimatedBytes > MaxSyncBytes)\n {\n Debug.LogError(\n $\"[MapData] Sync payload too large: {estimatedBytes} bytes (max {MaxSyncBytes}). Aborting.\");\n return;\n }\n\n RequestSerialization();\n }\n\n // For very large maps: chunk into multiple behaviours or use delta sync\n // See: Delta Sync section above\n}\n```\n\n**Explanation**: Use `OnPostSerialization(SerializationResult result)` to measure actual byte usage in the editor, then enforce a budget at runtime. Prefer `byte` over `int` for tile data, and consider delta sync (send only changes) for maps larger than ~64KB. Never assume the packet was delivered — use a `moveCounter` or version field to detect missed updates.\n\n---\n\n### 4. Mixing Continuous and Manual Sync\n\n**Problem**: Setting `BehaviourSyncMode` to both `Continuous` and `Manual` on the same behaviour is not valid — `BehaviourSyncMode` is a single enum value. However, a common mistake is adding `RequestSerialization()` calls inside a `Continuous` behaviour, or annotating a `Manual` behaviour with interpolation modes (`UdonSyncMode.Linear`/`Smooth`) expecting automatic 10Hz updates. Neither combination produces the intended result.\n\n**Wrong:**\n\n```csharp\n// Attempting to get both automatic 10Hz sync AND explicit sync control\n[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]\npublic class BadSyncMix : UdonSharpBehaviour\n{\n [UdonSynced(UdonSyncMode.Linear)] private Vector3 position;\n [UdonSynced] private int score; // Discrete value in Continuous mode — wastes bandwidth\n\n void Update()\n {\n if (Networking.IsOwner(gameObject))\n {\n position = transform.position;\n score = CalculateScore();\n RequestSerialization(); // Redundant in Continuous mode; called every frame\n }\n }\n}\n```\n\n**Correct:**\n\n```csharp\n// Behaviour A: Continuous — position only, no RequestSerialization\n[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]\npublic class PositionSync : UdonSharpBehaviour\n{\n [UdonSynced(UdonSyncMode.Linear)] private Vector3 position;\n\n void Update()\n {\n if (Networking.IsOwner(gameObject))\n {\n position = transform.position;\n // No RequestSerialization() — Continuous mode handles transmission automatically\n }\n else\n {\n transform.position = position;\n }\n }\n}\n\n// Behaviour B: Manual — score only, explicit sync on change\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class ScoreSync : UdonSharpBehaviour\n{\n [UdonSynced] private int score;\n\n public void UpdateScore(int newScore)\n {\n if (!Networking.IsOwner(gameObject)) return;\n score = newScore;\n RequestSerialization(); // Explicit sync only when value changes\n }\n\n public override void OnDeserialization()\n {\n UpdateScoreDisplay();\n }\n\n private void UpdateScoreDisplay() { /* Update UI */ }\n}\n```\n\n**Explanation**: Separate concerns by sync mode. `Continuous` is for values that change every frame (position, rotation); `Manual` is for discrete state changes (score, game phase). Mixing them on one behaviour wastes bandwidth (`Continuous` on score data) or loses features (`RequestSerialization()` is a no-op on `None` mode and has redundant effect on `Continuous`). Keep each behaviour focused on one sync mode.\n\n---\n\n### 5. Sync Without Ownership\n\n**Problem**: Modifying a `[UdonSynced]` variable without being the owner causes the change to be silently reverted on the next deserialization. VRChat does not throw an error — the local variable appears to change, but the change is never broadcast and will be overwritten when the actual owner next serializes.\n\n**Wrong:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class GameFlag : UdonSharpBehaviour\n{\n [UdonSynced] public bool isCaptured;\n\n public override void OnTriggerEnter(Collider other)\n {\n // Any player can run this, but only the owner's write will persist\n isCaptured = true;\n RequestSerialization(); // Silently fails if not owner — isCaptured reverts next sync\n }\n}\n```\n\n**Correct:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class GameFlag : UdonSharpBehaviour\n{\n [UdonSynced] public bool isCaptured;\n\n public override void OnTriggerEnter(Collider other)\n {\n if (!Networking.IsOwner(gameObject))\n {\n // Locally immediate; we own the object after this returns.\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n\n // SetOwner is locally immediate — safe to write under IsOwner.\n SetCaptured(true);\n }\n\n private void SetCaptured(bool value)\n {\n isCaptured = value;\n RequestSerialization();\n }\n\n public override void OnDeserialization()\n {\n UpdateFlagVisual();\n }\n\n private void UpdateFlagVisual() { /* Update flag appearance */ }\n}\n```\n\n**Explanation**: In UdonSharp, only the current owner's `RequestSerialization()` calls are transmitted. Non-owner writes to `[UdonSynced]` variables are purely local and will be overwritten by the next deserialization from the actual owner — that is the silent failure to guard against. Always guard synced writes with `Networking.IsOwner(gameObject)`; if you are not the owner, call `Networking.SetOwner` first — it is locally immediate, so once it returns `IsOwner` is `true` and you may write and serialize on the same frame.\n\n---\n\n### 6. Excessive RequestSerialization\n\n**Problem**: Calling `RequestSerialization()` every frame (or on every `Update` tick) floods the network. VRChat's Udon network budget is approximately **11KB/sec**. A 60Hz `Update` calling `RequestSerialization()` can consume that budget alone, causing \"Death Run\" congestion for all other network operations in the world.\n\n**Wrong:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class FrequentSync : UdonSharpBehaviour\n{\n [UdonSynced] private Vector3 trackedPosition;\n\n void Update()\n {\n if (!Networking.IsOwner(gameObject)) return;\n\n trackedPosition = transform.position;\n RequestSerialization(); // Called ~60 times/sec — severe bandwidth waste\n }\n}\n```\n\n**Correct:**\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class ThrottledSync : UdonSharpBehaviour\n{\n [UdonSynced] private Vector3 trackedPosition;\n\n private const float SyncInterval = 0.1f; // Max 10 syncs/sec\n private const float PositionThreshold = 0.01f; // Skip if barely moved\n private const float RetryInterval = 1.0f;\n\n private float _lastSyncTime = float.MinValue;\n private bool _isPendingSync = false;\n\n void Update()\n {\n if (!Networking.IsOwner(gameObject)) return;\n\n Vector3 current = transform.position;\n\n // Skip if position has not changed meaningfully\n if (Vector3.Distance(current, trackedPosition) \u003c PositionThreshold) return;\n\n trackedPosition = current;\n RequestSync();\n }\n\n private void RequestSync()\n {\n if (_isPendingSync) return;\n\n float now = Time.time;\n float remaining = (_lastSyncTime + SyncInterval) - now;\n\n if (remaining \u003c= 0f)\n {\n ExecuteSync();\n }\n else\n {\n // Schedule a single deferred sync — coalesces rapid changes\n SendCustomEventDelayedSeconds(nameof(ExecuteSync), remaining + 0.001f);\n _isPendingSync = true;\n }\n }\n\n public void ExecuteSync()\n {\n _isPendingSync = false;\n if (!Networking.IsOwner(gameObject)) return;\n\n if (Networking.IsClogged)\n {\n // Back off and retry during congestion\n SendCustomEventDelayedSeconds(nameof(ExecuteSync), RetryInterval);\n _isPendingSync = true;\n return;\n }\n\n RequestSerialization();\n _lastSyncTime = Time.time;\n }\n}\n```\n\n**Explanation**: Throttle `RequestSerialization()` to a maximum frequency appropriate for the data type (10Hz for position is generous; game state changes rarely need more than 1-2Hz). Combine throttling with a change threshold so unchanged values never trigger a sync. Check `Networking.IsClogged` before serializing and use `SendCustomEventDelayedSeconds` to retry rather than spinning in `Update`. See [networking-bandwidth.md - RequestSerialization Throttling Pattern](networking-bandwidth.md#requestserialization-throttling-pattern) for a reusable implementation.\n\n## Advanced Networking Patterns\n\nTechniques for reducing sync variable count, controlling serialization timing, and surviving network congestion.\n\n---\n\n### 1. Packed Sync Data\n\n**Problem**: Each `[UdonSynced]` variable consumes sync budget independently. A behaviour with many small values wastes budget on per-variable overhead.\n\n**Solution**: Pack multiple independent values into a single variable using the natural layout of numeric types.\n\n- `Vector3` stores 3 independent floats — use each component for a different purpose (e.g., `x` = question index, `y` = game type, `z` = category).\n- `int` stores 32 bits — encode multiple small integers via bit shifting.\n- A single `int` used as a bit field replaces a group of `bool` variables.\n\n**Template:** [assets/templates/PackedStateSync.cs](../assets/templates/PackedStateSync.cs)\n\nStores three independent state values (`_questionIndex`, `_gameType`, `_category`) in a single `[UdonSynced] Vector3 _packedState`. `OnPreSerialization` calls `PackState()` which writes each int as a Vector3 component. `OnDeserialization` calls `UnpackState()` using `Mathf.RoundToInt` to recover the values. Integer precision is exact up to 16,777,216 (24-bit float mantissa). Do not use for values requiring interpolation.\n\n**Key constraints**:\n- `float` has 24-bit mantissa precision — integers up to 16,777,216 round-trip exactly through `Vector3`.\n- Use `Mathf.RoundToInt` on unpack to absorb any floating-point noise.\n- Do not use this technique for values that need interpolation; use separate synced floats for those.\n\n---\n\n### 2. Rate-Limited Serialization\n\n**Problem**: Rapid user interactions (dragging a slider, scrubbing a seek bar) can fire dozens of change events per second. Calling `RequestSerialization()` on every event floods the ~11 KB/s Udon network budget.\n\n**Solution**: Use a `_syncLocked` flag and `SendCustomEventDelayedSeconds` to enforce a minimum cooldown between serializations. Only the value present at the end of the cooldown window is sent, so fast-moving values coalesce naturally.\n\n**Template:** [assets/templates/RateLimitedSync.cs](../assets/templates/RateLimitedSync.cs)\n\nUses a `_syncLocked` bool and a `_changeCounter` int to enforce a `SyncCooldown` (0.15 s) between serializations. On the first change in a window, the lock is set and `_OnSyncUnlock` is scheduled. Subsequent changes update `_localValue` without scheduling additional events. On unlock, `ExecuteSync` copies `_localValue` to `_syncedValue` and calls `RequestSerialization`. If the counter moved during the lock, one extra window fires to guarantee the final value is transmitted.\n\n**How it works**:\n1. The first change within a cooldown window locks further serializations and schedules `_OnSyncUnlock`.\n2. Subsequent changes during the lock update `_localValue` but do not schedule additional events.\n3. When the lock expires, `_OnSyncUnlock` serializes the current (latest) value.\n4. The `_changeCounter` comparison ensures one extra window fires if the value was still moving at unlock time, guaranteeing the last write reaches the network.\n\n---\n\n### 3. Dual-Copy Sync Variables\n\n**Problem**: Writing directly to `[UdonSynced]` variables from non-owner code is silently discarded at the next `OnDeserialization`. Code that freely mixes reads and writes to synced variables is error-prone and hard to reason about.\n\n**Solution**: Maintain a *local working copy* alongside each synced variable. The local copy is the single source of truth for all in-world logic. `OnPreSerialization` copies local → synced; `OnDeserialization` copies synced → local. A dirty flag prevents unnecessary serializations.\n\n**Template:** [assets/templates/DualCopySync.cs](../assets/templates/DualCopySync.cs)\n\nMaintains `volume` (public local copy) and `_syncedVolume` ([UdonSynced] private copy) as strictly separate fields. A `_dirty` flag guards `OnPreSerialization`: it only copies local → synced when something changed. `OnDeserialization` copies synced → local and calls `ApplyVolume`. All game logic reads the local copy; the synced copy is never written outside the two serialization hooks.\n\n**Benefits**:\n- All game logic reads `volume` — no conditional owner checks scattered throughout the codebase.\n- `_syncedVolume` is never written outside the two serialization hooks, making networking logic easy to audit.\n- The dirty flag ensures `RequestSerialization()` produces a packet only when the value genuinely changed, avoiding spurious traffic.\n\n---\n\n### 4. Delayed Serialization Batching\n\n**Problem**: Multiple rapid events (several players joining in quick succession, multi-field form submission) each call `RequestSerialization()` independently, producing redundant packets that carry nearly identical payloads.\n\n**Solution**: Instead of serializing immediately, set a *pending* flag and schedule a single delayed serialization. All changes that arrive before the delay fires are batched into one packet.\n\n**Template:** [assets/templates/BatchedSync.cs](../assets/templates/BatchedSync.cs)\n\nUses a `_syncPending` bool so that `ScheduleBatchedSync` is idempotent — only the first call within a `BatchDelay` (0.2 s) window schedules `_FlushBatch`. All three fields (`_playerCount`, `_readyFlags`, `_roundNumber`) are serialized in one packet regardless of how many mutation methods were called. `_FlushBatch` clears the pending flag then calls `RequestSerialization`. Tune `BatchDelay` to 100–300 ms for non-positional state.\n\n**Key points**:\n- `ScheduleBatchedSync` is idempotent: calling it multiple times before the delay fires has no effect beyond the first call.\n- All three fields (`_playerCount`, `_readyFlags`, `_roundNumber`) are serialized together in one packet, regardless of how many mutation methods were called during the batch window.\n- Tune `BatchDelay` to balance latency against packet reduction. 100–300 ms is typically invisible to players for non-positional state.\n\n---\n\n### 5. IsClogged Retry Pattern\n\n**Problem**: When the Udon network is congested, `RequestSerialization()` calls may be silently dropped. There is no built-in retry or acknowledgement.\n\n**Solution**: Check `Networking.IsClogged` before serializing. If the network is congested, skip the call and schedule a retry via `SendCustomEventDelayedSeconds`. Cap total retry attempts to prevent infinite retry storms during extended outages.\n\n**Template:** [assets/templates/CloggedRetrySync.cs](../assets/templates/CloggedRetrySync.cs)\n\n`TrySerialize` checks `Networking.IsClogged` before calling `RequestSerialization`. If congested, `ScheduleRetry` increments `_retryCount`, sets `_retryPending`, and schedules `_RetrySerialize` with linear back-off (`RetryDelay * _retryCount`). After `MaxRetries` (5) attempts, the cycle gives up and resets both counters. `_retryPending` prevents stacked retry chains if `TrySerialize` is called again while a retry is queued.\n\n**Design notes**:\n- `_retryPending` prevents multiple overlapping `SendCustomEventDelayedSeconds` chains from accumulating if `TrySerialize` is called again while a retry is already queued.\n- Linear back-off (`RetryDelay * _retryCount`) reduces pressure on an already-congested network rather than hammering it at a fixed interval.\n- `MaxRetries` caps total attempts. In practice, VRChat network congestion resolves within a few seconds; five retries at 1.5 s increments covers ~22 s of congestion before giving up.\n- After an abandonment, the next call to `UpdateScore` or `SetGameState` resets the counter and starts fresh.\n\n---\n\n\n## See Also\n\n- [networking.md](networking.md) - Sync modes, ownership, network events, data limits\n- [networking-bandwidth.md](networking-bandwidth.md) - Bandwidth throttling, bit packing, data optimization\n- [patterns-networking.md](patterns-networking.md) - Object pooling, game state management\n- [troubleshooting.md](troubleshooting.md) - Debugging networking issues and ownership race conditions\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":23974,"content_sha256":"ddf617cb0b5d03067744bf7c16ef5b15bb91ab58bf0011872b8c53cb37b74102"},{"filename":"references/networking-bandwidth.md","content":"# UdonSharp Network Bandwidth and Data Optimization\n\nBandwidth throttling, request serialization patterns, bit packing, data size optimization, owner-centric architecture, and network debugging.\n\n## Network Bandwidth and Throttling\n\n### Bandwidth Limits\n\n> Udon scripts can send out about **11 kilobytes** per second.\n> — [VRChat Creator Docs](https://creators.vrchat.com/worlds/udon/networking/network-details)\n\nExceeding this limit causes sync delays known as \"Death Runs.\" This is particularly problematic when:\n- Multiple UI elements are operated rapidly\n- Many synced variables are updated simultaneously\n- Players spam operations repeatedly\n\n### Detecting Network Congestion\n\nUse `Networking.IsClogged` to check for network queue backup:\n\n```csharp\nif (Networking.IsClogged)\n{\n // Network is congested - defer synchronization\n SendCustomEventDelayedSeconds(nameof(RetrySync), 1.0f);\n return;\n}\nRequestSerialization();\n```\n\n### RequestSerialization Throttling Pattern\n\nFor high-frequency updates, wrap `RequestSerialization()` with interval control:\n\n**Key principles:**\n1. Enforce minimum interval between syncs (e.g., 1 second)\n2. Auto-retry during network congestion\n3. Prevent scheduling duplicate delayed events\n4. Always sync the latest state (not intermediate states)\n\n```csharp\nprivate const float SyncInterval = 1.0f;\nprivate const float RetryInterval = 1.0f;\nprivate bool isPendingSync = false;\nprivate double lastSyncTime = double.MinValue;\n\n/// \u003csummary>\n/// Call this instead of RequestSerialization() for throttled sync.\n/// \u003c/summary>\nprivate void RequestSync()\n{\n if (isPendingSync) return;\n if (!Networking.IsOwner(gameObject)) return;\n\n double now = Time.timeAsDouble;\n\n if (now >= lastSyncTime + SyncInterval)\n {\n ExecuteSync();\n }\n else\n {\n float delay = (float)(lastSyncTime + SyncInterval - now) + 0.001f;\n SendCustomEventDelayedSeconds(nameof(ExecuteSync), delay);\n isPendingSync = true;\n }\n}\n\npublic void ExecuteSync()\n{\n isPendingSync = false;\n if (!Networking.IsOwner(gameObject)) return;\n\n if (Networking.IsClogged)\n {\n SendCustomEventDelayedSeconds(nameof(ExecuteSync), RetryInterval);\n isPendingSync = true;\n return;\n }\n\n RequestSerialization();\n lastSyncTime = Time.timeAsDouble;\n}\n```\n\n**Advantages:**\n- Prevents network overload from rapid operations\n- Auto-retries during congestion\n- Always syncs the latest state\n- No duplicate delayed events\n\n**Trade-offs:**\n- Up to `SyncInterval` seconds of latency\n- Individual sync requests may be merged (syncing state, not events)\n\n### Periodic Sync Pattern\n\nContinuous synchronization at controlled intervals:\n\n```csharp\nprivate const float PeriodicSyncInterval = 10.0f;\nprivate bool isPendingPeriodicSync = false;\nprivate bool loopNeeded = false;\n\nprivate void StartPeriodicSync()\n{\n if (!Networking.IsOwner(gameObject)) return;\n loopNeeded = true;\n RequestPeriodicSync();\n}\n\nprivate void StopPeriodicSync()\n{\n loopNeeded = false;\n}\n\nprivate void RequestPeriodicSync()\n{\n if (isPendingPeriodicSync) return;\n if (!Networking.IsOwner(gameObject)) return;\n\n SendCustomEventDelayedSeconds(nameof(ExecutePeriodicSync), PeriodicSyncInterval);\n isPendingPeriodicSync = true;\n}\n\npublic void ExecutePeriodicSync()\n{\n isPendingPeriodicSync = false;\n if (!Networking.IsOwner(gameObject)) return;\n\n RequestSync(); // Uses throttled sync\n\n if (loopNeeded)\n {\n RequestPeriodicSync();\n }\n}\n```\n\n## Data Size Optimization\n\nMinimize synced data size to stay within VRChat's ~11KB/sec bandwidth budget.\n\n### Sync Overhead\n\nEach synced variable has header overhead (metadata to identify the variable). This means:\n\n| Approach | Variable count | Sync data |\n|-----------|--------|-----------|\n| 8 separate `byte` | 8 | ~16 bytes (8 data + 8 overhead) |\n| 1 packed `ulong` | 1 | ~9 bytes (8 data + 1 overhead) |\n\n**Key point**: Reducing the number of variables is often more effective than reducing data size.\n\n### Bit Packing\n\nPack multiple small values into fewer variables:\n\n**Bit count and max value reference:**\n\n| Bits | Max value | Common uses |\n|---------|--------|-------------|\n| 1 | 1 | Boolean flags |\n| 2 | 3 | 4-state enum |\n| 3 | 7 | Dice (d6), small indices |\n| 4 | 15 | Hex digits, small counters |\n| 5 | 31 | Days (of month) |\n| 6 | 63 | Minutes, seconds |\n| 7 | 127 | ASCII characters |\n| 8 | 255 | Full byte |\n\n**Example: Pack 8 bools into 1 byte (87.5% reduction)**\n\n```csharp\n// Pack\nbyte packed = 0;\nif (flag0) packed |= 1; // bit 0\nif (flag1) packed |= 2; // bit 1\nif (flag2) packed |= 4; // bit 2\nif (flag3) packed |= 8; // bit 3\nif (flag4) packed |= 16; // bit 4\nif (flag5) packed |= 32; // bit 5\nif (flag6) packed |= 64; // bit 6\nif (flag7) packed |= 128; // bit 7\n\n// Unpack\nbool flag0 = (packed & 1) != 0;\nbool flag1 = (packed & 2) != 0;\nbool flag2 = (packed & 4) != 0;\n// ...\n```\n\n**Example: Pack array of 3-bit values into ulong**\n\n```csharp\n// Pack 20 values (0-7 each) into single ulong (60 bits used)\npublic void PackValues(byte[] values, out ulong packed)\n{\n packed = 0;\n for (int i = 0; i \u003c 20 && i \u003c values.Length; i++)\n {\n ulong threeBits = (ulong)(values[i] & 0b111);\n packed |= threeBits \u003c\u003c (i * 3);\n }\n}\n\n// Unpack\npublic void UnpackValues(ulong packed, byte[] values)\n{\n for (int i = 0; i \u003c 20 && i \u003c values.Length; i++)\n {\n values[i] = (byte)((packed >> (i * 3)) & 0b111);\n }\n}\n```\n\n### Range Shifting\n\nFor values with a limited range, shift to minimize bit count:\n\n```csharp\n// Value range: 200-210 (requires 8 bits as-is)\n// Shifted range: 0-10 (requires only 4 bits)\n\n// Pack\nbyte packed = (byte)(value - 200);\n\n// Unpack\nint value = packed + 200;\n```\n\n**Signed values**: Add an offset before packing to convert to unsigned:\n\n```csharp\n// Range: -50 to +50 (requires signed handling)\n// Shifted: 0 to 100 (7 bits, unsigned)\n\nbyte packed = (byte)(signedValue + 50);\nint signedValue = packed - 50;\n```\n\n### When to Use Bit Packing\n\n| Scenario | Recommendation |\n|---------|------|\n| Few variables, full range used | No packing needed - not worth the overhead |\n| Many bools | Pack into bytes/ints |\n| Array of small integers | Pack into ulong arrays |\n| Bandwidth is critical | Pack aggressively |\n| Large state sync for late joiners | Consider packing |\n\n**Caveats:**\n- `FieldChangeCallback` doesn't work directly with packed variables\n- Adds complexity - only use when bandwidth is a concern\n- Call pack before `RequestSerialization()`, unpack in `OnDeserialization()`\n\n## Synced Data Size — Application Examples\n\nVRChat's transmission bandwidth is approximately **11KB/sec**. Large synced data causes severe lag.\n\n### Data Size Estimation\n\n| Data | Size | Sync delay (11KB/sec) |\n|--------|--------|---------------------|\n| `int[40]` | 160 bytes | Instant |\n| `int[400]` | 1,600 bytes | ~0.15 sec |\n| `int[4000]` | 16,000 bytes | ~1.5 sec (NG) |\n| `byte[100]` | 100 bytes | Instant |\n\n### Optimization Techniques\n\n#### 1. Use Smaller Types\n\n```csharp\n// NG: Using int (4 bytes) when values are 0-255\n[UdonSynced] private int[] bottleColors; // 40 elements = 160 bytes\n\n// OK: byte (1 byte) is sufficient\n[UdonSynced] private byte[] bottleColors; // 40 elements = 40 bytes (75% reduction)\n```\n#### 2. Bit Packing (Many Small Values)\n\nPack multiple small values into fewer variables to reduce sync overhead.\nSee [Bit Packing](#bit-packing) above for the complete bit-count reference table and pack/unpack examples.\n\nQuick example — 40 color IDs (0-7, 3 bits each) into 15 bytes:\n\n```csharp\n// 40 colors x 3 bits = 120 bits = 15 bytes (160 bytes from int[40] -> 93% reduction)\n[UdonSynced] private byte[] packedColors; // 15 bytes stores 40 colors\n\nprivate byte GetColor(int index)\n{\n int byteIdx = (index * 3) / 8;\n int bitOffset = (index * 3) % 8;\n int raw = packedColors[byteIdx] | (packedColors[byteIdx + 1] \u003c\u003c 8);\n return (byte)((raw >> bitOffset) & 0x07);\n}\n```\n\n#### 3. Delta Sync (Send Only Changes)\n\nSync only changes instead of the full state. Send full state initially, then only deltas.\n\n```csharp\n// Instead of syncing full state every time, sync only the latest operation\n[UdonSynced] private int lastMoveFrom;\n[UdonSynced] private int lastMoveTo;\n[UdonSynced] private int moveCounter; // For change detection\n\npublic override void OnDeserialization()\n{\n // Re-apply the operation locally on the receiving side\n ApplyMove(lastMoveFrom, lastMoveTo);\n}\n```\n\n**Note:** Delta sync does not handle late joiners. If full state restoration is needed for initial connections, maintain full state in a synced array as well.\n\n---\n\n## Debugging Network Issues\n\n1. **Check Ownership**: `Debug.Log($\"Owner: {Networking.GetOwner(gameObject).displayName}\")`\n2. **Verify Sync**: Log before and after `RequestSerialization()`\n3. **Test Late Join**: Have player join mid-game to verify `OnDeserialization`\n4. **Monitor Bandwidth**: Keep sync frequency low (max 10/sec per object)\n5. **Test Edge Cases**: Player leaving while owning objects, rapid ownership transfers\n6. **Check Congestion**: Log `Networking.IsClogged` to detect network issues\n7. **Measure Data Size**: Use `OnPostSerialization(SerializationResult result)` to check `result.byteCount`\n\n## Owner-Centric Architecture (Recommended Design)\n\nFor multiplayer games, the recommended design is **\"only the owner modifies state, others receive the results.\"**\n\n### Design Principles\n\n1. **One GameManager** holds all game state synced variables (Manual sync)\n2. **Only the owner** runs game logic in `Update()`\n3. **Non-owners** only update display in `OnDeserialization()`\n4. **UI operations** notify the owner via `SendCustomNetworkEvent(Owner)`\n\n### Code Example: Owner-Centric GameManager\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class GameManager : UdonSharpBehaviour\n{\n [UdonSynced] private int[] boardState;\n [UdonSynced] private int currentTurn;\n [UdonSynced] private int gamePhase; // 0=Lobby, 1=Playing, 2=Result\n\n // --- Input from UI (fires on all clients) ---\n public void OnCellClicked(int cellIndex)\n {\n // Delegate to owner (works even if self is owner)\n SendCustomNetworkEvent(\n NetworkEventTarget.Owner,\n nameof(OwnerProcessMove),\n cellIndex,\n Networking.LocalPlayer.playerId\n );\n }\n\n // --- Owner only ---\n [NetworkCallable]\n public void OwnerProcessMove(int cellIndex, int playerId)\n {\n if (gamePhase != 1) return; // Ignore if not in game\n if (playerId != GetCurrentPlayerId()) return; // Ignore if not their turn\n if (boardState[cellIndex] != 0) return; // Already occupied\n\n boardState[cellIndex] = currentTurn;\n currentTurn = (currentTurn % 2) + 1;\n RequestSerialization();\n }\n\n // --- All clients: update display ---\n public override void OnDeserialization()\n {\n UpdateBoardDisplay();\n UpdateTurnIndicator();\n }\n\n private int GetCurrentPlayerId() { /* ... */ return 0; }\n private void UpdateBoardDisplay() { /* Reflect boardState in UI */ }\n private void UpdateTurnIndicator() { /* Display currentTurn */ }\n}\n```\n\n**Key points:**\n- UI callback -> `SendCustomNetworkEvent(Owner)` -> Owner validates and modifies -> `RequestSerialization()` -> Everyone receives via `OnDeserialization()`\n- Non-owners can still press buttons (delegated to owner)\n- Invalid operations can be rejected by the owner (design similar to server authority)\n\n\n## See Also\n\n- [networking.md](networking.md) - Sync modes, ownership, network events, data limits\n- [networking-antipatterns.md](networking-antipatterns.md) - Anti-patterns and advanced networking patterns\n- [patterns-networking.md](patterns-networking.md) - Object pooling, game state, NetworkCallable\n- [sync-examples.md](sync-examples.md) - Concrete synced gimmick patterns with data budget reference\n- [troubleshooting.md](troubleshooting.md) - Debugging networking issues\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12073,"content_sha256":"c58b8cc539fe8803d4b63ec43fdf5c4a3cc5d0080f6a296fcb97e949a9cf8b81"},{"filename":"references/networking.md","content":"# UdonSharp Networking Reference\n\nComprehensive guide to networking and synchronization in UdonSharp.\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\n> **Warning**: Networking in Udon is a work in progress and can be fragile. Keep implementations simple and test thoroughly with multiple players.\n>\n> **Best Practice**: \"The key to sync is NOT to sync.\" Minimize synced data and use local calculation where possible.\n>\n> **SDK 3.8.1+ New Feature**: The `[NetworkCallable]` attribute enables **network events with parameters**. See [Network Events with Parameters](#network-events-with-parameters-sdk-381) for details.\n\n## Sync Methods (BehaviourSyncMode)\n\nThree sync modes provided by VRChat. Specified using the `UdonBehaviourSyncMode` attribute:\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] // Specify sync mode\npublic class MySyncedScript : UdonSharpBehaviour\n{\n // ...\n}\n```\n\n### Continuous\n\nAutomatic synchronization at approximately **10Hz** (10 times per second).\n\n**Characteristics:**\n- Auto-transmits synced variables without calling `RequestSerialization()`\n- Data limit: approximately **200 bytes** per UdonBehaviour\n- Best for: Positions, rotations, continuously changing values\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]\npublic class ContinuousSyncExample : UdonSharpBehaviour\n{\n [UdonSynced] public Vector3 position; // 12 bytes\n [UdonSynced] public Quaternion rotation; // 16 bytes\n [UdonSynced] public float speed; // 4 bytes\n // Total: 32 bytes (within 200 byte limit)\n\n void Update()\n {\n if (Networking.IsOwner(gameObject))\n {\n position = transform.position;\n rotation = transform.rotation;\n // No RequestSerialization() needed!\n }\n else\n {\n // Apply synced values\n transform.position = position;\n transform.rotation = rotation;\n }\n }\n}\n```\n\n**Limitations:**\n- Limited data capacity (~200 bytes)\n- High network overhead due to constant transmission\n- Not suitable for large data or infrequent updates\n\n### Manual\n\nExplicit synchronization via `RequestSerialization()` calls.\n\n**Characteristics:**\n- Only syncs when `RequestSerialization()` is called\n- Data limit: **280,496 bytes (~280KB)** per serialization (increased from 65,024 bytes in an earlier release)\n- Best for: Game state, scores, settings, infrequent updates\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class ManualSyncExample : UdonSharpBehaviour\n{\n [UdonSynced] public int score;\n [UdonSynced] public int gameState; // Use int/enum for multiple flags\n [UdonSynced] public string playerName;\n\n public void UpdateScore(int newScore)\n {\n if (!Networking.IsOwner(gameObject))\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n\n score = newScore;\n RequestSerialization(); // Explicit sync required!\n }\n}\n```\n\n**Advantages:**\n- Full control over sync timing\n- Much larger data capacity\n- Low network overhead for infrequent updates\n\n### None (No Variable Sync)\n\nCompletely disables variable synchronization. Uses network events for communication.\n\n**Characteristics:**\n- No synced variables supported (`[UdonSynced]` will error)\n- Must use `SendCustomNetworkEvent()` for communication\n- Best for: Local-only logic, event-driven communication, reducing network overhead\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class NoSyncExample : UdonSharpBehaviour\n{\n // Cannot use [UdonSynced] with NoVariableSync mode!\n // [UdonSynced] private int score; // ERROR!\n\n public void TriggerGlobalEvent()\n {\n SendCustomNetworkEvent(\n VRC.Udon.Common.Interfaces.NetworkEventTarget.All,\n nameof(OnGlobalEvent)\n );\n }\n\n public void OnGlobalEvent()\n {\n // All players execute this\n Debug.Log(\"Event received!\");\n }\n}\n```\n\n**When to use NoVariableSync:**\n- Purely event-based systems (buttons, triggers)\n- Objects that only need notifications, not state synchronization\n- Reducing network overhead in complex worlds\n\n### Mode Selection Guide\n\n| Mode | Data size | Frequency | Use case |\n|--------|-------------|------|-------------|\n| Continuous | ~200 bytes | High (10Hz) | Position/rotation tracking |\n| Manual | ~280KB (280,496 bytes) | On-demand | Game state, scores, settings |\n| None | N/A | N/A | Event-only communication |\n\n## VRC_ObjectSync Warning\n\n> **Critical**: When using `VRC_ObjectSync` component alongside `UdonBehaviour`, be aware of sync freezing!\n\nWhen a physics object with `VRC_ObjectSync` stops moving (becomes stationary), the sync system stops transmitting. **This also affects coexisting UdonBehaviour synced variables!**\n\n```csharp\n// PROBLEM: Object stops -> Udon sync also freezes\npublic class ProblematicPickup : UdonSharpBehaviour\n{\n [UdonSynced] public int useCount; // May stop syncing when object is stationary!\n\n public override void OnPickupUseDown()\n {\n useCount++;\n RequestSerialization(); // Might not transmit if object is still!\n }\n}\n```\n\n**Workarounds:**\n1. **Separate UdonBehaviour**: Place sync logic on a separate GameObject without VRC_ObjectSync\n2. **Use network events**: Use `SendCustomNetworkEvent()` for critical updates\n3. **Force movement**: Apply minimal velocity to maintain sync (not recommended)\n\n```csharp\n// SOLUTION: Separate synced logic from physics object\npublic class SeparatedSyncLogic : UdonSharpBehaviour\n{\n // This script is on a SEPARATE GameObject without VRC_ObjectSync\n [UdonSynced] public int useCount;\n\n public void IncrementUse()\n {\n if (!Networking.IsOwner(gameObject))\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n useCount++;\n RequestSerialization(); // Now works reliably\n }\n}\n```\n\n## Late Joiner Considerations\n\nWhen a player joins mid-session, synced data behaves differently:\n\n### Synced Variables (Automatic)\n\nSynced variables are **automatically sent** to late joiners:\n\n```csharp\n[UdonSynced] private int gameScore; // Auto-synced to late joiners\n[UdonSynced] private bool isGameActive; // Auto-synced to late joiners\n\n// Late joiners receive current values via OnDeserialization\npublic override void OnDeserialization()\n{\n UpdateGameDisplay();\n}\n```\n\n### Network Events (Manual Handling Required)\n\nNetwork events are **not re-sent** to late joiners:\n\n```csharp\n// PROBLEM: Late joiners miss this event\npublic void StartGame()\n{\n SendCustomNetworkEvent(NetworkEventTarget.All, \"OnGameStarted\");\n}\n\npublic void OnGameStarted()\n{\n // Late joiners never receive this!\n ShowGameUI();\n}\n```\n\n**Solution: Use synced variables for state**\n\n```csharp\n[UdonSynced, FieldChangeCallback(nameof(GamePhase))]\nprivate int _gamePhase = 0;\n\npublic int GamePhase\n{\n get => _gamePhase;\n set\n {\n _gamePhase = value;\n OnGamePhaseChanged(); // Called for late joiners via OnDeserialization\n }\n}\n\nprivate void OnGamePhaseChanged()\n{\n switch (_gamePhase)\n {\n case 0: ShowLobbyUI(); break;\n case 1: ShowGameUI(); break;\n case 2: ShowResultsUI(); break;\n }\n}\n\npublic void StartGame()\n{\n if (!Networking.IsOwner(gameObject)) return;\n GamePhase = 1;\n RequestSerialization();\n}\n```\n\n### Side-Effect Guard for Late Joiners\n\nWhen a late joiner enters a world, `OnDeserialization` fires for all synced variables. If side effects (audio, animations, particles) are triggered directly in `OnDeserialization`, they will play unintentionally on join.\n\n#### The `_isInitialized` Flag Pattern\n\nUse an initialization flag to skip side effects on the first `OnDeserialization` call:\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class SafeSyncedObject : UdonSharpBehaviour\n{\n [UdonSynced, FieldChangeCallback(nameof(GameState))]\n private int _gameState;\n\n public AudioSource sfx;\n public Animator animator;\n\n private bool _isInitialized = false;\n\n public int GameState\n {\n get => _gameState;\n set\n {\n int previousState = _gameState;\n _gameState = value;\n ApplyState(previousState);\n }\n }\n\n public override void OnDeserialization()\n {\n if (!_isInitialized)\n {\n _isInitialized = true;\n // First deserialization (late joiner): apply state silently\n ApplyStateWithoutSideEffects();\n return;\n }\n // Subsequent deserializations: side effects are handled by\n // FieldChangeCallback (GameState property setter), not here\n }\n\n private void ApplyState(int previousState)\n {\n // Update visuals (always safe)\n UpdateDisplay();\n\n // Side effects only after initialization\n if (_isInitialized && previousState != _gameState)\n {\n sfx.Play();\n animator.SetTrigger(\"StateChange\");\n }\n }\n\n private void ApplyStateWithoutSideEffects()\n {\n UpdateDisplay();\n }\n\n private void UpdateDisplay() { /* Update UI/visuals */ }\n}\n```\n\n#### Using `OnDeserialization(DeserializationResult)` Overload\n\nThe overloaded `OnDeserialization(DeserializationResult)` provides timing context (`sendTime`, `receiveTime`) and storage origin (`isFromStorage`). These fields are useful for latency analysis and storage-restored data detection, but **do not directly identify late-joiner initial sync**. Use the `_isInitialized` flag pattern for late-joiner guards:\n\n##### DeserializationResult Properties\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `sendTime` | `float` | Time (in seconds, server clock) when the owner sent this update |\n| `receiveTime` | `float` | Time (in seconds, server clock) when this client received the update |\n| `isFromStorage` | `bool` | True if the data was restored from persistent storage rather than sent live |\n\n```csharp\npublic override void OnDeserialization(DeserializationResult result)\n{\n // Update visuals (always safe)\n UpdateDisplay();\n\n // Calculate network latency when valid timing data is available\n if (result.receiveTime > result.sendTime)\n {\n float latencySeconds = result.receiveTime - result.sendTime;\n Debug.Log($\"Network latency: {latencySeconds * 1000f:F1} ms\");\n }\n\n // Guard side effects: skip on initial sync for late joiners\n // Note: DeserializationResult does not provide a late-joiner flag;\n // use _isInitialized for this purpose\n if (!_isInitialized)\n {\n _isInitialized = true;\n return;\n }\n\n // Runtime update: play effects\n PlayTransitionEffects();\n}\n```\n\n> **Guard**: Always check `receiveTime > sendTime` before computing latency. In edge cases (storage restore, clock skew), `sendTime` may be zero or greater than `receiveTime`.\n\n> **Common pitfall**: Without this guard, a late joiner entering a multiplayer game will hear all audio cues and see all animations replay simultaneously. This was a reported issue in real-world VRChat game development.\n\n## Optimization Tips\n\n### Use Integers/Enums for Multiple Flags\n\nInstead of syncing multiple bools, pack them into a single integer:\n\n```csharp\n// BAD: Multiple synced bools\n[UdonSynced] private bool hasKey;\n[UdonSynced] private bool hasSword;\n[UdonSynced] private bool hasShield;\n[UdonSynced] private bool hasPotion;\n\n// GOOD: Single synced integer with bit flags\n[UdonSynced] private int inventory;\n\nprivate const int FLAG_KEY = 1;\nprivate const int FLAG_SWORD = 2;\nprivate const int FLAG_SHIELD = 4;\nprivate const int FLAG_POTION = 8;\n\npublic bool HasKey => (inventory & FLAG_KEY) != 0;\npublic bool HasSword => (inventory & FLAG_SWORD) != 0;\n\npublic void AddItem(int flag)\n{\n inventory |= flag;\n RequestSerialization();\n}\n```\n\n### Prefer Local Calculation Over Sync\n\nCalculate locally when possible:\n\n```csharp\n// BAD: Syncing calculated value\n[UdonSynced] private float elapsedTime;\n\nvoid Update()\n{\n if (Networking.IsOwner(gameObject))\n {\n elapsedTime += Time.deltaTime;\n RequestSerialization(); // Too frequent!\n }\n}\n\n// GOOD: Sync start time, calculate locally\n[UdonSynced] private double startServerTime;\n\nvoid Update()\n{\n if (startServerTime > 0)\n {\n float elapsed = (float)(Networking.GetServerTimeInSeconds() - startServerTime);\n timerDisplay.text = elapsed.ToString(\"F1\");\n }\n}\n```\n\n## Core Concepts\n\n### Ownership Model\n\nEvery GameObject with an UdonBehaviour has a network owner:\n\n- **Default owner**: Instance master (first player to join)\n- **One owner per object**: Ownership is per-GameObject, not per-component\n- **Only owner can modify**: Only the owner can change synced variables\n\n```csharp\n// Check if local player is owner\nif (Networking.IsOwner(gameObject))\n{\n // Safe to modify synced variables\n}\n\n// Get current owner\nVRCPlayerApi owner = Networking.GetOwner(gameObject);\nDebug.Log($\"Owner: {owner.displayName}\");\n```\n\n### Ownership Transfer\n\n```csharp\n// Request ownership for the local player.\n// Locally immediate — IsOwner(gameObject) returns true after this call.\nNetworking.SetOwner(Networking.LocalPlayer, gameObject);\n```\n\n**Set owner if needed and update synced state in one call:**\n\n```csharp\npublic void RequestOwnershipAndUpdate(int newValue)\n{\n if (!Networking.IsOwner(gameObject))\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n\n syncedValue = newValue;\n RequestSerialization();\n}\n\n// OnOwnershipTransferred remains useful for inheritance scenarios\n// (e.g., the previous owner left and you became owner without a\n// SetOwner call of your own). For SetOwner-initiated transfers, the\n// callback fires synchronously inside SetOwner — usually you do not\n// need to write code in it for the calling-side path.\npublic override void OnOwnershipTransferred(VRCPlayerApi player)\n{\n if (player.isLocal)\n {\n // Re-broadcast state so non-owner clients converge.\n RequestSerialization();\n }\n}\n```\n\n### Ownership Transfer Timing Semantics\n\n- **On the calling client:** `Networking.SetOwner` takes effect immediately. `Networking.IsOwner(gameObject)` returns `true` synchronously after the call. `OnOwnershipTransferred` fires synchronously within the `SetOwner` stack on the calling client.\n- **On remote clients:** the new ownership becomes visible after VRChat propagates the change. Each remote client's `OnOwnershipTransferred` fires when the propagation arrives.\n- **No client-side arbitration:** when two clients call `SetOwner` simultaneously, both succeed locally and may write synced variables; VRChat resolves the durable owner by network arrival order. The loser's write is overwritten when the winner's serialization arrives. This is by design — see [networking-antipatterns.md §1](networking-antipatterns.md#1-ownership-race-condition) for the recommended `IsOwner`-guarded pattern, and [§\"Ownership Arbitration with OnOwnershipRequest\"](#ownership-arbitration-with-onownershiprequest) below for owner-side protection during critical actions.\n\n> *Footnote: Pre-2021.2.2 SDKs treated `SetOwner` as asynchronous on the calling client; current SDKs (3.7.1+, this skill's coverage range) are locally immediate. Source: [Ownership Transfer Events](https://creators.vrchat.com/worlds/udon/networking/ownership/#transfer-events-diagram).*\n\n### Owner Leave and Ownership Cascade\n\nWhen the owner of a networked GameObject disconnects:\n\n1. **VRChat automatically assigns a new owner** — the exact selection rule is not publicly documented; do not assume a specific player will be chosen\n2. **`OnOwnershipTransferred` fires** on all clients with the new owner\n3. **Synced variables are preserved** — they are not reset when ownership transfers\n\n```csharp\npublic override void OnOwnershipTransferred(VRCPlayerApi player)\n{\n if (player == null || !player.IsValid()) return;\n\n if (player.isLocal)\n {\n // We became the new owner (e.g., previous owner left)\n // Resume game logic that only the owner should run\n Debug.Log(\"Inherited ownership — resuming owner duties\");\n RequestSerialization(); // Re-broadcast current state\n }\n}\n\npublic override void OnPlayerLeft(VRCPlayerApi player)\n{\n if (player == null || !player.IsValid()) return;\n\n // Clean up player-specific data\n // Note: If this player was the owner, OnOwnershipTransferred\n // will fire separately with the new owner\n RemovePlayerFromGame(player.playerId);\n}\n```\n\n**Best practices for ownership transitions:**\n- Do **not** assume the local player will become the new owner — VRChat decides\n- Always re-broadcast state via `RequestSerialization()` when inheriting ownership\n- Clean up departing player data in `OnPlayerLeft`, not in `OnOwnershipTransferred`\n- If your game logic runs in `Update()` with an owner check, it will automatically resume on the new owner\n\n### Ownership Arbitration with OnOwnershipRequest\n\n`OnOwnershipRequest` allows the current owner to **accept or reject** ownership transfer requests:\n\n```csharp\nprivate bool _isProcessingCriticalAction = false;\n\npublic override bool OnOwnershipRequest(\n VRCPlayerApi requestingPlayer,\n VRCPlayerApi requestedOwner)\n{\n // Reject ownership transfers during critical game logic\n if (_isProcessingCriticalAction)\n {\n Debug.Log($\"Rejected ownership request from {requestingPlayer.displayName} \" +\n $\"— critical action in progress\");\n return false;\n }\n\n // Accept the transfer\n return true;\n}\n```\n\n**When to use `OnOwnershipRequest`:**\n\n| Scenario | Return |\n|----------|--------|\n| Default (no override) | Always accepts (`true`) |\n| During critical game state transitions | Reject (`false`) until complete |\n| Turn-based game during active turn | Reject (`false`) until turn ends |\n| Free-for-all interaction | Accept (`true`) |\n\n> **Important**: `OnOwnershipRequest` runs locally on **both the requester and the current owner** (per the official [Network Components page](https://creators.vrchat.com/worlds/udon/networking/network-components/): \"This logic runs locally on both the requester and the owner\"). The logic must return the same result on both sides to avoid desync. If the current owner has disconnected, the callback is not invoked — VRChat auto-assigns directly.\n>\n> The two parameters are `VRCPlayerApi requestingPlayer` (the player calling `SetOwner`) and `VRCPlayerApi requestedOwner` (the player being assigned ownership — typically the same as `requestingPlayer` for self-promotion, but can differ when one client transfers ownership to another).\n\n## Synced Variables\n\n### Basic Synchronization\n\nUse the `[UdonSynced]` attribute to synchronize fields:\n\n```csharp\n[UdonSynced] private int score;\n[UdonSynced] private float health;\n[UdonSynced] private bool isActive;\n[UdonSynced] private Vector3 position;\n[UdonSynced] private string playerName; // 2 bytes/char; keep short in Continuous mode (~200 byte shared budget)\n```\n\n### Sync Modes\n\n```csharp\n// Default: Sync when changed (no interpolation)\n[UdonSynced]\nprivate int normalSync;\n\n// Linear interpolation for continuous values\n[UdonSynced(UdonSyncMode.Linear)]\nprivate Vector3 linearPosition; // Interpolates between updates\n\n// Smooth damped interpolation\n[UdonSynced(UdonSyncMode.Smooth)]\nprivate Quaternion smoothRotation; // Smoothly interpolates\n\n// With FieldChangeCallback (called on value change)\n[UdonSynced, FieldChangeCallback(nameof(SmoothPosition))]\nprivate Vector3 _smoothPosition;\npublic Vector3 SmoothPosition\n{\n get => _smoothPosition;\n set\n {\n _smoothPosition = value;\n // Handle smooth interpolation here\n }\n}\n```\n\n**UdonSyncMode values:**\n\n| Mode | Description | Best for |\n|--------|------|-----------|\n| `UdonSyncMode.None` | No interpolation (default) | Discrete values, flags, state |\n| `UdonSyncMode.Linear` | Linear interpolation between updates | Position, rotation, continuous values |\n| `UdonSyncMode.Smooth` | Smooth damped interpolation | Cameras, slow movement, UI elements |\n\n**Important:** Interpolation modes only affect how the receiving client applies values between network updates. Sync frequency is determined by BehaviourSyncMode (Continuous ~10Hz, Manual on-demand).\n\n### Requesting Serialization\n\nAfter changing synced variables, call `RequestSerialization()`:\n\n```csharp\npublic void IncrementScore()\n{\n if (!Networking.IsOwner(gameObject))\n {\n // SetOwner is locally immediate — IsOwner is true after this returns.\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n\n score += 10;\n RequestSerialization(); // Broadcast to all players\n}\n```\n\n### Detecting Changes\n\nUse `OnDeserialization` or `FieldChangeCallback`:\n\n```csharp\n// Method 1: OnDeserialization\n[UdonSynced] private int score;\n\npublic override void OnDeserialization()\n{\n // Called when synced data is received\n UpdateScoreDisplay();\n}\n\n// Method 2: FieldChangeCallback (more granular)\n[UdonSynced, FieldChangeCallback(nameof(Health))]\nprivate float _health;\n\npublic float Health\n{\n get => _health;\n set\n {\n _health = value;\n OnHealthChanged();\n }\n}\n\nprivate void OnHealthChanged()\n{\n healthBar.value = _health;\n}\n```\n\n## Network Events\n\n### SendCustomNetworkEvent (Legacy)\n\nSend events to all players or owner only (no parameters):\n\n```csharp\n// Send to ALL players (including self)\nSendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, \"OnButtonPressed\");\n\n// Send to OWNER only\nSendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, \"ProcessOwnerAction\");\n\n// The receiving method (must be public)\npublic void OnButtonPressed()\n{\n Debug.Log(\"Button pressed!\");\n}\n```\n\n**NetworkEventTarget options**:\n\n| Target | SDK | Description |\n|-----------|-----|------|\n| `NetworkEventTarget.All` | All versions | All players including self |\n| `NetworkEventTarget.Owner` | All versions | Object owner only |\n| `NetworkEventTarget.Others` | **3.8.1+** | All players except self |\n| `NetworkEventTarget.Self` | **3.8.1+** | Self only (equivalent to local execution) |\n\n> **SDK 3.8.1+ new targets**: `NetworkEventTarget.Others` sends to \"everyone except the sender\", preventing duplicate effect/sound playback. `NetworkEventTarget.Self` can be used for local-only processing.\n\n**Limitations (Legacy)**:\n- Cannot send parameters with network events\n- Cannot directly target specific players (Others/Self added in SDK 3.8.1+)\n- Events may arrive before synced variable updates (race condition!)\n- Events are not queued and arrival order is not guaranteed\n\n---\n\n## Network Events with Parameters (SDK 3.8.1+)\n\nThe `[NetworkCallable]` attribute added in **SDK 3.8.1** enables sending **up to 8 parameters** with network events.\n\n### [NetworkCallable] Attribute\n\nMethods callable over the network require the `[NetworkCallable]` attribute:\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class NetworkCallableExample : UdonSharpBehaviour\n{\n [NetworkCallable]\n public void TakeDamage(int damage, int attackerId)\n {\n Debug.Log($\"Received {damage} damage from player {attackerId}\");\n }\n\n public void Attack(VRCPlayerApi target, int damage)\n {\n // Send network event with parameters\n SendCustomNetworkEvent(\n NetworkEventTarget.All,\n nameof(TakeDamage),\n damage,\n Networking.LocalPlayer.playerId\n );\n }\n}\n```\n\n### [NetworkCallable] Constraints\n\n| Constraint | Details |\n|------|------|\n| `public` required | Method must be public |\n| `[NetworkCallable]` required | Without the attribute, parameters cannot be received |\n| `static` not allowed | Static methods cannot be used |\n| `virtual`/`override` not allowed | Virtual methods cannot be used |\n| No overloading | Multiple methods with the same name not allowed |\n| Maximum 8 parameters | More than 8 parameters not allowed |\n| Syncable types only | Parameters limited to syncable types |\n\n### Rate Limiting\n\n`[NetworkCallable]` accepts an optional integer parameter that controls the maximum call rate (in calls per second) allowed for that method per behaviour instance. This value also acts as the network cost/priority indicator — higher values consume more network budget and are scheduled at higher priority.\n\n```csharp\n// Default: 5 calls/sec per behaviour (no argument)\n[NetworkCallable]\npublic void NormalEvent(int value) { }\n\n// Custom rate: 100 calls/sec (maximum allowed)\n[NetworkCallable(100)]\npublic void HighFrequencyEvent(float value) { }\n\n// Low rate: 1 call/sec (minimal network cost)\n[NetworkCallable(1)]\npublic void RareBroadcast(string message) { }\n```\n\n**Note**: Events exceeding the rate limit are dropped. Rate limiting is applied **per event per behaviour**. Default is **5 calls/sec**, configurable up to **100 calls/sec** per behaviour.\n\n### Types Usable as Parameters\n\nOnly types syncable with `[UdonSynced]` can be used as parameters:\n\n| Type | Size | Notes |\n|------|------|-------|\n| `bool` | 1 byte | |\n| `byte`, `sbyte` | 1 byte | |\n| `short`, `ushort` | 2 bytes | |\n| `int`, `uint` | 4 bytes | |\n| `long`, `ulong` | 8 bytes | |\n| `float` | 4 bytes | |\n| `double` | 8 bytes | |\n| `string` | 2 bytes/char | No fixed per-string limit; bounded by NetworkCallable event payload (16 KB/event max, ~18 KB/s throughput). Events >1024 bytes are split into multiple internal packets. Independent of `[UdonSynced]` sync mode budgets. |\n| `Vector2/3/4` | 8/12/16 bytes | |\n| `Quaternion` | 16 bytes | |\n| `Color`, `Color32` | 16/4 bytes | |\n| Arrays of above | variable | |\n\n**Not usable**: `GameObject`, `Transform`, `VRCPlayerApi`, custom classes\n\n### Practical Pattern: Damage System\n\nFor a full `[NetworkCallable]`-based damage system with ownership forwarding, hit effects, and death handling, see the `DamageReceiver` example in [patterns-networking.md](patterns-networking.md#networkcallable-patterns-sdk-381).\n\n### Legacy vs NetworkCallable Comparison\n\n| Feature | Legacy | NetworkCallable (3.8.1+) |\n|------|--------|--------------------------|\n| Sending parameters | Not possible | Up to 8 |\n| Attribute | Not required | `[NetworkCallable]` required |\n| Rate limiting | None | Configurable (1-100/sec) |\n| Backward compatibility | All versions | SDK 3.8.1+ only |\n\n### Migration Guide\n\n**Before (Legacy):**\n\n```csharp\n[UdonSynced] private int pendingDamage;\n[UdonSynced] private int pendingAttackerId;\n\npublic void Attack(int damage, int attackerId)\n{\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n pendingDamage = damage;\n pendingAttackerId = attackerId;\n RequestSerialization();\n SendCustomNetworkEvent(NetworkEventTarget.All, \"OnAttack\");\n}\n\npublic void OnAttack()\n{\n // pendingDamage may still be the old value (race condition)\n ProcessDamage(pendingDamage, pendingAttackerId);\n}\n```\n\n**After (NetworkCallable):**\n\n```csharp\n[NetworkCallable]\npublic void Attack(int damage, int attackerId)\n{\n // Parameters are reliably delivered (no race condition)\n ProcessDamage(damage, attackerId);\n}\n\npublic void TriggerAttack(int damage)\n{\n SendCustomNetworkEvent(\n NetworkEventTarget.All,\n nameof(Attack),\n damage,\n Networking.LocalPlayer.playerId\n );\n}\n```\n\n### Race Condition Between Network Events and Synced Variables\n\n**Critical issue**: When sending a network event and updating synced variables simultaneously, the event may arrive before the synced variable update on remote clients:\n\n```csharp\n// PROBLEM: Event may arrive before syncedData is updated on remote clients\npublic void SendDataWithEvent()\n{\n syncedData = \"important data\";\n RequestSerialization();\n SendCustomNetworkEvent(NetworkEventTarget.All, \"ProcessData\");\n}\n\npublic void ProcessData()\n{\n // syncedData might still be the OLD value here!\n Debug.Log(syncedData); // Might print old data!\n}\n```\n\n**Solution: Use FieldChangeCallback instead of events**\n\n```csharp\n[UdonSynced, FieldChangeCallback(nameof(SyncedData))]\nprivate string _syncedData;\n\npublic string SyncedData\n{\n get => _syncedData;\n set\n {\n _syncedData = value;\n ProcessData(); // Called after data is actually updated\n }\n}\n\npublic void SendData()\n{\n SyncedData = \"important data\";\n RequestSerialization();\n // No need for network event - FieldChangeCallback handles it\n}\n\nprivate void ProcessData()\n{\n // syncedData is guaranteed to be the new value here\n Debug.Log(_syncedData);\n}\n```\n\n### Workaround: Targeting Specific Players\n\nSince direct player targeting is not available, use synced variables:\n\n```csharp\n[UdonSynced] private int targetPlayerId;\n[UdonSynced] private string message;\n\npublic void SendMessageToPlayer(VRCPlayerApi player, string msg)\n{\n if (!Networking.IsOwner(gameObject))\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n\n targetPlayerId = player.playerId;\n message = msg;\n RequestSerialization();\n\n SendCustomNetworkEvent(NetworkEventTarget.All, \"CheckMessage\");\n}\n\npublic void CheckMessage()\n{\n if (Networking.LocalPlayer.playerId == targetPlayerId)\n {\n ProcessMessage(message);\n }\n}\n```\n\n## Data Limits\n\n### String Length\n\nSynced strings have no fixed character limit. The practical limit depends on sync buffer size and UTF-16 encoding (2 bytes per character):\n- **Continuous**: ~200 bytes per serialization (shared across all synced fields on the behaviour)\n- **Manual**: 280,496 bytes (~280KB) per serialization\n\n```csharp\n// Keep synced strings short to conserve sync buffer\n[UdonSynced] private string status; // \"ready\", \"waiting\", etc.\n\n// For longer data, split across multiple variables\n[UdonSynced] private string data1;\n[UdonSynced] private string data2;\n[UdonSynced] private string data3;\n```\n\n### Bandwidth Limits\n\nVRChat limits network data rate. Excessive synchronization causes \"Death Runs\" (data loss):\n\n```csharp\n// WRONG - Too frequent updates\nvoid Update()\n{\n position = transform.position;\n RequestSerialization(); // Every frame = bad!\n}\n\n// CORRECT - Throttle updates\nprivate float lastSyncTime;\nprivate const float SYNC_INTERVAL = 0.1f; // 10 times per second max\n\nvoid Update()\n{\n if (Time.time - lastSyncTime > SYNC_INTERVAL)\n {\n if (HasPositionChanged())\n {\n position = transform.position;\n RequestSerialization();\n lastSyncTime = Time.time;\n }\n }\n}\n```\n\n## Object Pooling\n\nDynamic instantiation is not network-supported in VRChat. Use object pooling with pre-placed GameObjects.\n\nFor full implementations, see:\n- Simple pool: [patterns-networking.md](patterns-networking.md#object-pooling)\n- Master-managed player pool: [assets/templates/MasterManagedPlayerPool.cs](../assets/templates/MasterManagedPlayerPool.cs)\n\n## Player Events\n\n```csharp\npublic override void OnPlayerJoined(VRCPlayerApi player)\n{\n if (player == null || !player.IsValid()) return;\n\n Debug.Log($\"{player.displayName} joined\");\n\n // Sync state for new player if we're owner\n if (Networking.IsOwner(gameObject))\n {\n RequestSerialization();\n }\n}\n\npublic override void OnPlayerLeft(VRCPlayerApi player)\n{\n if (player == null || !player.IsValid()) return;\n\n Debug.Log($\"{player.displayName} left\");\n\n // Handle ownership transfer if the owner left\n // VRChat automatically assigns a new owner\n}\n\npublic override void OnOwnershipTransferred(VRCPlayerApi player)\n{\n if (player == null || !player.IsValid()) return;\n\n Debug.Log($\"New owner: {player.displayName}\");\n\n if (player.isLocal)\n {\n // We are now the owner, can modify synced variables\n }\n}\n```\n\n## Common Patterns\n\n### Master-Only Actions\n\n> **Warning**: `Networking.IsMaster` is not deprecated, but it is fragile in practice. The instance master is the first player to join. If that player leaves, the master role transfers to another player, creating a brief window where no action runs, or two clients race to act simultaneously. Prefer owner-centric patterns for any logic that must run reliably. See [Owner-Centric Architecture Migration](#owner-centric-architecture-migration) below.\n\n```csharp\npublic void DoMasterAction()\n{\n if (Networking.IsMaster)\n {\n // Only instance master executes this\n PerformAction();\n SendCustomNetworkEvent(NetworkEventTarget.All, \"OnActionPerformed\");\n }\n}\n```\n\n### Local Player Detection\n\n```csharp\npublic void OnInteract()\n{\n VRCPlayerApi localPlayer = Networking.LocalPlayer;\n\n if (localPlayer != null)\n {\n interactingPlayerId = localPlayer.playerId;\n interactingPlayerName = localPlayer.displayName;\n RequestSerialization();\n }\n}\n```\n\n### Synced Timer\n\n```csharp\n[UdonSynced] private float gameStartTime;\n[UdonSynced] private bool gameRunning;\n\npublic void StartGame()\n{\n if (!Networking.IsMaster) return;\n\n gameStartTime = (float)Networking.GetServerTimeInSeconds();\n gameRunning = true;\n RequestSerialization();\n}\n\nvoid Update()\n{\n if (!gameRunning) return;\n\n float elapsed = (float)Networking.GetServerTimeInSeconds() - gameStartTime;\n timerDisplay.text = elapsed.ToString(\"F1\");\n}\n```\n\n---\n\n## Owner-Centric Architecture Migration\n\n`Networking.IsMaster` checks which player is the **instance master** (the first player to join).\nUsing it to gate critical logic creates two failure modes:\n\n1. **Master-leave gap**: When the master disconnects, VRChat transfers the master role to\n another player. During the brief transition, `Networking.IsMaster` returns `false` on all\n clients simultaneously — timed events or game-state updates can be silently dropped.\n\n2. **Concurrent master race**: If two clients check `Networking.IsMaster` in the same frame\n during a handoff, both may act, causing duplicate state mutations.\n\nThe **owner-centric** pattern reduces both risks: a specific `GameObject` has exactly one\nowner at all times, so `Networking.IsOwner(gameObject)` typically returns the same result\nacross all clients. Note that during ownership transfers (e.g., the current owner leaves),\nthere is a brief transient window where clients may briefly disagree on who the owner is\nuntil VRChat propagates the new ownership to other clients. The `OnOwnershipTransferred` callback is the correct\nplace to handle this case — re-initialize owner-only state and call `RequestSerialization()`\nso all clients converge to the new owner's authoritative state.\n\n### Refactoring Pattern: IsMaster → IsOwner\n\n**Before (IsMaster)**\n\n```csharp\npublic void StartGame()\n{\n if (!Networking.IsMaster) return; // Fragile: master may leave mid-check\n\n gameStartTime = (float)Networking.GetServerTimeInSeconds();\n gameRunning = true;\n RequestSerialization();\n}\n```\n\n**After (owner-centric)**\n\n```csharp\n// Assign one dedicated GameObject as the \"game manager\" object.\n// Its owner is the authoritative game controller.\n\npublic void StartGame()\n{\n if (!Networking.IsOwner(gameObject)) return; // Stable: exactly one owner\n\n gameStartTime = (float)Networking.GetServerTimeInSeconds();\n gameRunning = true;\n RequestSerialization();\n}\n```\n\n### Handling Owner Leave\n\nWhen the owner of the manager object leaves, VRChat automatically transfers ownership.\nResume game-manager duties in `OnOwnershipTransferred`:\n\n```csharp\npublic override void OnOwnershipTransferred(VRCPlayerApi player)\n{\n if (player == null || !player.IsValid()) return;\n\n if (player.isLocal)\n {\n // Inherited ownership — re-broadcast current state so late joiners are covered\n RequestSerialization();\n\n // Resume any periodic owner duties here\n if (gameRunning)\n {\n SendCustomEventDelayedSeconds(nameof(OwnerHeartbeat), 1.0f);\n }\n }\n}\n```\n\n### Migration Decision Table\n\n| Scenario | Use `IsMaster`? | Use `IsOwner`? |\n|---|---|---|\n| One-off world init (fires once at world launch) | Acceptable | Preferred |\n| Ongoing game logic (timers, spawning, scoring) | No — fragile | Yes |\n| Responding to player join/leave events | No — may double-fire | Yes |\n| Approving ownership transfers (`OnOwnershipRequest`) | No — wrong API | Neither[^owner-req] |\n| Checking if a specific player is the master | `player.isMaster` on `VRCPlayerApi` | N/A |\n\n[^owner-req]: The callback fires on both the requester and the current owner; logic must agree on both clients to avoid desync. See [Ownership Arbitration with OnOwnershipRequest](#ownership-arbitration-with-onownershiprequest) above.\n\n> **Reference**: VRChat networking documentation — https://creators.vrchat.com/worlds/udon/networking/\n\n---\n\n## See Also\n\n- [networking-bandwidth.md](networking-bandwidth.md) - Bandwidth throttling, bit packing, owner-centric architecture, debugging\n- [networking-antipatterns.md](networking-antipatterns.md) - 6 anti-patterns and 5 advanced patterns\n- [patterns-networking.md](patterns-networking.md) - Object pooling, game state management, NetworkCallable patterns\n- [persistence.md](persistence.md) - PlayerData/PlayerObject API for persisting data across sessions\n- [context-preservation.md](context-preservation.md) - Task-context notes for source-of-truth, ownership, sync, and late-joiner decisions\n- [sync-examples.md](sync-examples.md) - Concrete synced gimmick patterns with data budget reference\n- [troubleshooting.md](troubleshooting.md) - Debugging networking issues, ownership race conditions\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":37675,"content_sha256":"789c600478c788133fc8fabe754e7d1b3acb25e0676deb7d41db5faf7cb35aee"},{"filename":"references/patterns-core.md","content":"# UdonSharp Core Patterns\n\nInitialization, interaction, player detection, timer, audio, pickup, animation, UI, and teleportation patterns for VRChat world development.\n\n## Initialization Patterns\n\nIn VRChat worlds, gimmicks are often **placed in an inactive state** (for performance optimization, conditional display, etc.). Since `Start()` is not called when the GameObject is inactive, initialization requires careful handling.\n\n### Problem: Start() Not Called\n\n```csharp\n// BAD: Start() is not called when placed in an inactive state\npublic class BrokenGimmick : UdonSharpBehaviour\n{\n private AudioSource audioSource;\n\n void Start()\n {\n // This is never reached if the GameObject is inactive!\n audioSource = GetComponent\u003cAudioSource>();\n }\n\n public void PlaySound()\n {\n audioSource.Play(); // NullReferenceException!\n }\n}\n```\n\n### Solution: Separate Initialize Method\n\n```csharp\n// GOOD: OnEnable + initialization flag pattern\npublic class RobustGimmick : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private AudioSource audioSource;\n\n private bool _initialized = false;\n\n void OnEnable()\n {\n // Called when transitioning from inactive to active\n Initialize();\n }\n\n void Start()\n {\n // Initializes here if active from the start\n Initialize();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n // Fallback if not set via SerializeField\n if (audioSource == null)\n {\n audioSource = GetComponent\u003cAudioSource>();\n }\n }\n\n public void PlaySound()\n {\n Initialize(); // Guard against being called externally first\n if (audioSource != null)\n {\n audioSource.Play();\n }\n }\n}\n```\n\n### Full Pattern: Defensive Initialization\n\n```csharp\npublic class DefensiveGimmick : UdonSharpBehaviour\n{\n [Header(\"Configuration\")]\n [SerializeField] private float speed = 1.0f;\n\n [Header(\"References (Auto-filled if empty)\")]\n [SerializeField] private Transform targetTransform;\n [SerializeField] private Renderer targetRenderer;\n\n // Internal state\n private bool _initialized = false;\n private MaterialPropertyBlock _propBlock;\n\n // === Lifecycle ===\n\n void OnEnable()\n {\n Initialize();\n }\n\n void Start()\n {\n Initialize();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n // Auto-fill missing references\n if (targetTransform == null)\n {\n targetTransform = transform;\n }\n if (targetRenderer == null)\n {\n targetRenderer = GetComponent\u003cRenderer>();\n }\n\n // Initialize internal state\n _propBlock = new MaterialPropertyBlock();\n\n // Apply initial state\n ApplyInitialState();\n }\n\n private void ApplyInitialState()\n {\n // Apply initial state (also useful for late joiner support)\n }\n\n // === Public API ===\n\n public void DoAction()\n {\n Initialize(); // Defensive call\n\n // Safe to use all references\n targetRenderer.GetPropertyBlock(_propBlock);\n // ...\n }\n\n // === Reset ===\n\n public void ResetGimmick()\n {\n _initialized = false;\n Initialize();\n }\n}\n```\n\n### Choosing the Right Approach\n\n| Scenario | Recommended pattern |\n|----------|--------------|\n| Always-active objects | `Start()` only is fine |\n| May be placed inactive | `OnEnable()` + `Initialize()` pattern |\n| May be called externally before activation | Call `Initialize()` in public methods too |\n| Combined with synced variables | Call `Initialize()` in `OnDeserialization()` too |\n\n### Combined with Synced Variables\n\n```csharp\npublic class SyncedGimmick : UdonSharpBehaviour\n{\n [UdonSynced, FieldChangeCallback(nameof(State))]\n private int _state = 0;\n\n private bool _initialized = false;\n private Renderer _renderer;\n\n public int State\n {\n get => _state;\n set\n {\n _state = value;\n Initialize(); // Ensure initialization on sync receive\n ApplyState();\n }\n }\n\n void OnEnable() => Initialize();\n void Start() => Initialize();\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n _renderer = GetComponent\u003cRenderer>();\n }\n\n private void ApplyState()\n {\n if (_renderer == null) return;\n _renderer.enabled = _state > 0;\n }\n\n public override void OnDeserialization()\n {\n Initialize(); // Late joiner support\n ApplyState();\n }\n}\n```\n\n---\n\n## Interaction Patterns\n\n### Basic Button\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.Udon;\n\npublic class SimpleButton : UdonSharpBehaviour\n{\n public GameObject targetObject;\n private bool isOn = false;\n\n public override void Interact()\n {\n isOn = !isOn;\n targetObject.SetActive(isOn);\n }\n}\n```\n\n### Button with Cooldown\n\n```csharp\npublic class CooldownButton : UdonSharpBehaviour\n{\n public float cooldownTime = 2.0f;\n private float lastInteractTime = -999f;\n\n public override void Interact()\n {\n if (Time.time - lastInteractTime \u003c cooldownTime)\n {\n return; // Still on cooldown\n }\n\n lastInteractTime = Time.time;\n DoAction();\n }\n\n private void DoAction()\n {\n Debug.Log(\"Button pressed!\");\n }\n}\n```\n\n### Synced Toggle Switch\n\n```csharp\npublic class SyncedSwitch : UdonSharpBehaviour\n{\n public GameObject[] controlledObjects;\n\n [UdonSynced, FieldChangeCallback(nameof(IsOn))]\n private bool _isOn = false;\n\n public bool IsOn\n {\n get => _isOn;\n set\n {\n _isOn = value;\n UpdateObjects();\n }\n }\n\n public override void Interact()\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n IsOn = !IsOn;\n RequestSerialization();\n }\n\n private void UpdateObjects()\n {\n foreach (GameObject obj in controlledObjects)\n {\n if (obj != null)\n {\n obj.SetActive(_isOn);\n }\n }\n }\n}\n```\n\n## Player Detection\n\n### Trigger Zone\n\n```csharp\npublic class PlayerTrigger : UdonSharpBehaviour\n{\n public GameObject activateOnEnter;\n private int playersInZone = 0;\n\n public override void OnPlayerTriggerEnter(VRCPlayerApi player)\n {\n playersInZone++;\n\n if (player.isLocal)\n {\n OnLocalPlayerEnter();\n }\n\n activateOnEnter.SetActive(playersInZone > 0);\n }\n\n public override void OnPlayerTriggerExit(VRCPlayerApi player)\n {\n playersInZone--;\n\n if (player.isLocal)\n {\n OnLocalPlayerExit();\n }\n\n activateOnEnter.SetActive(playersInZone > 0);\n }\n\n private void OnLocalPlayerEnter()\n {\n Debug.Log(\"Welcome to the zone!\");\n }\n\n private void OnLocalPlayerExit()\n {\n Debug.Log(\"Goodbye!\");\n }\n}\n```\n\n### Player Counter Display\n\n```csharp\nusing TMPro;\n\npublic class PlayerCounter : UdonSharpBehaviour\n{\n public TextMeshProUGUI counterText;\n\n void Start()\n {\n UpdateCounter();\n }\n\n public override void OnPlayerJoined(VRCPlayerApi player)\n {\n UpdateCounter();\n }\n\n public override void OnPlayerLeft(VRCPlayerApi player)\n {\n UpdateCounter();\n }\n\n private void UpdateCounter()\n {\n VRCPlayerApi[] players = new VRCPlayerApi[VRCPlayerApi.GetPlayerCount()];\n VRCPlayerApi.GetPlayers(players);\n counterText.text = $\"Players: {players.Length}\";\n }\n}\n```\n\n### Get All Players in Range\n\u003c!-- alias: Array.FindAll alternative — distance/range filter -->\n\n```csharp\npublic class ProximityDetector : UdonSharpBehaviour\n{\n public float detectionRange = 5.0f;\n\n public VRCPlayerApi[] GetPlayersInRange()\n {\n VRCPlayerApi[] allPlayers = new VRCPlayerApi[VRCPlayerApi.GetPlayerCount()];\n VRCPlayerApi.GetPlayers(allPlayers);\n\n // Single pass: collect matches into an oversized temp array, then copy\n VRCPlayerApi[] temp = new VRCPlayerApi[allPlayers.Length];\n int count = 0;\n\n foreach (VRCPlayerApi player in allPlayers)\n {\n if (player != null && player.IsValid())\n {\n float distance = Vector3.Distance(\n transform.position,\n player.GetPosition()\n );\n if (distance \u003c= detectionRange)\n {\n temp[count++] = player;\n }\n }\n }\n\n // Trim to actual count\n VRCPlayerApi[] result = new VRCPlayerApi[count];\n for (int i = 0; i \u003c count; i++)\n {\n result[i] = temp[i];\n }\n\n return result;\n }\n}\n```\n\n### Get Remote Players\n\u003c!-- alias: Array.FindAll alternative — local-player exclusion -->\n\nSame temp-array pattern as `GetPlayersInRange`, but excluding the local player. Useful when broadcasting to \"everyone except me\" or computing remote-only stats.\n\n```csharp\npublic class RemotePlayerCollector : UdonSharpBehaviour\n{\n public VRCPlayerApi[] GetRemotePlayers()\n {\n VRCPlayerApi[] allPlayers = new VRCPlayerApi[VRCPlayerApi.GetPlayerCount()];\n VRCPlayerApi.GetPlayers(allPlayers);\n\n VRCPlayerApi local = Networking.LocalPlayer;\n VRCPlayerApi[] temp = new VRCPlayerApi[allPlayers.Length];\n int count = 0;\n\n foreach (VRCPlayerApi player in allPlayers)\n {\n if (player != null && player.IsValid() && player != local)\n {\n temp[count++] = player;\n }\n }\n\n VRCPlayerApi[] result = new VRCPlayerApi[count];\n for (int i = 0; i \u003c count; i++)\n {\n result[i] = temp[i];\n }\n return result;\n }\n}\n```\n\n## Timer Patterns\n\n### Simple Timer\n\n```csharp\npublic class SimpleTimer : UdonSharpBehaviour\n{\n public float duration = 60f;\n public TextMeshProUGUI timerText;\n\n private float timeRemaining;\n private bool isRunning = false;\n\n public void StartTimer()\n {\n timeRemaining = duration;\n isRunning = true;\n }\n\n public void StopTimer()\n {\n isRunning = false;\n }\n\n void Update()\n {\n if (!isRunning) return;\n\n timeRemaining -= Time.deltaTime;\n\n if (timeRemaining \u003c= 0)\n {\n timeRemaining = 0;\n isRunning = false;\n OnTimerComplete();\n }\n\n UpdateDisplay();\n }\n\n private void UpdateDisplay()\n {\n int minutes = Mathf.FloorToInt(timeRemaining / 60);\n int seconds = Mathf.FloorToInt(timeRemaining % 60);\n timerText.text = $\"{minutes:00}:{seconds:00}\";\n }\n\n private void OnTimerComplete()\n {\n Debug.Log(\"Timer finished!\");\n }\n}\n```\n\n### Delayed Action (Without Coroutines)\n\n```csharp\npublic class DelayedAction : UdonSharpBehaviour\n{\n public void DoAfterDelay(float seconds)\n {\n SendCustomEventDelayedSeconds(nameof(ExecuteDelayedAction), seconds);\n }\n\n public void ExecuteDelayedAction()\n {\n Debug.Log(\"Delayed action executed!\");\n }\n\n // Cancel by disabling the component\n public void CancelDelayed()\n {\n // Note: There's no direct way to cancel SendCustomEventDelayedSeconds\n // Use a flag instead\n }\n}\n```\n\n### Repeating Action\n\n```csharp\npublic class RepeatingAction : UdonSharpBehaviour\n{\n public float interval = 1.0f;\n private bool isRepeating = false;\n\n public void StartRepeating()\n {\n isRepeating = true;\n DoRepeat();\n }\n\n public void StopRepeating()\n {\n isRepeating = false;\n }\n\n public void DoRepeat()\n {\n if (!isRepeating) return;\n\n // Your repeating action here\n Debug.Log(\"Tick!\");\n\n // Schedule next iteration\n SendCustomEventDelayedSeconds(nameof(DoRepeat), interval);\n }\n}\n```\n\n## Audio Patterns\n\n### Simple Audio Player\n\n```csharp\npublic class AudioPlayer : UdonSharpBehaviour\n{\n public AudioSource audioSource;\n public AudioClip[] clips;\n\n public void PlayClip(int index)\n {\n if (index >= 0 && index \u003c clips.Length)\n {\n audioSource.clip = clips[index];\n audioSource.Play();\n }\n }\n\n public void PlayRandom()\n {\n if (clips.Length > 0)\n {\n int randomIndex = Random.Range(0, clips.Length);\n PlayClip(randomIndex);\n }\n }\n\n public void Stop()\n {\n audioSource.Stop();\n }\n}\n```\n\n### Synced Music Player\n\n```csharp\npublic class SyncedMusicPlayer : UdonSharpBehaviour\n{\n public AudioSource audioSource;\n public AudioClip[] tracks;\n\n [UdonSynced, FieldChangeCallback(nameof(CurrentTrack))]\n private int _currentTrack = -1;\n\n [UdonSynced, FieldChangeCallback(nameof(IsPlaying))]\n private bool _isPlaying = false;\n\n public int CurrentTrack\n {\n get => _currentTrack;\n set\n {\n _currentTrack = value;\n if (_currentTrack >= 0 && _currentTrack \u003c tracks.Length)\n {\n audioSource.clip = tracks[_currentTrack];\n if (_isPlaying) audioSource.Play();\n }\n }\n }\n\n public bool IsPlaying\n {\n get => _isPlaying;\n set\n {\n _isPlaying = value;\n if (_isPlaying) audioSource.Play();\n else audioSource.Stop();\n }\n }\n\n public void PlayTrack(int index)\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n CurrentTrack = index;\n IsPlaying = true;\n RequestSerialization();\n }\n\n public void TogglePlay()\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n IsPlaying = !IsPlaying;\n RequestSerialization();\n }\n}\n```\n\n## Pickup Patterns\n\n### Basic Pickup with Events\n\n```csharp\npublic class CustomPickup : UdonSharpBehaviour\n{\n public override void OnPickup()\n {\n Debug.Log(\"Picked up!\");\n }\n\n public override void OnDrop()\n {\n Debug.Log(\"Dropped!\");\n }\n\n public override void OnPickupUseDown()\n {\n Debug.Log(\"Use button pressed!\");\n DoPickupAction();\n }\n\n public override void OnPickupUseUp()\n {\n Debug.Log(\"Use button released!\");\n }\n\n private void DoPickupAction()\n {\n // Action when use button is pressed while holding\n }\n}\n```\n\n### Throwable Object\n\n```csharp\npublic class Throwable : UdonSharpBehaviour\n{\n public float throwForce = 10f;\n private Rigidbody rb;\n private VRC_Pickup pickup;\n\n void Start()\n {\n rb = GetComponent\u003cRigidbody>();\n pickup = (VRC_Pickup)GetComponent(typeof(VRC_Pickup));\n }\n\n public override void OnDrop()\n {\n // Apply throw force based on hand velocity\n VRCPlayerApi player = Networking.LocalPlayer;\n if (player != null)\n {\n Vector3 velocity = player.GetVelocity();\n rb.velocity = velocity * throwForce;\n }\n }\n}\n```\n\n## Animation Patterns\n\n### Simple Animator Control\n\n```csharp\npublic class AnimatorController : UdonSharpBehaviour\n{\n public Animator animator;\n\n public void PlayAnimation(string triggerName)\n {\n animator.SetTrigger(triggerName);\n }\n\n public void SetBool(string paramName, bool value)\n {\n animator.SetBool(paramName, value);\n }\n\n public void SetFloat(string paramName, float value)\n {\n animator.SetFloat(paramName, value);\n }\n}\n```\n\n### Synced Animation State\n\n```csharp\npublic class SyncedAnimator : UdonSharpBehaviour\n{\n public Animator animator;\n public string boolParameter = \"IsActive\";\n\n [UdonSynced, FieldChangeCallback(nameof(AnimState))]\n private bool _animState = false;\n\n public bool AnimState\n {\n get => _animState;\n set\n {\n _animState = value;\n animator.SetBool(boolParameter, value);\n }\n }\n\n public override void Interact()\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n AnimState = !AnimState;\n RequestSerialization();\n }\n}\n```\n\n## UI Patterns\n\n### Button Array Handler\n\n```csharp\npublic class ButtonHandler : UdonSharpBehaviour\n{\n public int buttonIndex;\n public UdonSharpBehaviour targetScript;\n public string methodName;\n\n public void OnClick()\n {\n // Store index for target to read\n targetScript.SetProgramVariable(\"selectedIndex\", buttonIndex);\n targetScript.SendCustomEvent(methodName);\n }\n}\n```\n\n### Slider Value Display\n\n```csharp\nusing UnityEngine.UI;\n\npublic class SliderDisplay : UdonSharpBehaviour\n{\n public Slider slider;\n public TextMeshProUGUI valueText;\n public string format = \"{0:F1}\";\n\n public void OnSliderChanged()\n {\n valueText.text = string.Format(format, slider.value);\n }\n}\n```\n\n## Teleportation\n\n### Simple Teleporter\n\n```csharp\npublic class Teleporter : UdonSharpBehaviour\n{\n public Transform destination;\n\n public override void Interact()\n {\n VRCPlayerApi player = Networking.LocalPlayer;\n if (player != null)\n {\n player.TeleportTo(\n destination.position,\n destination.rotation\n );\n }\n }\n}\n```\n\n### Multi-Destination Teleporter\n\n```csharp\npublic class MultiTeleporter : UdonSharpBehaviour\n{\n public Transform[] destinations;\n private int currentIndex = 0;\n\n public override void Interact()\n {\n if (destinations.Length == 0) return;\n\n VRCPlayerApi player = Networking.LocalPlayer;\n if (player != null)\n {\n player.TeleportTo(\n destinations[currentIndex].position,\n destinations[currentIndex].rotation\n );\n currentIndex = (currentIndex + 1) % destinations.Length;\n }\n }\n}\n```\n\n## Lazy Initialization Guard\n\n### Problem\n\n`Start()` execution order between UdonBehaviours placed in a scene is not guaranteed. An external script may call a public method before the target behaviour's `Start()` has run, resulting in null-reference errors.\n\n### Solution\n\nUse a private `_initialized` flag and an explicit `Initialize()` method. Call `Initialize()` from both `Start()` and every public API entry point.\n\n```csharp\npublic class ScoreBoard : UdonSharpBehaviour\n{\n [SerializeField] private TextMeshProUGUI scoreText;\n\n private bool _initialized = false;\n private int _score = 0;\n\n void Start()\n {\n Initialize();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n // One-time setup that requires Unity component access\n if (scoreText == null)\n {\n scoreText = GetComponentInChildren\u003cTextMeshProUGUI>();\n }\n RefreshDisplay();\n }\n\n /// \u003csummary>\n /// May be called by other behaviours before this object's Start() runs.\n /// The Initialize() guard ensures safety.\n /// \u003c/summary>\n public void AddScore(int points)\n {\n Initialize(); // Guard: idempotent, safe to call repeatedly\n _score += points;\n RefreshDisplay();\n }\n\n public void ResetScore()\n {\n Initialize();\n _score = 0;\n RefreshDisplay();\n }\n\n private void RefreshDisplay()\n {\n if (scoreText != null)\n {\n scoreText.text = _score.ToString();\n }\n }\n}\n```\n\n**Key rules:**\n- `Initialize()` must be idempotent (guarded by `_initialized`).\n- Every `public` method that touches component references should call `Initialize()` first.\n- Do not reset `_initialized` to `false` unless you also repeat all setup work (use a dedicated `Reset()` method that sets `_initialized = false` and then calls `Initialize()`).\n\n---\n\n## VRC+ Detection — Reading `isVRCPlus` (SDK 3.10.3+)\n\nThis pattern reads `VRCPlayerApi.isVRCPlus` on the local player and conditionally enables a `GameObject`. Substitute any behaviour you want to condition on subscription status — the code shape is the same regardless of what the target object does. Whether and how to use `isVRCPlus` is a design decision left to the caller.\n\nTwo technical constraints shape the code: `isVRCPlus` must be read after `OnPlayerRestored` (see `api.md` for the timing rationale), and the value must never be `[UdonSynced]` (each client evaluates `player.isVRCPlus` locally, so a synced value would misreport state for every other client).\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\npublic class LocalVRCPlusBadge : UdonSharpBehaviour\n{\n [SerializeField] private GameObject plusBadge;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (player == null || !player.isLocal) return;\n plusBadge.SetActive(player.isVRCPlus);\n }\n}\n```\n\nNotes:\n- The read is on the **local** player against the local `VRCPlayerApi`. Applying this to remote players works the same way — iterate `VRCPlayerApi.GetPlayers()` on each client and evaluate each player's `isVRCPlus` locally; never sync the result.\n- Do not skip the `OnPlayerRestored` gate. Reading in `OnPlayerJoined` may return an unset / default value while the profile is still being fetched.\n- If you need to react to a subscription change mid-session, re-read inside whatever update hook you already have; still no `[UdonSynced]`.\n\n---\n\n\n## See Also\n\n- [dynamics.md](dynamics.md) - PhysBones, Contacts, and VRC Constraints for physics-based interactions\n- [patterns-networking.md](patterns-networking.md) - Object pooling, game state, NetworkCallable, persistence, dynamics\n- [patterns-performance.md](patterns-performance.md) - Partial class, update handler, PostLateUpdate, spatial query\n- [patterns-ui.md](patterns-ui.md) - UI/Canvas patterns, immobilize guard, localization, settings persistence\n- [patterns-utilities.md](patterns-utilities.md) - Array helpers, event bus, relay communication\n- [networking.md](networking.md) - Ownership model, sync modes, and RequestSerialization details\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22268,"content_sha256":"47e75380f05b26dd9db73467aac8d4fa6ab7d6ebfd0d7b5b83d7e9bb6340a319"},{"filename":"references/patterns-networking.md","content":"# UdonSharp Networking Patterns\n\nObject pooling, synced game state management, NetworkCallable patterns, persistence, dynamics interactions, and delayed event debouncing.\n\n## Object Pooling\n\n### Simple Object Pooling\n\n```csharp\npublic class SimplePool : UdonSharpBehaviour\n{\n public GameObject prefab;\n public int poolSize = 10;\n public Transform poolParent;\n\n private GameObject[] pool;\n private int nextIndex = 0;\n\n void Start()\n {\n pool = new GameObject[poolSize];\n for (int i = 0; i \u003c poolSize; i++)\n {\n pool[i] = VRCInstantiate(prefab);\n pool[i].transform.SetParent(poolParent);\n pool[i].SetActive(false);\n }\n }\n\n public GameObject Get()\n {\n GameObject obj = pool[nextIndex];\n obj.SetActive(true);\n nextIndex = (nextIndex + 1) % poolSize;\n return obj;\n }\n\n public void Return(GameObject obj)\n {\n obj.SetActive(false);\n }\n}\n```\n\n## Master-Managed Player Object Pool\n\nA networked pattern that assigns a unique pool object to each player present in the world.\nThe instance master owns all assignment logic; other clients react to synced state via `OnDeserialization`.\n\n**When to use this pattern:**\n- Each player needs a dedicated, persistent object (nameplate, avatar attachment, scoreboard slot, etc.)\n- Pool size is fixed and known at design time (set `_poolObjects` in the Inspector)\n- Assignment authority must be centralised to avoid conflicts\n\n### Architecture\n\n| Member | Kind | Purpose |\n|---|---|---|\n| `_assignments` | `[UdonSynced] int[]` | Maps pool index → VRC player ID (0 = unassigned) |\n| `_poolObjects` | `UdonSharpBehaviour[]` | Inspector-assigned pool object references |\n| `_freeQueue` | `int[]` (local) | FIFO ring buffer of free slot indices (master only) |\n| `_freeHead/Tail` | `int` (local) | Ring-buffer pointers for O(1) enqueue / dequeue |\n| `_previousAssignments` | `int[]` (local) | Snapshot used in `OnDeserialization` for change detection |\n\n**Template:** [assets/templates/MasterManagedPlayerPool.cs](../assets/templates/MasterManagedPlayerPool.cs)\n\nThe implementation uses `Manual` sync mode. On `Start`, it allocates `_assignments[]` (synced) and the local `_freeQueue` ring buffer. Only the master initialises the free queue. `OnPlayerJoined`/`OnPlayerLeft` (master only) dequeue/enqueue slots and call `RequestSerialization`. `OnDeserialization` diffs against `_previousAssignments` and calls `_ActivateSlot`/`_DeactivateSlot` only for changed entries. `OnMasterClientSwitched` rebuilds the free queue and schedules a deferred `VerifyAssignments` call to close the race-condition window.\n\n\n### Key Design Decisions\n\n**Why master-only assignment?**\nCentralising writes to the master eliminates the need for distributed conflict resolution. Only one client ever calls `RequestSerialization`, so the synced array is always consistent.\n\n**Why a FIFO queue instead of a linear scan?**\n`OnPlayerJoined` runs on every join event. A ring-buffer dequeue is O(1) regardless of pool size, keeping join latency predictable.\n\n**`_previousAssignments` change detection**\n`OnDeserialization` fires whenever *any* synced variable changes. Diffing against the previous snapshot means only genuinely modified slots trigger `_ActivateSlot` / `_DeactivateSlot`, avoiding redundant work.\n\n**Late-joiner initialisation**\nWhen a late joiner receives their first `OnDeserialization`, `_previousAssignments` is all-zeros, so every occupied slot in `_assignments` is detected as a new assignment and the corresponding pool objects are activated automatically.\n\n**Master handoff race condition**\nThere is a brief window between the old master leaving and the new master being elected where join/leave events may be dropped. The 2-second deferred `VerifyAssignments` call reconciles the assignment table against the live player list to close this gap.\n\n### Usage Notes\n\n- Set `_poolObjects` in the Inspector before entering Play mode. Pool size equals `_poolObjects.Length`.\n- Pool objects should handle their own visual/audio state inside `SetActive`. The manager only toggles `gameObject.SetActive`.\n- If your pool objects need the assigned player at enable time, store the player reference via `SetProgramVariable(\"assignedPlayer\", player)` before calling `SetActive(true)`, as shown in `_ActivateSlot`.\n- This pattern does not support runtime pool growth. Size the pool to the world's maximum player count.\n\n### Array Helpers\n\n```csharp\npublic class ArrayHelpers : UdonSharpBehaviour\n{\n // Find index in array\n public int FindIndex(GameObject[] array, GameObject target)\n {\n for (int i = 0; i \u003c array.Length; i++)\n {\n if (array[i] == target) return i;\n }\n return -1;\n }\n\n // Shuffle array (Fisher-Yates)\n public void ShuffleArray(int[] array)\n {\n for (int i = array.Length - 1; i > 0; i--)\n {\n int j = Random.Range(0, i + 1);\n int temp = array[i];\n array[i] = array[j];\n array[j] = temp;\n }\n }\n\n // Resize array (create new)\n public GameObject[] ResizeArray(GameObject[] original, int newSize)\n {\n GameObject[] newArray = new GameObject[newSize];\n int copyLength = Mathf.Min(original.Length, newSize);\n System.Array.Copy(original, newArray, copyLength);\n return newArray;\n }\n}\n```\n\n## NetworkCallable Patterns (SDK 3.8.1+)\n\n### Basic Parameterized RPC\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class NetworkCallableBasic : UdonSharpBehaviour\n{\n public TextMeshProUGUI messageText;\n\n [NetworkCallable]\n public void ShowMessage(string message, int senderId)\n {\n VRCPlayerApi sender = VRCPlayerApi.GetPlayerById(senderId);\n string senderName = sender != null ? sender.displayName : \"Unknown\";\n messageText.text = $\"{senderName}: {message}\";\n }\n\n public void BroadcastMessage(string message)\n {\n SendCustomNetworkEvent(\n NetworkEventTarget.All,\n nameof(ShowMessage),\n message,\n Networking.LocalPlayer.playerId\n );\n }\n}\n```\n\n### Damage System with NetworkCallable\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class DamageReceiver : UdonSharpBehaviour\n{\n [UdonSynced] private int health = 100;\n public TextMeshProUGUI healthText;\n\n [NetworkCallable]\n public void TakeDamage(int damage, Vector3 hitPosition, int attackerId)\n {\n // Only owner processes damage\n if (!Networking.IsOwner(gameObject))\n {\n // Forward to owner\n SendCustomNetworkEvent(\n NetworkEventTarget.Owner,\n nameof(TakeDamage),\n damage, hitPosition, attackerId\n );\n return;\n }\n\n health -= damage;\n RequestSerialization();\n\n // Notify all players of hit effect\n SendCustomNetworkEvent(\n NetworkEventTarget.All,\n nameof(ShowHitEffect),\n hitPosition\n );\n\n if (health \u003c= 0)\n {\n SendCustomNetworkEvent(\n NetworkEventTarget.All,\n nameof(OnDeath)\n );\n }\n }\n\n [NetworkCallable]\n public void ShowHitEffect(Vector3 position)\n {\n // Spawn particle at hit position\n SpawnHitParticle(position);\n }\n\n [NetworkCallable]\n public void OnDeath()\n {\n // Play death animation/sound\n Debug.Log(\"Target destroyed!\");\n }\n\n public override void OnDeserialization()\n {\n healthText.text = $\"HP: {health}\";\n }\n}\n```\n\n### Chat System\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.None)]\npublic class ChatSystem : UdonSharpBehaviour\n{\n public TextMeshProUGUI chatLog;\n public UnityEngine.UI.InputField inputField;\n\n private string[] messages = new string[50];\n private int messageIndex = 0;\n\n [NetworkCallable(10)] // Allow 10 messages/sec\n public void ReceiveMessage(string message, string senderName)\n {\n messages[messageIndex] = $\"[{senderName}] {message}\";\n messageIndex = (messageIndex + 1) % messages.Length;\n UpdateChatDisplay();\n }\n\n public void SendMessage()\n {\n string msg = inputField.text;\n if (string.IsNullOrEmpty(msg)) return;\n\n inputField.text = \"\";\n\n SendCustomNetworkEvent(\n NetworkEventTarget.All,\n nameof(ReceiveMessage),\n msg,\n Networking.LocalPlayer.displayName\n );\n }\n\n private void UpdateChatDisplay()\n {\n System.Text.StringBuilder sb = new System.Text.StringBuilder();\n for (int i = 0; i \u003c messages.Length; i++)\n {\n if (!string.IsNullOrEmpty(messages[i]))\n {\n sb.AppendLine(messages[i]);\n }\n }\n chatLog.text = sb.ToString();\n }\n}\n```\n\n## Persistence Patterns (SDK 3.7.4+)\n\n### Settings Manager\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Persistence;\n\npublic class SettingsManager : UdonSharpBehaviour\n{\n [Header(\"UI References\")]\n public UnityEngine.UI.Slider volumeSlider;\n public UnityEngine.UI.Toggle musicToggle;\n public UnityEngine.UI.Dropdown qualityDropdown;\n\n private bool initialized = false;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n // Load all settings\n if (PlayerData.TryGetFloat(player, \"volume\", out float vol))\n volumeSlider.value = vol;\n\n if (PlayerData.TryGetBool(player, \"musicEnabled\", out bool music))\n musicToggle.isOn = music;\n\n if (PlayerData.TryGetInt(player, \"quality\", out int quality))\n qualityDropdown.value = quality;\n\n initialized = true;\n }\n\n public void OnVolumeChanged()\n {\n if (!initialized) return;\n PlayerData.SetFloat(Networking.LocalPlayer, \"volume\", volumeSlider.value);\n ApplyVolume(volumeSlider.value);\n }\n\n public void OnMusicToggled()\n {\n if (!initialized) return;\n PlayerData.SetBool(Networking.LocalPlayer, \"musicEnabled\", musicToggle.isOn);\n ApplyMusic(musicToggle.isOn);\n }\n\n public void OnQualityChanged()\n {\n if (!initialized) return;\n PlayerData.SetInt(Networking.LocalPlayer, \"quality\", qualityDropdown.value);\n ApplyQuality(qualityDropdown.value);\n }\n}\n```\n\n### Unlock System\n\n```csharp\npublic class UnlockSystem : UdonSharpBehaviour\n{\n [Header(\"Unlock Objects\")]\n public GameObject[] unlockableObjects;\n public string[] unlockKeys;\n\n private bool dataReady = false;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n dataReady = true;\n\n // Check all unlocks\n for (int i = 0; i \u003c unlockableObjects.Length; i++)\n {\n if (PlayerData.TryGetBool(player, unlockKeys[i], out bool unlocked))\n {\n unlockableObjects[i].SetActive(unlocked);\n }\n }\n }\n\n public void Unlock(int index)\n {\n if (!dataReady) return;\n if (index \u003c 0 || index >= unlockKeys.Length) return;\n\n PlayerData.SetBool(Networking.LocalPlayer, unlockKeys[index], true);\n unlockableObjects[index].SetActive(true);\n\n Debug.Log($\"Unlocked: {unlockKeys[index]}\");\n }\n\n public void ResetAllUnlocks()\n {\n if (!dataReady) return;\n\n for (int i = 0; i \u003c unlockKeys.Length; i++)\n {\n PlayerData.DeleteKey(Networking.LocalPlayer, unlockKeys[i]);\n unlockableObjects[i].SetActive(false);\n }\n }\n}\n```\n\n## Dynamics Patterns (SDK 3.10.0+)\n\n### Interactive Button\n\n```csharp\npublic class ContactButton : UdonSharpBehaviour\n{\n [Header(\"Visual Feedback\")]\n public Transform buttonTop;\n public Material normalMaterial;\n public Material pressedMaterial;\n public Renderer buttonRenderer;\n\n [Header(\"Audio\")]\n public AudioSource pressSound;\n public AudioSource releaseSound;\n\n [Header(\"Settings\")]\n public float pressDepth = 0.02f;\n public float pressSpeed = 10f;\n public float cooldown = 0.5f;\n\n private bool isPressed = false;\n private float lastPressTime;\n private Vector3 originalPos;\n private Vector3 pressedPos;\n\n void Start()\n {\n originalPos = buttonTop.localPosition;\n pressedPos = originalPos - new Vector3(0, pressDepth, 0);\n }\n\n void Update()\n {\n Vector3 target = isPressed ? pressedPos : originalPos;\n buttonTop.localPosition = Vector3.Lerp(\n buttonTop.localPosition, target, Time.deltaTime * pressSpeed);\n }\n\n public override void OnContactEnter(ContactEnterInfo info)\n {\n if (isPressed) return;\n if (Time.time - lastPressTime \u003c cooldown) return;\n\n isPressed = true;\n lastPressTime = Time.time;\n buttonRenderer.material = pressedMaterial;\n pressSound.Play();\n\n OnButtonPressed(info);\n }\n\n public override void OnContactExit(ContactExitInfo info)\n {\n isPressed = false;\n buttonRenderer.material = normalMaterial;\n releaseSound.Play();\n }\n\n private void OnButtonPressed(ContactEnterInfo info)\n {\n if (info.isAvatar && info.player != null)\n {\n Debug.Log($\"Button pressed by: {info.player.displayName}\");\n }\n // Add your button action here\n }\n}\n```\n\n### Touch Piano\n\n```csharp\npublic class TouchPiano : UdonSharpBehaviour\n{\n public AudioSource[] noteAudioSources;\n public int noteIndex;\n\n private bool isPlaying = false;\n\n public override void OnContactEnter(ContactEnterInfo info)\n {\n if (isPlaying) return;\n isPlaying = true;\n\n if (noteIndex >= 0 && noteIndex \u003c noteAudioSources.Length)\n {\n noteAudioSources[noteIndex].Play();\n }\n }\n\n public override void OnContactExit(ContactExitInfo info)\n {\n isPlaying = false;\n\n if (noteIndex >= 0 && noteIndex \u003c noteAudioSources.Length)\n {\n noteAudioSources[noteIndex].Stop();\n }\n }\n}\n```\n\n### Grabbable Rope (Physics)\n\n```csharp\npublic class GrabbableRope : UdonSharpBehaviour\n{\n [Header(\"Sync\")]\n [UdonSynced] private bool isGrabbed = false;\n [UdonSynced] private int grabberId = -1;\n\n [Header(\"Audio\")]\n public AudioSource grabSound;\n public AudioSource releaseSound;\n\n public override void OnPhysBoneGrab(PhysBoneGrabInfo info)\n {\n Networking.SetOwner(info.player, gameObject);\n isGrabbed = true;\n grabberId = info.player.playerId;\n RequestSerialization();\n\n grabSound.Play();\n }\n\n public override void OnPhysBoneRelease(PhysBoneReleaseInfo info)\n {\n isGrabbed = false;\n grabberId = -1;\n RequestSerialization();\n\n releaseSound.Play();\n }\n\n public bool IsGrabbed() => isGrabbed;\n\n public VRCPlayerApi GetGrabber()\n {\n if (grabberId \u003c 0) return null;\n return VRCPlayerApi.GetPlayerById(grabberId);\n }\n}\n```\n\n## Synced Game State Management\n\n### History/Undo Sync Pattern\n\nWhen implementing undo functionality in a game, **history is shared among all players as synced variables**.\nThe initial state is saved as history entry 0, and resetting returns to history 0 (no separate variable for initial state).\n\n**Important notes:**\n- **1 logical operation = 1 history save** (do not save twice on both sender and receiver)\n- Save the state **after** the operation, not before\n- History saving is done only within the owner's operation processing method\n\n**Template:** [assets/templates/UndoableGameManager.cs](../assets/templates/UndoableGameManager.cs)\n\nSyncs `currentState` (byte[]), `stateHistory` (flat byte[] of N×stateSize), and `historyCount` as `[UdonSynced]` variables. `OwnerProcessMove` (owner-only `[NetworkCallable]`) applies the move then calls `SaveStateToHistory`. `OwnerUndo` decrements `historyCount` and restores the previous snapshot. `OwnerReset` resets to `stateHistory[0]`. `OnDeserialization` only calls `UpdateDisplay` — never saves to history.\n\n**Common mistakes:**\n\n| Mistake | Problem | Correct approach |\n|--------|------|-----------|\n| Saving history in OnDeserialization | Double-saving on sender + receiver | Save only in owner's operation method |\n| Managing initial state in a separate variable | Inconsistency on reset | history[0] = initial state |\n| Saving state before the operation | Undo goes back 2 steps instead of 1 | Save state after the operation |\n| Not making history synced | Undo results differ between players | Share history as synced variables |\n\n## Distant-Room Pseudo-Multi-Room Pattern\n\nReuse a single local room model to render the illusion of multiple rooms by separating **synced room-assignment state** from **local presentation placement**, and teleporting same-`roomIndex` players to a shared distant origin on each client.\n\n**When to use this pattern:**\n- Multiple rooms with identical or near-identical interiors (escape rooms, hub-and-spoke lounges, voice-isolated breakout rooms)\n- Authoring one Unity scene cost is acceptable, but authoring N parallel copies is not\n- Some level of voice isolation between rooms is desired (a side effect of physical separation)\n- Players in the same room must visibly share the same space; players in different rooms must not collide\n\nRequires SDK >= 3.7.4 for the recommended `VRCPlayerObject` tier. The other tiers (fixed-size synced array, local-only) work on older SDKs.\n\n### Architecture (state vs presentation split)\n\nTwo responsibilities, each on a separate UdonBehaviour with a different sync mode:\n\n| Layer | Sync mode | What it holds | Where it lives |\n|---|---|---|---|\n| **State** — `RoomAssignment` | `[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]` | `[UdonSynced] int roomIndex` | On a `VRCPlayerObject` prefab — auto-spawned per player, auto-owned by that player |\n| **Presentation** — `LocalRoomPresenter` | `[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]` | `Transform roomRoot`, `Transform[] roomOrigins` | One scene-level instance, per-client local view only |\n\nEach client computes \"where is **my** room?\" from the local player's `RoomAssignment.roomIndex`, then locally moves `roomRoot` to `roomOrigins[roomIndex]` and `TeleportTo`s the local player to that same origin. **Two players who hold the same `roomIndex` independently run this on their own clients and therefore collocate at the same world coordinate** — they appear together because they were each sent there, not because the room object itself was synced.\n\n### Why this works\n\n`VRCPlayerApi.TeleportTo` only affects the local player; calling it on a remote player is a no-op (ClientSim emits *\"Teleporting remote players will do nothing\"*). Each client teleporting **its own** `Networking.LocalPlayer` to the world coordinate returned by `roomOrigins[myRoomIndex]` is what produces the shared-room illusion. The room `GameObject` itself never participates in network sync — only the integer room assignment does. Voice attenuation, avatar proximity audio, and pickup proximity all follow the new world position automatically because they operate on post-teleport world coordinates, not on `roomIndex`.\n\n### Cost Tier 1: How is `roomIndex` synced?\n\n| Choice | When to use | Trade-off |\n|---|---|---|\n| **`VRCPlayerObject` + per-player `RoomAssignment`** (recommended) | Open-world worlds, frequent joins/leaves, rooms come and go organically | Each player auto-owns their `RoomAssignment` — no master, no manager, no slot allocator. Late joiners receive existing assignments through PlayerObject's standard restoration. |\n| **Fixed-size synced array on a manager** (`int[] roomIndexBySlot`) | Small lobby worlds with a hard player cap, room capacity limits, master-driven assignment | See [Master-Managed Player Object Pool](#master-managed-player-object-pool) — same shape, with `roomIndex` instead of `poolIndex`. Reuses its master-handoff and slot-recycling logic. |\n| **Local-only (no sync)** | Single-player preview / debug only | Other players cannot tell which room you are in; late joiners cannot see existing assignments. Not viable as a main route for multi-player play. |\n\n### Cost Tier 2: Who can write `roomIndex`?\n\n| Choice | When to use | Implementation |\n|---|---|---|\n| **Self-owned** (recommended starting point) | No capacity limits, lottery is acceptable, players simply choose or randomise their own room | Each player writes only their own `RoomAssignment.roomIndex` under an `IsOwner` guard, then calls `RequestSerialization`. Interact buttons or a local random pick drive the write. |\n| **Master-approved** | Capacity caps, fair lottery across all players, reservation systems, banlist-style exclusion | Player sends a request via `SendCustomNetworkEvent(NetworkEventTarget.Owner, ...)` to a Master-owned manager. The manager validates against the synced occupancy table, then writes the assignment (or rejects). See [Master-Managed Player Object Pool](#master-managed-player-object-pool) for the master-handoff race-condition mitigation. |\n\nThe self-owned tier avoids the master-handoff race entirely. Escalate to master-approved only when cross-player validation is actually required.\n\n### Key Design Decisions\n\n**Why `NoVariableSync` on the presenter?**\nThe presenter holds no shared state — only local view placement derived from the synced `roomIndex`. `NoVariableSync` makes the design intent explicit: *this object's fields must never participate in network sync, even by accident*. Editor warnings will flag attempts to add `[UdonSynced]` later.\n\n**Why `Manual` sync mode on the assignment script?**\nRoom assignment changes are discrete user actions (Interact, button press, lottery roll), not continuous values. `Manual` + `RequestSerialization()` after each write minimises bandwidth and avoids per-frame churn. Discrete user-action state maps to Manual sync per the [Sync Mode Quick Decision](../SKILL.md#sync-mode-quick-decision) in SKILL.md.\n\n**Why `VRCPlayerObject` rather than a master-managed slot table by default?**\nPlayerObject infrastructure already solves ownership-per-player, late-joiner restoration, and lifecycle cleanup on player leave. There is no need to reinvent slot allocation, and `Networking.SetOwner` is not required because VRChat auto-assigns ownership of each instance to its player (see [persistence.md](persistence.md#playerobject)). `VRCEnablePersistence` is optional — without it the prefab still instantiates per-player but `roomIndex` resets when the player rejoins, which is appropriate for volatile room state.\n\n**Replication-lag window for self-owned assignment.**\nWhen Player A switches rooms locally, their own client moves them immediately. A's avatar position then propagates to remote clients through the normal avatar transform channel — this is a separate, much faster sync than `[UdonSynced]` Manual sync — so voice attenuation and pickup proximity react to the new location within ~100 ms. What does lag is the *application-level* `roomIndex` value: B's client only sees A's new `roomIndex` when A's `RequestSerialization` arrives via `OnDeserialization`, typically sub-second under Manual sync. During that window any room-affiliation UI (room labels, occupant lists, room-scoped event routing) on B's client still reflects A's previous room. Do not build gameplay that requires *all* clients to agree on A's `roomIndex` value within the same frame; the physical-presence aspects of the move are already correct.\n\n### Caveats\n\nThe pattern silently breaks if any of these are violated:\n\n| Issue | Why it breaks | What to do |\n|---|---|---|\n| **`VRCObjectSync` on anything under `roomRoot`** | `VRCObjectSync` broadcasts world-space transforms. Since each client's `roomRoot` is at a different world position, a `VRCObjectSync` child appears at the owner's world coordinate on every client — inside the owner's room only, and in the empty void on everyone else | Keep all `VRCObjectSync` objects outside `roomRoot`, or replicate per-room without `VRCObjectSync` |\n| **`roomOrigins` at inconsistent offsets across clients** | If a client's `roomOrigins[1]` differs from another client's `roomOrigins[1]`, same-`roomIndex` players land on different coordinates and stop seeing each other | `roomOrigins` must be Inspector-set Transforms baked into the scene. Never compute them at runtime — even seeded RNG is unsafe because Udon does not guarantee identical `UnityEngine.Random` sequences across clients, and `Time.time`-derived math is per-client by definition |\n| **Cameras not anchored to the local player's room** — Drone (`VRCDroneApi`), Stream Camera, scene-fixed render textures aimed at remote-room coordinates | Players are physically at their teleported coordinates, but only the local client's `roomRoot` is positioned at the matching origin. A camera that pans toward another room's coordinates renders those players \"in the void\" — no walls, no room interior | Place visual occlusion at each `roomOrigin` (opaque box, light-fog volume, view-limiting geometry) so off-room cameras cannot reveal floating avatars |\n| **Distant offsets that approach Unity's float-precision band** | Beyond roughly +/-5000 units, position jitter and physics drift become observable; beyond +/-100000, floats lose sub-meter precision | Keep `roomOrigins` within a few thousand units of the world origin. For very large room counts, prefer rotation around a central pivot over linear offset |\n\n### Code Sketch (self-owned + `VRCPlayerObject` tier)\n\nThe two scripts together. UI plumbing — how an Interact button on the local client finds and calls into the local player's `RoomAssignment` instance — follows the standard PlayerObject-child to scene-controller registration idiom and is out of scope here.\n\n```csharp\n// On a VRCPlayerObject prefab.\n// VRChat auto-spawns one per player and auto-assigns ownership to that player —\n// Networking.SetOwner is not required for PlayerObject behaviours.\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class RoomAssignment : UdonSharpBehaviour\n{\n // By convention, roomIndex = 0 is the lobby / default room.\n // If you need an explicit \"unassigned\" state, use -1 as a sentinel and\n // check for it in LocalRoomPresenter.ApplyLocalRoom().\n [UdonSynced] public int roomIndex = 0;\n\n [SerializeField] private LocalRoomPresenter presenter;\n\n // Called by your UI on the local client only.\n public void SetRoom(int newIndex)\n {\n if (!Networking.IsOwner(gameObject)) return; // Self-owned; guard anyway per Rule 12.\n roomIndex = newIndex;\n RequestSerialization();\n\n // Local apply happens immediately; OnDeserialization does not fire for the\n // writer's own client, so the local view will not update from sync alone.\n presenter.ApplyLocalRoom(newIndex);\n }\n\n // Restoration entry point — fires once per player when their persistent\n // data (under VRCEnablePersistence) has been loaded. Without persistence,\n // this still fires but roomIndex is at its default value. Either way, apply\n // the local view for the local owner so a rejoining player is placed back\n // in their saved room rather than the scene default.\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!Networking.IsOwner(player, gameObject)) return; // Only this player's instance.\n if (!player.isLocal) return; // Only apply on the local client's view.\n\n presenter.ApplyLocalRoom(roomIndex);\n }\n}\n```\n\n```csharp\n// One instance in the scene. Holds the room model and the origin transforms.\n// NoVariableSync makes it explicit: this object's state is per-client local.\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class LocalRoomPresenter : UdonSharpBehaviour\n{\n [SerializeField] private Transform roomRoot;\n [SerializeField] private Transform[] roomOrigins;\n\n public void ApplyLocalRoom(int roomIndex)\n {\n if (roomIndex \u003c 0 || roomIndex >= roomOrigins.Length) return;\n\n Transform origin = roomOrigins[roomIndex];\n if (origin == null) return; // Defensive null check (Inspector slot may be empty).\n\n // Local-only move of the room model. roomRoot has no VRCObjectSync.\n roomRoot.SetPositionAndRotation(origin.position, origin.rotation);\n\n // Per-client local teleport. Each player's client teleports its own\n // LocalPlayer to the same origin; same-roomIndex players collocate.\n Networking.LocalPlayer.TeleportTo(origin.position, origin.rotation);\n }\n}\n```\n\nThe wiring for capacity-limited or master-approved variants follows the [Master-Managed Player Object Pool](#master-managed-player-object-pool) pattern above — keep its `_assignments[]` synced array of player IDs and add a parallel `[UdonSynced] int[] _roomIndexBySlot` indexed by the same slot id, then route writes through a master-owned manager via `SendCustomNetworkEvent(NetworkEventTarget.Owner, ...)`.\n\n### See Also\n\n- [Master-Managed Player Object Pool](#master-managed-player-object-pool) — slot allocation pattern, reusable for master-approved room assignment\n- [persistence.md PlayerObject section](persistence.md#playerobject) — PlayerObject lifecycle, auto-ownership, `OnPlayerRestored`\n- [api.md VRCPlayerApi Movement Methods](api.md#movement-methods) — `TeleportTo` overloads and per-client local teleport semantics\n\n## Delayed Event Debounce\n\n### Problem\n\n`SendCustomEventDelayedSeconds` schedules a future event, but there is no cancellation API. If the same event is scheduled multiple times in quick succession (e.g., a rapid button tap), all enqueued callbacks fire.\n\n### Solution\n\nUse an integer generation counter. Each new schedule increments the counter and captures the current value. The callback checks whether the counter has advanced; if so, a newer schedule exists and this invocation is a no-op.\n\n```csharp\npublic class DebouncedSearch : UdonSharpBehaviour\n{\n [SerializeField] private float debounceDelay = 0.5f;\n\n // Monotonically increasing; each new schedule captures the current value.\n private int _scheduleGeneration = 0;\n\n /// \u003csummary>\n /// Call this whenever input changes. Only the callback scheduled after the\n /// last call within debounceDelay seconds will actually execute.\n /// \u003c/summary>\n public void OnInputChanged()\n {\n _scheduleGeneration++;\n // Pass the current generation as a serialized field so the callback can read it.\n // UdonSharp does not support lambda captures, so store in a member variable.\n _pendingGeneration = _scheduleGeneration;\n SendCustomEventDelayedSeconds(nameof(ExecuteSearch), debounceDelay);\n }\n\n // Captured generation for the most recently scheduled callback.\n private int _pendingGeneration = 0;\n\n public void ExecuteSearch()\n {\n // If _scheduleGeneration has moved past _pendingGeneration, a newer\n // schedule supersedes this one — bail out.\n if (_scheduleGeneration != _pendingGeneration) return;\n\n // Safe to execute: this is the most recent scheduled callback.\n PerformSearch();\n }\n\n private void PerformSearch()\n {\n Debug.Log(\"Executing debounced search\");\n // ... actual search logic\n }\n}\n```\n\n> **Note:** This pattern ensures only the *last* scheduled event executes. It does not prevent intermediate callbacks from running their guard check — it only makes them return immediately.\n\n---\n\n## String Join for Array Sync\n\n### Problem\n\nSyncing `string[]` via `[UdonSynced]` serialises each element individually with per-element overhead. For arrays that change together as a logical unit — playlist titles, display names, ordered slot labels — this wastes bandwidth and produces multiple `OnDeserialization` callbacks if the array is written element-by-element in a loop.\n\n### Solution\n\nJoin the entire array into a single `[UdonSynced] string` using a separator character that virtually never appears in natural text or URLs, then split on deserialization. The recommended separator is **U+2029 PARAGRAPH SEPARATOR** (`\\u2029`): it is invisible, not present on any keyboard, and absent from VRCUrl strings, display names, and common user text.\n\n**Size consideration:** The joined string must fit within VRChat's synced-data limits. See [networking-bandwidth.md](networking-bandwidth.md) for per-variable and per-behaviour size caps. For large playlists, prefer pagination (sync a window of N entries at a time) over a single huge string.\n\n**Alternative:** Declare a fixed number of separate `[UdonSynced] string` fields — one per playlist slot. Simpler but limited to a known maximum count and does not scale beyond ~10–20 slots without cluttering the behaviour.\n\n**When to use:**\n- Playlist titles where the full list changes on every shuffle or load\n- User display names collected by the master and broadcast to late joiners\n- Any variable-length `string[]` that logically changes as a unit\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class SyncedPlaylist : UdonSharpBehaviour\n{\n // U+2029 PARAGRAPH SEPARATOR — rare enough to be a safe delimiter.\n private const string Separator = \"\\u2029\";\n\n [UdonSynced]\n private string _syncedTitles = \"\";\n\n // Local working copy — split from _syncedTitles on deserialization.\n private string[] _titles = new string[0];\n\n // ── Helper methods ────────────────────────────────────────────────────\n\n /// \u003csummary>\n /// Joins a string array into a single sync-safe string.\n /// Empty or null items are preserved as empty strings so indices are stable.\n /// \u003c/summary>\n private string JoinForSync(string[] items)\n {\n if (items == null || items.Length == 0) return \"\";\n\n System.Text.StringBuilder sb = new System.Text.StringBuilder();\n for (int i = 0; i \u003c items.Length; i++)\n {\n if (i > 0) sb.Append(Separator);\n\n string item = items[i];\n // Guard: strip any accidental separator characters from content.\n if (!string.IsNullOrEmpty(item) && item.Contains(Separator))\n {\n item = item.Replace(Separator, \" \");\n }\n\n sb.Append(item ?? \"\");\n }\n\n return sb.ToString();\n }\n\n /// \u003csummary>\n /// Splits a sync string back into a string array.\n /// Returns an empty array for an empty or null input.\n /// \u003c/summary>\n private string[] SplitFromSync(string joined)\n {\n if (string.IsNullOrEmpty(joined)) return new string[0];\n\n // UdonSharp does not support string.Split(string[], StringSplitOptions).\n // Manual split on the single-character separator.\n int separatorChar = Separator[0];\n int count = 1;\n for (int i = 0; i \u003c joined.Length; i++)\n {\n if (joined[i] == separatorChar) count++;\n }\n\n string[] result = new string[count];\n int startIndex = 0;\n int resultIndex = 0;\n\n for (int i = 0; i \u003c= joined.Length; i++)\n {\n bool atSeparator = (i \u003c joined.Length && joined[i] == separatorChar);\n bool atEnd = (i == joined.Length);\n\n if (atSeparator || atEnd)\n {\n result[resultIndex] = joined.Substring(startIndex, i - startIndex);\n resultIndex++;\n startIndex = i + 1;\n }\n }\n\n return result;\n }\n\n // ── Owner-side write ──────────────────────────────────────────────────\n\n /// \u003csummary>\n /// Sets the playlist titles (owner only) and serializes.\n /// \u003c/summary>\n public void SetTitles(string[] titles)\n {\n if (!Networking.IsOwner(gameObject)) return;\n\n _titles = titles ?? new string[0];\n _syncedTitles = JoinForSync(_titles);\n RequestSerialization();\n }\n\n // ── Late-joiner / deserialization ─────────────────────────────────────\n\n public override void OnDeserialization()\n {\n _titles = SplitFromSync(_syncedTitles);\n OnPlaylistUpdated();\n }\n\n // ── Consumer hook ─────────────────────────────────────────────────────\n\n private void OnPlaylistUpdated()\n {\n Debug.Log($\"[SyncedPlaylist] Received {_titles.Length} title(s).\");\n for (int i = 0; i \u003c _titles.Length; i++)\n {\n Debug.Log($\" [{i}] {_titles[i]}\");\n }\n }\n\n public string[] GetTitles() => _titles;\n}\n```\n\n**Notes:**\n- The manual split loop avoids `string.Split(char[])`, which is blocked in some UdonSharp SDK versions. If your SDK version supports `string.Split(new char[]{ '\\u2029' })`, you may use it instead.\n- The `Replace(Separator, \" \")` guard in `JoinForSync` sanitises content that somehow contains the separator character. In practice U+2029 will never appear in VRCUrl strings or player display names, but the guard is cheap insurance.\n- `_titles` is a local field only — it is not `[UdonSynced]` and is rebuilt from `_syncedTitles` on every `OnDeserialization`. Late joiners receive the correct full list without any extra synchronisation logic.\n\n---\n\n\n## See Also\n\n- [networking-antipatterns.md](networking-antipatterns.md) - Anti-patterns to avoid and advanced sync patterns\n- [networking-bandwidth.md](networking-bandwidth.md) - Bandwidth throttling, bit packing, and data size optimization\n- [patterns-core.md](patterns-core.md) - Initialization, interaction, timer, audio, pickup, animation, UI\n- [patterns-performance.md](patterns-performance.md) - Partial class, update handler, PostLateUpdate, spatial query\n- [patterns-utilities.md](patterns-utilities.md) - Array helpers, event bus, relay communication\n- [patterns-video.md](patterns-video.md) - Video player state machine, server-time playback sync, late joiner sync, synced playlist\n- [networking.md](networking.md) - Sync modes, ownership, bandwidth throttling, anti-patterns\n- [persistence.md](persistence.md) - PlayerData/PlayerObject full reference (SDK 3.7.4+)\n- [dynamics.md](dynamics.md) - PhysBones, Contacts, VRC Constraints full reference (SDK 3.10.0+)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":38949,"content_sha256":"37eb1cbc07b1259a8d77db2b7a2b22ca73bb7729796bad32a060d04d707c2de0"},{"filename":"references/patterns-performance.md","content":"# UdonSharp Performance Patterns\n\nCross-class call overhead, partial class pattern, update handler, PostLateUpdate, spatial query optimization, animator hash caching, and platform detection.\n\n## Performance Patterns\n\n### Cross-Class Call Overhead\n\nIn Udon, calling methods on other UdonBehaviours has significant overhead (~1.5x slower than same-class calls). This creates a dilemma:\n\n- **Good design** suggests splitting responsibilities across classes\n- **Performance** suggests keeping everything in one class\n\nTwo patterns help resolve this: **Partial Classes** and **Update Handler Pattern**.\n\n### Partial Class Pattern\n\nSplit a large class across multiple files while maintaining single-class performance:\n\n```csharp\n// MyGimmick.cs - Main entry points and core logic\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\npublic partial class MyGimmick : UdonSharpBehaviour\n{\n void Start()\n {\n InitializeUI();\n InitializeSync();\n }\n\n public override void Interact()\n {\n HandleInteraction();\n }\n}\n```\n\n```csharp\n// MyGimmick.UI.cs - UI-related code\nusing TMPro;\nusing UnityEngine;\nusing UnityEngine.UI;\n\npublic partial class MyGimmick\n{\n [Header(\"UI References\")]\n [SerializeField] private TextMeshProUGUI statusText;\n [SerializeField] private Slider progressSlider;\n\n private void InitializeUI()\n {\n UpdateStatusDisplay();\n }\n\n private void UpdateStatusDisplay()\n {\n if (statusText != null)\n {\n statusText.text = $\"State: {_currentState}\";\n }\n }\n\n private void UpdateProgress(float progress)\n {\n if (progressSlider != null)\n {\n progressSlider.value = progress;\n }\n }\n}\n```\n\n```csharp\n// MyGimmick.Sync.cs - Network synchronization\nusing VRC.SDKBase;\n\npublic partial class MyGimmick\n{\n [UdonSynced, FieldChangeCallback(nameof(CurrentState))]\n private int _currentState = 0;\n\n public int CurrentState\n {\n get => _currentState;\n set\n {\n _currentState = value;\n OnStateChanged();\n }\n }\n\n private void InitializeSync()\n {\n // Sync initialization\n }\n\n private void HandleInteraction()\n {\n if (!Networking.IsOwner(gameObject))\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n CurrentState = (_currentState + 1) % 3;\n RequestSerialization();\n }\n\n private void OnStateChanged()\n {\n UpdateStatusDisplay();\n }\n}\n```\n\n**Benefits:**\n- Same performance as a single class (verified by benchmarks)\n- Better code organization and readability\n- Easier to maintain large gimmicks\n- Each file can focus on one responsibility\n\n**File naming convention:**\n| File | Responsibility |\n|------|----------------|\n| `Gimmick.cs` | Main entry points, core logic |\n| `Gimmick.UI.cs` | UI handling, display updates |\n| `Gimmick.Sync.cs` | Network synchronization |\n| `Gimmick.Audio.cs` | Audio playback |\n| `Gimmick.Animation.cs` | Animation control |\n\n**Caveats:**\n- All partials share the same member namespace (no duplicates allowed)\n- `private` members are accessible across all partials\n- Requires strict naming conventions for clarity\n- This is an anti-pattern in standard C# (normally for generated code)\n\n**Performance comparison:**\n\n| Call Type | Time (1000 calls) |\n|-----------|-------------------|\n| Same-class method | 0.68 ms |\n| Partial-class method (different file) | 0.68 ms |\n| Other-class method | 1.04 ms |\n\n### Update Handler Pattern\n\nSeparate `Update()` into a dedicated component that can be enabled/disabled:\n\n```csharp\n// GimmickManager.cs - Controls the gimmick, no Update()\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\npublic class GimmickManager : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private GimmickUpdateHandler updateHandler;\n\n [Header(\"Settings\")]\n [SerializeField] private float processingDuration = 5.0f;\n\n private bool isProcessing = false;\n\n void Start()\n {\n // Ensure Update is disabled initially\n if (updateHandler != null)\n {\n updateHandler.enabled = false;\n }\n }\n\n public override void Interact()\n {\n if (isProcessing) return;\n StartProcessing();\n }\n\n public void StartProcessing()\n {\n isProcessing = true;\n\n if (updateHandler != null)\n {\n updateHandler.enabled = true;\n }\n\n // Auto-stop after duration\n SendCustomEventDelayedSeconds(nameof(StopProcessing), processingDuration);\n }\n\n public void StopProcessing()\n {\n isProcessing = false;\n\n if (updateHandler != null)\n {\n updateHandler.enabled = false;\n }\n }\n\n public bool IsProcessing => isProcessing;\n}\n```\n\n```csharp\n// GimmickUpdateHandler.cs - Contains Update() logic, enabled only when needed\nusing UdonSharp;\nusing UnityEngine;\n\npublic class GimmickUpdateHandler : UdonSharpBehaviour\n{\n [SerializeField] private GimmickManager manager;\n [SerializeField] private Transform targetTransform;\n [SerializeField] private float rotationSpeed = 90f;\n\n void Update()\n {\n // This only runs when enabled\n if (targetTransform != null)\n {\n targetTransform.Rotate(Vector3.up, rotationSpeed * Time.deltaTime);\n }\n }\n}\n```\n\n**Why this matters:**\n\nWith 100 inactive gimmicks in a world:\n\n| Approach | CPU Time |\n|----------|----------|\n| Always-running Update with early return | 0.0745 ms |\n| Disabled UpdateHandler | 0.0122 ms |\n\n**6x performance improvement** for inactive gimmicks.\n\n**When to use:**\n- Gimmicks that are only active occasionally\n- Optional features that players may not use\n- Processing-intensive operations\n\n**When NOT to use:**\n- Gimmicks that are always active\n- Simple Update() with minimal overhead\n- When the extra component adds more complexity than benefit\n\n### Combining Both Patterns\n\nFor complex gimmicks, combine Partial Class and Update Handler:\n\n```csharp\n// ComplexGimmick.cs\npublic partial class ComplexGimmick : UdonSharpBehaviour\n{\n [SerializeField] private ComplexGimmickUpdateHandler updateHandler;\n // Main logic...\n}\n\n// ComplexGimmick.UI.cs\npublic partial class ComplexGimmick\n{\n // UI code...\n}\n\n// ComplexGimmick.Sync.cs\npublic partial class ComplexGimmick\n{\n // Sync code...\n}\n\n// ComplexGimmickUpdateHandler.cs (separate class, not partial)\npublic class ComplexGimmickUpdateHandler : UdonSharpBehaviour\n{\n [SerializeField] private ComplexGimmick manager;\n\n void Update()\n {\n // Heavy per-frame processing\n }\n}\n```\n\nThis gives you:\n- Organized code across multiple files (Partial Class)\n- Controlled Update() execution (Update Handler)\n- Best possible performance for both active and inactive states\n\n## Performance Optimization Patterns\n\n### PostLateUpdate for Camera-Dependent Effects\n\n`Update()` runs before the camera moves each frame. For effects that must track the VRChat camera — nameplate overlays, HUD elements, billboard sprites — use `PostLateUpdate()` instead. It runs after the camera's final position is resolved.\n\nAdd change-detection to skip the GPU upload when the camera has not moved:\n\n```csharp\npublic class CameraTracker : UdonSharpBehaviour\n{\n [SerializeField] private Transform trackedTransform;\n\n private Vector3 _lastCameraPosition;\n private Quaternion _lastCameraRotation;\n\n public override void PostLateUpdate()\n {\n VRCPlayerApi localPlayer = Networking.LocalPlayer;\n if (localPlayer == null) return;\n\n VRCPlayerApi.TrackingData head = localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head);\n Vector3 camPos = head.position;\n Quaternion camRot = head.rotation;\n\n // Skip update when camera has not moved (change detection)\n if (camPos == _lastCameraPosition && camRot == _lastCameraRotation) return;\n\n _lastCameraPosition = camPos;\n _lastCameraRotation = camRot;\n\n // Apply transform relative to camera\n if (trackedTransform != null)\n {\n trackedTransform.position = camPos + camRot * Vector3.forward * 2f;\n trackedTransform.rotation = camRot;\n }\n }\n}\n```\n\n### Bounds Pre-Check for Spatial Queries\n\n`Collider.ClosestPoint()` is expensive. When you have many potential colliders, compute a compound `Bounds` at startup that wraps all of them. Discard distant queries with a fast `Bounds.Contains()` before paying the full cost.\n\n```csharp\npublic class SpatialQueryZone : UdonSharpBehaviour\n{\n [SerializeField] private Collider[] zoneColliders;\n\n private Bounds _compoundBounds;\n\n void Start()\n {\n if (zoneColliders == null || zoneColliders.Length == 0) return;\n\n // Build compound bounds from all colliders\n _compoundBounds = zoneColliders[0].bounds;\n for (int i = 1; i \u003c zoneColliders.Length; i++)\n {\n if (zoneColliders[i] != null)\n {\n _compoundBounds.Encapsulate(zoneColliders[i].bounds);\n }\n }\n }\n\n /// \u003csummary>\n /// Returns the closest point on any zone collider, or Vector3.zero if the\n /// query point is clearly outside the compound bounds.\n /// \u003c/summary>\n public Vector3 GetClosestPoint(Vector3 queryPoint)\n {\n // Fast rejection: skip expensive ClosestPoint calls entirely\n if (!_compoundBounds.Contains(queryPoint)) return Vector3.zero;\n\n Vector3 closest = Vector3.zero;\n float minDist = float.MaxValue;\n\n for (int i = 0; i \u003c zoneColliders.Length; i++)\n {\n if (zoneColliders[i] == null) continue;\n\n Vector3 candidate = zoneColliders[i].ClosestPoint(queryPoint);\n float dist = Vector3.Distance(queryPoint, candidate);\n if (dist \u003c minDist)\n {\n minDist = dist;\n closest = candidate;\n }\n }\n\n return closest;\n }\n}\n```\n\n### Animator Parameter Hash Caching\n\n`Animator.SetFloat(string, float)` resolves the string to an internal hash every call. Cache the hash in `Start()` and use the integer overload in `Update()`.\n\n```csharp\npublic class AnimatedPlatform : UdonSharpBehaviour\n{\n [SerializeField] private Animator platformAnimator;\n\n // Cached hashes — computed once, reused every frame\n private int _speedHash;\n private int _isMovingHash;\n private int _directionHash;\n\n void Start()\n {\n _speedHash = Animator.StringToHash(\"Speed\");\n _isMovingHash = Animator.StringToHash(\"IsMoving\");\n _directionHash = Animator.StringToHash(\"Direction\");\n }\n\n void Update()\n {\n float currentSpeed = GetPlatformSpeed();\n bool moving = currentSpeed > 0.01f;\n\n // Integer overloads: no string lookup at runtime\n platformAnimator.SetFloat(_speedHash, currentSpeed);\n platformAnimator.SetBool(_isMovingHash, moving);\n platformAnimator.SetFloat(_directionHash, GetPlatformDirection());\n }\n\n private float GetPlatformSpeed()\n {\n // Platform-specific logic\n return 0f;\n }\n\n private float GetPlatformDirection()\n {\n // Platform-specific logic\n return 0f;\n }\n}\n```\n\n**Rule of thumb:** Cache any string passed to `Animator.Set*` or `Animator.Get*` that is called more than once per second.\n\n### Platform Detection Pattern\n\nUse VRChat's runtime API to branch behaviour by platform. Check once in `Start()` and store results in fields rather than querying every frame.\n\n```csharp\npublic class PlatformAdapter : UdonSharpBehaviour\n{\n private bool _isVR = false;\n private bool _isMobile = false;\n\n void Start()\n {\n VRCPlayerApi localPlayer = Networking.LocalPlayer;\n if (localPlayer == null) return;\n\n _isVR = localPlayer.IsUserInVR();\n\n // Mobile players use touch input as their last input method\n _isMobile = InputManager.GetLastUsedInputMethod() == VRCInputMethod.Touch;\n\n ApplyPlatformSettings();\n }\n\n private void ApplyPlatformSettings()\n {\n if (_isVR)\n {\n // Adjust interaction distances for VR reach\n Debug.Log(\"VR mode: adjusting grab distances\");\n }\n else if (_isMobile)\n {\n // Enlarge touch targets for mobile players\n Debug.Log(\"Mobile mode: scaling up UI hit areas\");\n }\n else\n {\n // Desktop mouse+keyboard defaults\n Debug.Log(\"Desktop mode\");\n }\n }\n\n public bool IsVR => _isVR;\n public bool IsMobile => _isMobile;\n public bool IsDesktop => !_isVR && !_isMobile;\n}\n```\n\n> **Note:** `InputManager.GetLastUsedInputMethod()` reflects the last device the player used, not a fixed platform flag. It can change during a session if the player switches devices. For a stable platform classification, check only `IsUserInVR()` at `Start()` and treat everything else as flat-screen.\n\n---\n\n### Frame Budget Stopwatch\n\n#### Problem\n\nHeavy synchronous operations — parsing large downloaded strings, decoding Base64 data, building UI elements from network payloads — can stall the main thread for tens of milliseconds. UdonSharp has no `async`/`await` and no coroutines. If the entire operation runs in a single frame, VR users will see a visible hitch.\n\n#### Solution\n\nUse `System.Diagnostics.Stopwatch` to measure how much time has elapsed within the current frame. After each processing step, call a `BranchByBudget` helper:\n\n- If the elapsed time is **under the budget** (default 20 ms), call the next step immediately via `SendCustomEvent` — no frame boundary is crossed.\n- If the elapsed time **meets or exceeds the budget**, defer to the next frame via `SendCustomEventDelayedFrames(nextMethodName, 1)` and restart the stopwatch.\n\nEach deferred frame resets the stopwatch so the next step gets a full fresh budget.\n\n#### Why 20 ms?\n\nVR targets 90 FPS (≈11.1 ms per frame). A 20 ms budget is deliberately looser than one frame: it allows small overruns without dropping two frames in a row, while still yielding control before a second heavy step can compound the problem. Adjust `_processBudgetMs` in the Inspector to match your target framerate and workload.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing System.Diagnostics;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class FrameBudgetProcessor : UdonSharpBehaviour\n{\n [Header(\"Processing Data\")]\n [SerializeField] private string[] _inputLines;\n\n [Header(\"Budget Settings\")]\n [SerializeField] private float _processBudgetMs = 20f;\n\n // Internal state\n private Stopwatch _stopwatch;\n private int _currentIndex;\n private string[] _results;\n\n void Start()\n {\n _stopwatch = new Stopwatch();\n }\n\n // ── Public entry point ────────────────────────────────────────────────\n\n public void BeginProcessing()\n {\n if (_inputLines == null || _inputLines.Length == 0) return;\n\n _currentIndex = 0;\n _results = new string[_inputLines.Length];\n\n // Start the stopwatch fresh for this batch\n _stopwatch.Reset();\n _stopwatch.Start();\n\n SendCustomEvent(nameof(Step1_Parse));\n }\n\n // ── Processing pipeline ───────────────────────────────────────────────\n\n public void Step1_Parse()\n {\n while (_currentIndex \u003c _inputLines.Length)\n {\n // Simulate per-line work (replace with real parsing logic)\n _results[_currentIndex] = _inputLines[_currentIndex].Trim();\n _currentIndex++;\n\n // Yield if the frame budget is spent\n if (BranchByBudget(nameof(Step1_Parse))) return;\n }\n\n // All lines parsed — move to next stage via SendCustomEvent (not BranchByBudget).\n // BranchByBudget is for within-stage iteration only; using it for stage transitions\n // causes a deadlock because the freshly-reset stopwatch always reads 0 ms elapsed,\n // so BranchByBudget never defers and Step2 runs in the same frame budget window.\n _currentIndex = 0;\n SendCustomEvent(nameof(Step2_Transform));\n }\n\n public void Step2_Transform()\n {\n // Reset the stopwatch at the start of each stage so the budget is fresh\n _stopwatch.Reset();\n _stopwatch.Start();\n\n while (_currentIndex \u003c _results.Length)\n {\n // Simulate transform work\n _results[_currentIndex] = _results[_currentIndex].ToUpper();\n _currentIndex++;\n\n if (BranchByBudget(nameof(Step2_Transform))) return;\n }\n\n // Move to next stage — use SendCustomEvent, not BranchByBudget (same reason as above)\n _currentIndex = 0;\n SendCustomEvent(nameof(Step3_BuildUI));\n }\n\n public void Step3_BuildUI()\n {\n // Reset the stopwatch at the start of each stage so the budget is fresh\n _stopwatch.Reset();\n _stopwatch.Start();\n\n while (_currentIndex \u003c _results.Length)\n {\n // Simulate UI construction work per entry\n _currentIndex++;\n\n if (BranchByBudget(nameof(Step3_BuildUI))) return;\n }\n\n SendCustomEvent(nameof(OnProcessingComplete));\n }\n\n public void OnProcessingComplete()\n {\n _stopwatch.Stop();\n UnityEngine.Debug.Log(\"[FrameBudgetProcessor] Processing complete.\");\n }\n\n // ── Budget helper ─────────────────────────────────────────────────────\n\n /// \u003csummary>\n /// Returns true and defers \u003cparamref name=\"nextMethodName\"/> to the next frame\n /// if the current frame budget is exhausted. Returns false if the budget has\n /// not yet been spent (caller should continue its loop).\n /// \u003c/summary>\n private bool BranchByBudget(string nextMethodName)\n {\n if (_stopwatch.Elapsed.TotalMilliseconds >= _processBudgetMs)\n {\n // Budget spent — hand back control and resume next frame\n SendCustomEventDelayedFrames(nextMethodName, 1);\n _stopwatch.Reset();\n _stopwatch.Start();\n return true;\n }\n return false;\n }\n}\n```\n\n**Notes:**\n\n- `System.Diagnostics.Stopwatch` is available in UdonSharp (verified in SDK 3.7.1+; not explicitly listed in the official allowlist but confirmed working in production worlds).\n- `SendCustomEventDelayedFrames` is documented in [events.md](events.md).\n- The `BranchByBudget` helper is checked **after each unit of work**, not before, so the final unit in a budget window may slightly exceed the target. Keep individual work units small (single array element, single string operation) to minimise overshoot.\n- Do not share a single `Stopwatch` instance across two simultaneous processing pipelines — each pipeline needs its own instance and its own `_processBudgetMs` field.\n- **Important:** `Stopwatch` measures wall-clock time continuously, including idle time between frames. When `BranchByBudget` defers work to the next frame via `SendCustomEventDelayedFrames`, the stopwatch is reset and restarted in `BranchByBudget` so the next frame begins with a fresh budget. If you restructure this pattern, always reset the stopwatch at the start of each new frame's work — otherwise the elapsed time will include the inter-frame gap and the budget will appear instantly exhausted.\n\n**When to use:**\n\n- Parsing large strings received from `VRCUrl` or `VRCStringDownloader` downloads\n- Batch texture or colour decoding from Base64 data\n- Building UI panels from multi-entry data arrays\n- Any single-threaded operation that may take more than 5 ms in isolation\n\n---\n\n### Heavy Processing Architecture\n\n#### Problem\n\nThe Frame Budget Stopwatch above solves *when to yield* — but large-scale systems (world builders, replay-based games, procedural generators) also need to answer *what to process*, *how to rebuild*, and *how to cancel safely*. Without a clear separation between authoritative data and derived visuals, a reset or late-joiner sync can leave the world in an inconsistent state.\n\n#### Core Principle: Authoritative Data vs Derived State\n\nSplit every heavy system into two layers:\n\n| Layer | Holds | Examples | Survives reset? |\n|-------|-------|----------|----------------|\n| **Authoritative** | Minimal data that fully describes the current state | Operation log (`byte[]`), config arrays, placement indices | Yes — this IS the state |\n| **Derived** | Visuals / physics / UI generated from authoritative data | Instantiated GameObjects, UI text, material colours | No — regenerated on demand |\n\nThe rebuild contract: given only the authoritative layer, the system can regenerate all derived state from scratch. This makes reset, undo, and late-joiner sync straightforward — replay the authoritative data through the same generation pipeline.\n\n#### Pattern: Cursor-Based Rebuild\n\nWhen derived state involves many objects (e.g., 200+ placed blocks), rebuilding in a single frame causes a VR hitch. Combine the authoritative/derived split with the `BranchByBudget` stopwatch:\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing System.Diagnostics;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class RebuildableWorld : UdonSharpBehaviour\n{\n // Pool size and synced array size must match.\n private const int MaxPlacements = 256;\n\n [Header(\"Authoritative Data\")]\n [UdonSynced] private int[] _placementIds = new int[MaxPlacements];\n [UdonSynced] private Vector3[] _placementPositions = new Vector3[MaxPlacements];\n [UdonSynced] private int _placementCount;\n\n [Header(\"Derived State — pre-allocate MaxPlacements objects in the scene\")]\n [SerializeField] private GameObject[] _blockPool;\n private int _rebuildCursor;\n private bool _isRebuilding;\n\n [Header(\"Budget\")]\n [SerializeField] private float _budgetMs = 16f;\n private Stopwatch _sw;\n\n void Start()\n {\n _sw = new Stopwatch();\n\n // Fail-fast: detect Inspector misconfiguration before any rebuild\n if (_blockPool == null || _blockPool.Length \u003c MaxPlacements)\n {\n Debug.LogError($\"[RebuildableWorld] _blockPool must have at least {MaxPlacements} entries.\");\n return;\n }\n }\n\n // --- Authoritative mutation (owner only) ---\n\n public void AddPlacement(int blockId, Vector3 position)\n {\n if (!Networking.IsOwner(gameObject)) return;\n if (_placementCount >= MaxPlacements) return;\n // Block incremental adds while a full rebuild is in flight\n // to avoid double-applying the new entry.\n if (_isRebuilding) return;\n\n _placementIds[_placementCount] = blockId;\n _placementPositions[_placementCount] = position;\n _placementCount++;\n\n // Instant local feedback for the single new block\n ApplyOnePlacement(_placementCount - 1);\n RequestSerialization();\n }\n\n // --- Full rebuild (reset, undo, late-joiner) ---\n\n public void BeginFullRebuild()\n {\n // Hide all derived objects before rebuilding\n for (int i = 0; i \u003c _blockPool.Length; i++)\n {\n _blockPool[i].SetActive(false);\n }\n\n _rebuildCursor = 0;\n _isRebuilding = true;\n _sw.Reset();\n _sw.Start();\n SendCustomEvent(nameof(_RebuildStep));\n }\n\n public void _RebuildStep()\n {\n // Guard: if cancelled or a new BeginFullRebuild was called while\n // a previous deferred _RebuildStep was still pending, bail out.\n if (!_isRebuilding) return;\n\n while (_rebuildCursor \u003c _placementCount)\n {\n ApplyOnePlacement(_rebuildCursor);\n _rebuildCursor++;\n\n if (_sw.Elapsed.TotalMilliseconds >= _budgetMs)\n {\n SendCustomEventDelayedFrames(nameof(_RebuildStep), 1);\n _sw.Reset();\n _sw.Start();\n return;\n }\n }\n\n _sw.Stop();\n _isRebuilding = false;\n }\n\n private void ApplyOnePlacement(int index)\n {\n if (index \u003c 0 || index >= _blockPool.Length) return;\n if (_blockPool[index] == null) return;\n _blockPool[index].SetActive(true);\n _blockPool[index].transform.position = _placementPositions[index];\n // blockId lookup omitted for brevity\n }\n\n // --- Sync ---\n\n public override void OnDeserialization()\n {\n // Late joiner or owner change: full rebuild from authoritative data.\n // If a previous rebuild is mid-flight, BeginFullRebuild resets the\n // cursor and sets _isRebuilding = true; the stale deferred callback\n // from the old rebuild bails out at the guard in _RebuildStep.\n BeginFullRebuild();\n }\n}\n```\n\n**Key points:**\n\n- `AddPlacement` mutates the authoritative arrays and applies instant local feedback for one block — no full rebuild needed for incremental changes. It is blocked while `_isRebuilding` is true to avoid double-applying entries.\n- `BeginFullRebuild` is the universal entry point for reset, undo, and late-joiner sync. It clears all derived state and walks the authoritative data with a cursor. If called while a previous rebuild is in progress, the stale deferred `_RebuildStep` callback safely exits via the `_isRebuilding` guard.\n- The `_blockPool` is pre-allocated in the scene (UdonSharp cannot instantiate at runtime). The pool size must equal `MaxPlacements`; both the synced arrays and the pool share this constant.\n- `Vector3[]` sync is valid but costs 12 bytes per element. For large placement counts consider packing positions into `int[]` with fixed-point encoding — see [networking-bandwidth.md](networking-bandwidth.md).\n\n#### Pattern: Operation Log with Replay\n\nFor systems where the *sequence of actions* matters (board games, drawing tools), store an operation log rather than final state. This enables undo, replay, and late-joiner catch-up:\n\n```csharp\n// Authoritative layer: operation log\n[UdonSynced] private byte[] _opLog; // Packed operations\n[UdonSynced] private int _opCount; // Number of valid entries\n\n// Each operation is a fixed-size record (e.g., 4 bytes):\n// byte 0: operation type (place=0, remove=1, move=2)\n// byte 1: target slot index\n// byte 2-3: parameter (e.g., colour index, position index)\n\nprivate void ReplayFromScratch()\n{\n ResetDerivedState();\n for (int i = 0; i \u003c _opCount; i++)\n {\n int offset = i * 4;\n byte opType = _opLog[offset];\n byte slot = _opLog[offset + 1];\n int param = (_opLog[offset + 2] \u003c\u003c 8) | _opLog[offset + 3];\n ApplyOperation(opType, slot, param);\n }\n}\n```\n\n**Cross-reference:** The `UndoableGameManager.cs` template uses **full-state snapshots** rather than operation logs — each move saves the complete `currentState` array via `System.Array.Copy`. Use snapshots when state is small and replay is expensive; use the operation-log approach when state is large but individual operations are compact. See [assets/templates/UndoableGameManager.cs](../assets/templates/UndoableGameManager.cs).\n\n> **Note:** The operation-log snippet above is a fragment showing the data layout and replay loop. In a full implementation, wrap it in an `UdonSharpBehaviour` class with `[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]` and add an initialized array (e.g., `private byte[] _opLog = new byte[4000];` for up to 1000 four-byte operations — 4 KB, well within the ~280KB (280,496 bytes) Manual sync limit).\n\n#### Reset vs Cancel\n\nThese are distinct operations — conflating them causes bugs:\n\n| Operation | Meaning | Authoritative data | Derived state | In-progress work |\n|-----------|---------|-------------------|---------------|-----------------|\n| **Reset** | Return to a known initial state | Revert to snapshot (e.g., `_opCount = 0` or restore `history[0]`) | Full rebuild from reverted data | Abort and discard |\n| **Cancel** | Abort an in-progress multi-frame operation | Keep current authoritative data unchanged | Stop rebuild cursor, keep whatever is already rendered | Abort only the pending steps |\n\n```csharp\n// Cancel: stop an in-progress rebuild without touching authoritative data\npublic void CancelRebuild()\n{\n _isRebuilding = false;\n // Derived state is partially rebuilt — acceptable for cancel.\n // Next user action or sync will trigger a fresh full rebuild if needed.\n}\n\n// Reset: revert authoritative data, then rebuild\npublic void ResetToInitial()\n{\n if (!Networking.IsOwner(gameObject)) return;\n _placementCount = 0;\n BeginFullRebuild();\n RequestSerialization();\n}\n```\n\n#### Guidelines\n\n| Guideline | Rationale |\n|-----------|-----------|\n| Keep authoritative data in synced arrays, derived state in local references | Late joiners receive authoritative data via `OnDeserialization` and rebuild locally |\n| One rebuild entry point (`BeginFullRebuild`) for all triggers | Reset, undo, late-joiner sync, and error recovery all use the same path — fewer edge cases |\n| Do not mix rebuild progress with sync serialization | If `RequestSerialization` fires mid-rebuild, the partial derived state is irrelevant — only authoritative data is transmitted |\n| Cap operation logs with a maximum size | `byte[]` sync has a ~280KB (280,496 bytes) Manual sync limit; a 4-byte-per-op log with 1000 ops = 4 KB — well within budget |\n| Use cancel for user-initiated abort, reset for state revert | Cancel preserves partial visual progress; reset guarantees a clean starting state |\n\n---\n\n## Rate Limit Resolver\n\n### Problem\n\nVRChat enforces a **5-second rate limit** on video URL loads shared across the entire scene. Multiple behaviours in the same world that independently trigger URL loads will collide: the second request within the 5-second window is silently rejected, leaving the requester waiting indefinitely with no error callback.\n\n**Cross-reference:** The same 5-second rate limit applies to `VRCStringDownloader` and image-loading requests. See [web-loading.md](web-loading.md) for details on string/image downloads.\n\n### Solution\n\nA singleton `UrlLoadScheduler` behaviour serialised into every world that needs video URL loading. It owns an array-based queue of pending load requests. Each request stores the requester behaviour reference and a callback method name. The scheduler drains one request per 5.05-second cycle (5.05 s adds a small margin above the hard limit), ensuring no two loads collide.\n\nAll video-loading behaviours in the world hold a `[SerializeField]` reference to the same `UrlLoadScheduler` instance rather than triggering loads directly.\n\n**Architecture:**\n\n```text\nVideoPlayerA ──ScheduleLoad──▶ UrlLoadScheduler\nVideoPlayerB ──ScheduleLoad──▶ (shared singleton)\n │\n every 5.05s │ drain one request\n ▼\n requester.SendCustomEvent(callbackName)\n```\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n/// \u003csummary>\n/// Singleton scheduler that enforces the VRChat 5-second URL-load rate limit.\n/// Place one instance in the scene and wire all video-loading behaviours to it\n/// via SerializeField.\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class UrlLoadScheduler : UdonSharpBehaviour\n{\n [Header(\"Rate Limit\")]\n [Tooltip(\"Minimum seconds between URL loads. Must be >= 5.0.\")]\n [SerializeField] private float _intervalSeconds = 5.05f;\n\n // Queue storage — parallel arrays act as a struct-of-arrays queue.\n private const int MaxQueueDepth = 16;\n\n private UdonSharpBehaviour[] _queueRequesters\n = new UdonSharpBehaviour[MaxQueueDepth];\n private string[] _queueCallbacks = new string[MaxQueueDepth];\n private VRCUrl[] _queueUrls = new VRCUrl[MaxQueueDepth];\n\n private int _queueHead = 0; // index of oldest item (dequeue from here)\n private int _queueTail = 0; // index of next free slot (enqueue here)\n private int _queueCount = 0;\n\n private bool _drainPending = false;\n\n void Start()\n {\n // Enforce the VRChat rate-limit lower bound at runtime regardless of\n // what the Inspector field was set to.\n if (_intervalSeconds \u003c 5.0f) _intervalSeconds = 5.0f;\n }\n\n // ── Public API ────────────────────────────────────────────────────────\n\n /// \u003csummary>\n /// Enqueues a URL load request. When the scheduler drains this request,\n /// it calls requester.SendCustomEvent(callbackName).\n /// The requester is expected to perform the actual VRCUrl load inside\n /// that callback.\n /// \u003c/summary>\n public void ScheduleLoad(\n UdonSharpBehaviour requester,\n string callbackName,\n VRCUrl url)\n {\n if (requester == null || string.IsNullOrEmpty(callbackName)) return;\n\n if (_queueCount >= MaxQueueDepth)\n {\n Debug.LogWarning(\"[UrlLoadScheduler] Queue full — dropping load request.\");\n return;\n }\n\n _queueRequesters[_queueTail] = requester;\n _queueCallbacks[_queueTail] = callbackName;\n _queueUrls[_queueTail] = url;\n\n _queueTail = (_queueTail + 1) % MaxQueueDepth;\n _queueCount++;\n\n // Start the drain loop if it is not already running.\n if (!_drainPending)\n {\n _drainPending = true;\n SendCustomEvent(nameof(_DrainNext));\n }\n }\n\n // ── Internal drain loop ───────────────────────────────────────────────\n\n /// \u003csummary>\n /// Dequeues and dispatches one request, then schedules itself again\n /// if the queue is still non-empty.\n /// \u003c/summary>\n public void _DrainNext()\n {\n if (_queueCount == 0)\n {\n _drainPending = false;\n return;\n }\n\n // Dequeue the oldest request.\n UdonSharpBehaviour requester = _queueRequesters[_queueHead];\n string callbackName = _queueCallbacks[_queueHead];\n // VRCUrl is written to the requester's public field before the callback.\n VRCUrl url = _queueUrls[_queueHead];\n\n // Clear the slot.\n _queueRequesters[_queueHead] = null;\n _queueCallbacks[_queueHead] = null;\n _queueUrls[_queueHead] = null;\n _queueHead = (_queueHead + 1) % MaxQueueDepth;\n _queueCount--;\n\n // Deliver the URL to the requester via a public field, then fire the callback.\n if (requester != null)\n {\n requester.SetProgramVariable(\"ScheduledUrl\", url);\n // IMPORTANT: Consumer must have a public field named exactly \"ScheduledUrl\" (VRCUrl type).\n // If the field is renamed, this call silently fails at runtime.\n requester.SendCustomEvent(callbackName);\n }\n\n // Schedule the next drain after the rate-limit interval.\n if (_queueCount > 0)\n {\n SendCustomEventDelayedSeconds(nameof(_DrainNext), _intervalSeconds);\n }\n else\n {\n _drainPending = false;\n }\n }\n}\n```\n\n**Consumer — how a video-loading behaviour uses the scheduler:**\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class ManagedVideoLoader : UdonSharpBehaviour\n{\n [SerializeField] private UrlLoadScheduler _scheduler;\n\n // Written by UrlLoadScheduler before the callback fires.\n // The field name must match the string constant used in UrlLoadScheduler._DrainNext\n // (\"ScheduledUrl\"). Use FIELD_SCHEDULED_URL when calling SetProgramVariable from\n // custom code to avoid silent mismatches.\n public const string FIELD_SCHEDULED_URL = \"ScheduledUrl\";\n [HideInInspector] public VRCUrl ScheduledUrl;\n\n public void RequestLoad(VRCUrl url)\n {\n if (_scheduler == null) { Debug.LogError(\"[ManagedVideoLoader] Scheduler not assigned\"); return; }\n _scheduler.ScheduleLoad(this, nameof(OnScheduledLoad), url);\n }\n\n /// \u003csummary>\n /// Called by UrlLoadScheduler when it is safe to load.\n /// ScheduledUrl is already set at this point.\n /// \u003c/summary>\n public void OnScheduledLoad()\n {\n if (ScheduledUrl == null) return;\n Debug.Log($\"[ManagedVideoLoader] Loading URL: {ScheduledUrl}\");\n // Perform the actual VRCUrl load here (e.g. videoPlayer.LoadURL(ScheduledUrl)).\n }\n}\n```\n\n**Notes:**\n- `_intervalSeconds` defaults to 5.05 s. Do not set it below 5.0.\n- If two behaviours call `ScheduleLoad` in the same frame, only the first starts the drain loop; the second is queued and will fire after 5.05 s.\n- The queue depth is 16 by default. Increase `MaxQueueDepth` if the world can have more concurrent requesters than that.\n- `ScheduledUrl` on the consumer must be declared `public` (not `[HideInInspector]` alone) for `SetProgramVariable` to write it; the `[HideInInspector]` attribute hides it from the Inspector while keeping it accessible to the scheduler.\n- **`SetProgramVariable` field-name contract**: The scheduler writes to the field named `\"ScheduledUrl\"` by string at runtime. If the consumer renames that field, `SetProgramVariable` silently no-ops and the callback receives `null`. Always keep the field name in sync with the string literal in `_DrainNext`.\n\n---\n\n## Event Dispatch & Cross-Behaviour Call Cost Tiers\n\nUdon dispatches cross-behaviour method calls via `SendCustomEvent` internally, and per the [official UdonSharp performance pointers](https://udonsharp.docs.vrchat.com/random-tips-&-performance-pointers/) \"Udon can take on the order of 200x to 1000x longer to run a piece of code than the equivalent in normal C#\" and \"Calls across behaviours will be somewhat slower than local method calls due to how Udon handles SendCustomEvent which is used internally for those cases.\" The same docs note that \"Due to how Udon searches for methods to call, the fewer public methods you have, the better performance-wise\" and recommend \"Prefer to keep methods as private if they are not being called from other scripts.\"\n\nThis section consolidates the cost-vs-shape trade-offs across three axes — method visibility, cross-behaviour calls, and periodic / time-distributed work — so the right tier can be chosen per situation. None of these tiers is forbidden; the (Avoid) row is reserved for combinations that produce frame-time spikes.\n\n- See [`Cross-Class Call Overhead`](#cross-class-call-overhead) above for the ~1.5x benchmark and the underlying dispatch mechanism.\n- See [`Public Method Lookup Cost`](constraints.md#public-method-lookup-cost) for the method-table lookup explanation.\n\n### Method visibility cost tiers\n\n| Tier | Approach | Cost | When |\n|---|---|---|---|\n| 1 | `private` method on the same behaviour | Lowest (direct call) | Default for any method not invoked from another UdonBehaviour, Inspector wiring, or `SendCustomEvent`. |\n| 2 | `public` method invoked from Inspector / `SendCustomEvent` / `[NetworkCallable]` | Bounded (string-name lookup, scales with `public` count per behaviour) | When the method must be reachable from the Inspector, another behaviour, or the network. |\n| 3 | `public` methods kept for \"consistency\" or \"future use\" with no actual external caller | Same as Tier 2, paid for nothing | **Avoid.** Adds to every `SendCustomEvent` dispatch lookup on this behaviour with zero benefit. |\n\n### Cross-behaviour call cost tiers\n\n| Tier | Approach | Cost | When |\n|---|---|---|---|\n| 1 | Private method call inside the same `UdonSharpBehaviour` | Lowest | Default for tightly coupled, frequent calls. |\n| 2 | `partial class` split across files (still one behaviour) | Same as Tier 1 (benchmarked ~0.68 ms / 1000 calls) | Same-class performance with multi-file organization. See [`Partial Class Pattern`](#partial-class-pattern) above. |\n| 3 | Cross-behaviour call via direct reference or `SendCustomEvent` | ~1.5x slower (benchmarked ~1.04 ms / 1000 calls); internally dispatches through `SendCustomEvent` | When the target is a genuinely independent system (a different manager, a different gameplay element, Inspector-wired event handlers). |\n\n### Event dispatch cost tiers for periodic / time-distributed work\n\n| Tier | Approach | When |\n|---|---|---|\n| 1 | `[FieldChangeCallback]` or direct property setter on the same behaviour | State propagation triggered by value changes. Default. See [`networking.md`](networking.md). |\n| 2 | Single coordinator `UdonSharpBehaviour` with `Update` + `deltaTime` accumulation, enabled only when needed | Steady-cadence time-sliced work on **one** manager. Smooths per-frame load. Combine with the [`Update Handler Pattern`](#update-handler-pattern) to disable when idle. |\n| 3 | Self-recursive `SendCustomEventDelayedSeconds` loop (single instance, low frequency) | Sparse timers, single-instance state machines, one-shot retries. See `CHEATSHEET.md` Delayed Execution. |\n| (Avoid) | Self-recursive `SendCustomEventDelayedSeconds` on **many** instances at short period **or** `EventBus.RaiseEvent` from `Update()` on a producer with many subscribers | Event firings concentrate on the same frames, producing frame-time spikes even when steady-state cost looks low. Use Tier 1 or Tier 2 instead. |\n\n> **Caveat (preserved verbatim from reporter):** Tier 2 is not \"Update always wins.\" Adding `Update()` to many behaviours defeats the point — only use Tier 2 on a **dedicated manager** that needs steady-cadence work, and disable the manager via `enabled = false` or the [`Update Handler Pattern`](#update-handler-pattern) when its work is idle.\n\n### Quick decision tree\n\n```text\nOne-shot interaction (Interact, Inspector OnClick)? -> SendCustomEvent (Tier 3 cross-behaviour, fine)\nState changed on owner, react on all clients? -> FieldChangeCallback (Tier 1 event dispatch)\nTightly-coupled per-frame work on one system? -> private methods on same behaviour (Tier 1) or partial class (Tier 2)\nPeriodic work on a single coordinator? -> Update + deltaTime on one manager (Tier 2)\nPeriodic work on many instances at short period? -> Re-architect to one coordinator — use Tier 1 or Tier 2 instead\nOne-to-many broadcast at low frequency? -> EventBus (acceptable for Interact, joystick toggles, etc.)\n```\n\n---\n\n## GameObject Reference Cost Tiers\n\n`GameObject.Find` is not deprecated — but its cost depends entirely on **when** and **how often** you call it. Use this tier table to choose the cheapest approach that fits the situation.\n\n| Tier | Approach | Cost | When |\n|------|----------|------|------|\n| 1 | `[SerializeField]` + Inspector | 0 (compile-time wire) | Default. Targets known at scene-design time. |\n| 2 | Lazy-cached `GameObject.Find` once in `Start()` / `OnEnable()` / first use | 1× O(N) at boot | Targets initially inactive, dynamically generated, or in another scene root that the Inspector cannot reach. |\n| 3 | `GameObject.Find` inside `Update()` / per-frame events | per-frame O(N), GC pressure | **Avoid.** Frame-budget killer; equivalent to a hash-table lookup over the entire scene every frame. |\n\nSee [`constraints.md`](constraints.md) `Lazy Initialization Pattern` for the Tier 2 implementation (`_initialized` flag + null-fallback caching).\n\n### Why Tier 1 is preferred (beyond cost)\n\n`GameObject.Find(\"Name\")` couples runtime behavior to a string literal. If the target GameObject is renamed during scene editing, the call returns `null` silently — no compile error, no warning. This makes string-based lookup a **maintainability hazard** in addition to a performance concern.\n\n`[SerializeField]` references break visibly in the Inspector when the target is renamed or removed (shown as `Missing`), surfacing the problem **before runtime** rather than after a player joins the world.\n\n---\n\n## Array Filtering — `Array.FindAll` Alternative\n\n`Predicate\u003cT>`-based APIs (`Array.FindAll`, `Array.Find`, `Array.FindIndex`) are impractical in UdonSharp — either the delegate type is not exposed, or call-site overhead defeats the purpose. The standard form for filtering an array is the following **3-step temp-array pattern**:\n\n1. Allocate a **temp array** sized to the input (worst case = all elements pass).\n2. Walk the input once, packing matches into the temp array via a `count` cursor.\n3. Allocate the final array of `count` and copy with `Array.Copy`.\n\n### Implementation Index\n\n| Use case | Where to find the pattern |\n|----------|---------------------------|\n| Distance / range filter | [`patterns-core.md` — Get All Players in Range](patterns-core.md#get-all-players-in-range) |\n| Local-player exclusion | [`patterns-core.md` — Get Remote Players](patterns-core.md#get-remote-players) |\n\n### Why \"impractical\" instead of \"blocked\"\n\nWe deliberately avoid claiming `FindAll` is *blocked*. UdonSharp's type-exposure table can change between SDK releases, and a future SDK could surface `Predicate\u003cT>`. The performance argument (manual loops avoid delegate dispatch) holds regardless, so this guidance ages well.\n\n---\n\n\n## See Also\n\n- [patterns-core.md](patterns-core.md) - Initialization, interaction, timer, audio, pickup, animation, UI\n- [patterns-networking.md](patterns-networking.md) - Object pooling, game state, NetworkCallable\n- [patterns-utilities.md](patterns-utilities.md) - Array helpers, event bus, relay communication\n- [networking.md](networking.md) - Network bandwidth throttling and sync optimization\n- [web-loading-advanced.md](web-loading-advanced.md) - Packed resource loading, Base64 texture decode, LRU cache\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":46113,"content_sha256":"cb39628c7b3f1c33c46c32c2c23d2deaf0e1056b7e58559fb0b97b09ae9239b0"},{"filename":"references/patterns-ui.md","content":"# UdonSharp UI/Canvas Patterns\n\nImmobilize guard, avatar-scale-aware UI, FOV-responsive positioning, platform-adaptive layout,\ndynamic player list, scroll input abstraction, lookup-table localization, toggle-animator bridge,\nsettings persistence via PlayerObject, listener-based menu event system, finger-based touch\ninteraction for canvas UI, and modular app architecture with plugin lifecycle.\n\n## 1. Immobilize Guard Pattern\n\nWhen the local player interacts with dropdown menus or scroll views, accidental movement\ncan disrupt the UI experience. The Immobilize Guard locks player locomotion while UI\nis active and automatically releases it when the panel closes.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n/// \u003csummary>\n/// Attach to the root Canvas or panel GameObject.\n/// Enable/disable the GameObject to toggle the guard.\n/// \u003c/summary>\npublic class ImmobilizeGuard : UdonSharpBehaviour\n{\n void OnEnable()\n {\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local != null)\n {\n local.Immobilize(true);\n }\n }\n\n void OnDisable()\n {\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local != null)\n {\n local.Immobilize(false);\n }\n }\n}\n```\n\n> **Key notes:**\n> - Always pair `Immobilize(true)` with a guaranteed `Immobilize(false)` path.\n> - `OnDisable` fires when the GameObject is deactivated *and* when the player leaves the world, so there is no risk of permanent lock.\n> - Combine with a semi-transparent background overlay to visually indicate the locked state.\n\n---\n\n## 2. Avatar-Scale-Aware UI\n\nVRChat avatars vary wildly in scale. A Canvas that looks correct at default height may be\nunreachable for tiny avatars or clip into giant ones. This pattern reads the local player's\nhead tracking height and rescales the Canvas RectTransform proportionally.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class AvatarScaleUI : UdonSharpBehaviour\n{\n [Header(\"Configuration\")]\n [SerializeField] private RectTransform canvasRect;\n\n [Tooltip(\"Reference avatar eye height in meters (default VRChat avatar)\")]\n [SerializeField] private float referenceHeight = 1.65f;\n\n [Tooltip(\"Minimum scale clamp to prevent near-invisible UI\")]\n [SerializeField] private float minScale = 0.3f;\n\n [Tooltip(\"Maximum scale clamp to prevent oversized UI\")]\n [SerializeField] private float maxScale = 3.0f;\n\n private bool _initialized = false;\n\n void OnEnable()\n {\n Initialize();\n ApplyScale();\n }\n\n void Start()\n {\n Initialize();\n ApplyScale();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n if (canvasRect == null)\n {\n canvasRect = GetComponent\u003cRectTransform>();\n }\n }\n\n /// \u003csummary>\n /// Call this when the avatar changes or periodically to keep the UI in sync.\n /// \u003c/summary>\n public void ApplyScale()\n {\n Initialize();\n\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local == null) return;\n if (canvasRect == null) return;\n\n // Head tracking Y position approximates current avatar eye height\n VRCPlayerApi.TrackingData headData =\n local.GetTrackingData(VRCPlayerApi.TrackingDataType.Head);\n float currentHeight = headData.position.y;\n\n // Avoid division by zero for extremely small avatars\n if (currentHeight \u003c 0.01f) currentHeight = 0.01f;\n\n float scaleFactor = currentHeight / referenceHeight;\n scaleFactor = Mathf.Clamp(scaleFactor, minScale, maxScale);\n\n canvasRect.localScale = Vector3.one * scaleFactor;\n }\n}\n```\n\n> **Key notes:**\n> - `GetTrackingData(Head).position.y` returns the world-space Y of the player's head, which correlates with avatar scale.\n> - Clamp the scale factor to avoid UI becoming invisible (tiny avatars) or enormous (giant avatars).\n> - Call `ApplyScale()` from an external trigger (e.g., avatar change event or a periodic timer) since there is no built-in \"avatar changed\" callback in UdonSharp.\n\n---\n\n## 3. FOV-Responsive UI Positioning\n\nWhen players change their camera FOV (e.g., through VRChat camera zoom settings), world-space\nUI panels can drift out of view. This pattern adjusts the Canvas offset using trigonometric\nFOV calculations and hooks `OnVRCCameraSettingsChanged()` for live updates.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class FovResponsiveUI : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private Transform uiAnchor;\n\n [Header(\"Configuration\")]\n [Tooltip(\"Distance from camera to UI panel in meters\")]\n [SerializeField] private float baseDistance = 2.0f;\n\n [Tooltip(\"Reference FOV that the UI was designed for\")]\n [SerializeField] private float referenceFov = 60.0f;\n\n private bool _isVR = false;\n\n void Start()\n {\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local != null)\n {\n _isVR = local.IsUserInVR();\n }\n\n RecalculatePosition();\n }\n\n /// \u003csummary>\n /// Called by VRChat when camera settings (FOV, near/far plane) change.\n /// \u003c/summary>\n public override void OnVRCCameraSettingsChanged()\n {\n RecalculatePosition();\n }\n\n private void RecalculatePosition()\n {\n if (uiAnchor == null) return;\n\n // In VR, FOV is determined by the headset and cannot be read reliably.\n // Apply distance adjustment only on desktop.\n if (_isVR) return;\n\n float currentFov = 60.0f;\n Camera screenCam = VRCCameraSettings.ScreenCamera;\n if (screenCam != null)\n {\n currentFov = screenCam.fieldOfView;\n }\n\n // Compute viewport-relative scale factor\n float refTan = Mathf.Tan((referenceFov / 2.0f) * Mathf.Deg2Rad);\n float curTan = Mathf.Tan((currentFov / 2.0f) * Mathf.Deg2Rad);\n\n // Avoid division by zero\n if (curTan \u003c 0.001f) curTan = 0.001f;\n\n float distanceFactor = refTan / curTan;\n float adjustedDistance = baseDistance * distanceFactor;\n\n uiAnchor.localPosition = new Vector3(\n uiAnchor.localPosition.x,\n uiAnchor.localPosition.y,\n adjustedDistance\n );\n }\n}\n```\n\n> **Key notes:**\n> - `OnVRCCameraSettingsChanged()` fires whenever the player adjusts camera zoom or near/far clip planes.\n> - VR headsets have a fixed FOV; this adjustment is only meaningful on desktop.\n> - The tangent ratio preserves the apparent angular size of the UI panel regardless of FOV.\n\n---\n\n## 4. Platform-Adaptive UI Layout\n\nVRChat worlds run on PC (Desktop and VR) and Quest (Android). Screen aspect ratios,\ninput methods, and performance budgets differ significantly. This pattern branches\nUI layout at runtime based on platform and input mode.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class PlatformAdaptiveUI : UdonSharpBehaviour\n{\n [Header(\"Platform-Specific Panels\")]\n [SerializeField] private GameObject pcPanel;\n [SerializeField] private GameObject questPanel;\n\n [Header(\"Input-Mode Panels\")]\n [SerializeField] private GameObject vrControlsHint;\n [SerializeField] private GameObject desktopControlsHint;\n\n [Header(\"Aspect-Dependent Elements\")]\n [SerializeField] private GameObject landscapeSidebar;\n [SerializeField] private GameObject portraitBottomBar;\n\n void Start()\n {\n ApplyPlatformLayout();\n ApplyInputModeLayout();\n ApplyAspectLayout();\n }\n\n private void ApplyPlatformLayout()\n {\n // Compile-time platform check for Quest-specific UI\n #if UNITY_ANDROID || UNITY_IOS\n if (pcPanel != null) pcPanel.SetActive(false);\n if (questPanel != null) questPanel.SetActive(true);\n #else\n if (pcPanel != null) pcPanel.SetActive(true);\n if (questPanel != null) questPanel.SetActive(false);\n #endif\n }\n\n private void ApplyInputModeLayout()\n {\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local == null) return;\n\n bool isVR = local.IsUserInVR();\n\n if (vrControlsHint != null) vrControlsHint.SetActive(isVR);\n if (desktopControlsHint != null) desktopControlsHint.SetActive(!isVR);\n }\n\n private void ApplyAspectLayout()\n {\n Camera screenCam = VRCCameraSettings.ScreenCamera;\n if (screenCam == null) return;\n\n float aspect = screenCam.aspect;\n bool isLandscape = aspect >= 1.0f;\n\n if (landscapeSidebar != null) landscapeSidebar.SetActive(isLandscape);\n if (portraitBottomBar != null) portraitBottomBar.SetActive(!isLandscape);\n }\n}\n```\n\n> **Key notes:**\n> - `#if UNITY_ANDROID || UNITY_IOS` is evaluated at **compile time**, producing separate builds for PC and Quest with no runtime overhead.\n> - `IsUserInVR()` detects VR headsets at runtime — a PC user can be in either Desktop or VR mode.\n> - `VRCCameraSettings.ScreenCamera.aspect` returns the current viewport aspect ratio, useful for detecting portrait-mode streaming or unusual resolutions.\n\n---\n\n## 5. Dynamic Player List UI\n\nMany worlds need a live player list for teleportation, team assignment, or voting.\nThis pattern enumerates all players, creates a button for each, refreshes on join/leave,\nand dispatches click callbacks using button name parsing.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\nusing TMPro;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class DynamicPlayerList : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private Transform listParent;\n [SerializeField] private GameObject buttonTemplate;\n\n [Header(\"Configuration\")]\n [Tooltip(\"Prefix for button names used to identify player ID\")]\n [SerializeField] private string buttonPrefix = \"PlayerBtn_\";\n\n private GameObject[] _activeButtons = new GameObject[0];\n\n void Start()\n {\n if (buttonTemplate != null)\n {\n buttonTemplate.SetActive(false);\n }\n RefreshPlayerList();\n }\n\n public override void OnPlayerJoined(VRCPlayerApi player)\n {\n RefreshPlayerList();\n }\n\n public override void OnPlayerLeft(VRCPlayerApi player)\n {\n RefreshPlayerList();\n }\n\n public void RefreshPlayerList()\n {\n // Clean up existing buttons\n for (int i = 0; i \u003c _activeButtons.Length; i++)\n {\n if (_activeButtons[i] != null)\n {\n Destroy(_activeButtons[i]);\n }\n }\n\n int playerCount = VRCPlayerApi.GetPlayerCount();\n VRCPlayerApi[] players = new VRCPlayerApi[playerCount];\n VRCPlayerApi.GetPlayers(players);\n\n _activeButtons = new GameObject[playerCount];\n\n for (int i = 0; i \u003c playerCount; i++)\n {\n if (players[i] == null) continue;\n if (!players[i].IsValid()) continue;\n\n GameObject btn = Object.Instantiate(buttonTemplate);\n btn.transform.SetParent(listParent, false);\n btn.SetActive(true);\n\n // Encode player ID in button name for callback dispatch\n btn.name = buttonPrefix + players[i].playerId.ToString();\n\n // Set display text\n TextMeshProUGUI label = btn.GetComponentInChildren\u003cTextMeshProUGUI>();\n if (label != null)\n {\n label.text = players[i].displayName;\n }\n\n _activeButtons[i] = btn;\n }\n }\n\n /// \u003csummary>\n /// Called by each button's OnClick UnityEvent. The button passes\n /// its own GameObject name so we can extract the player ID.\n /// \u003c/summary>\n public void OnPlayerButtonClicked(string buttonName)\n {\n if (buttonName == null) return;\n\n // Parse player ID from button name\n string idStr = buttonName.Replace(buttonPrefix, \"\");\n int playerId = -1;\n\n // Manual int parse (no int.TryParse in older Udon runtimes)\n bool valid = true;\n int result = 0;\n for (int i = 0; i \u003c idStr.Length; i++)\n {\n char c = idStr[i];\n if (c \u003c '0' || c > '9')\n {\n valid = false;\n break;\n }\n result = result * 10 + (c - '0');\n }\n if (valid && idStr.Length > 0)\n {\n playerId = result;\n }\n\n if (playerId \u003c 0) return;\n\n VRCPlayerApi targetPlayer = VRCPlayerApi.GetPlayerById(playerId);\n if (targetPlayer == null) return;\n if (!targetPlayer.IsValid()) return;\n\n // Example action: teleport local player to target player\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local == null) return;\n\n Vector3 targetPos = targetPlayer.GetPosition();\n Quaternion targetRot = targetPlayer.GetRotation();\n local.TeleportTo(targetPos, targetRot);\n }\n}\n```\n\n> **Key notes:**\n> - `Object.Instantiate()` works in UdonSharp for scene objects. The template button must exist in the scene (not an asset prefab).\n> - Player IDs are encoded in the button `name` field and parsed back on click — this avoids needing per-button UdonBehaviours.\n> - Always check `IsValid()` before using a `VRCPlayerApi` reference, as players may leave between list refresh and click.\n> - For large player counts (80+), consider recycling buttons instead of Destroy/Instantiate every refresh.\n\n---\n\n## 6. ScrollRect VR/Desktop Input Abstraction\n\nUnity's built-in `ScrollRect` responds to mouse scroll on desktop but has no native support\nfor VR controller thumbstick scrolling. This pattern polls VR input axes and desktop mouse\nscroll, then applies the appropriate scroll delta each frame.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class ScrollInputAdapter : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private ScrollRect scrollRect;\n\n [Header(\"Configuration\")]\n [SerializeField] private float vrScrollSpeed = 0.5f;\n [SerializeField] private float desktopScrollSpeed = 0.1f;\n\n [Tooltip(\"Dead zone for VR thumbstick to prevent drift\")]\n [SerializeField] private float vrDeadZone = 0.15f;\n\n private bool _isVR = false;\n\n void Start()\n {\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local != null)\n {\n _isVR = local.IsUserInVR();\n }\n }\n\n void Update()\n {\n if (scrollRect == null) return;\n\n float scrollDelta = 0.0f;\n\n if (_isVR)\n {\n scrollDelta = GetVRScrollDelta();\n }\n else\n {\n scrollDelta = GetDesktopScrollDelta();\n }\n\n if (Mathf.Abs(scrollDelta) > 0.001f)\n {\n Vector2 pos = scrollRect.normalizedPosition;\n pos.y = Mathf.Clamp01(pos.y + scrollDelta);\n scrollRect.normalizedPosition = pos;\n }\n }\n\n private float GetVRScrollDelta()\n {\n // InputLookVertical maps to the right-hand thumbstick Y axis\n float axis = Input.GetAxis(\"Oculus_CrossPlatform_SecondaryThumbstickVertical\");\n\n // Apply dead zone\n if (Mathf.Abs(axis) \u003c vrDeadZone) return 0.0f;\n\n return axis * vrScrollSpeed * Time.deltaTime;\n }\n\n private float GetDesktopScrollDelta()\n {\n float mouseScroll = Input.GetAxis(\"Mouse ScrollWheel\");\n return mouseScroll * desktopScrollSpeed;\n }\n}\n```\n\n> **Key notes:**\n> - VR thumbstick axes vary by headset. `Oculus_CrossPlatform_SecondaryThumbstickVertical` covers most SteamVR and Oculus setups in VRChat.\n> - Apply a dead zone to prevent unintentional drift from loose thumbsticks.\n> - Desktop scroll uses `Mouse ScrollWheel`, which returns small float values per frame; adjust `desktopScrollSpeed` to taste.\n> - This pattern runs in `Update()` — see the Update Handler pattern in `patterns-performance.md` for centralized update management in complex worlds.\n\n---\n\n## 7. Lookup-Table Localization\n\nFor worlds that support multiple languages, a lookup-table approach using parallel arrays\nprovides simple, efficient localization without external libraries. Font sizes can be\nadjusted per language (CJK characters often need different sizes than Latin text).\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing TMPro;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class LookupLocalization : UdonSharpBehaviour\n{\n [Header(\"UI Elements\")]\n [SerializeField] private TextMeshProUGUI[] textElements;\n\n [Header(\"Japanese Localization\")]\n [SerializeField] private string[] textsJa;\n [SerializeField] private float[] fontSizesJa;\n\n [Header(\"English Localization\")]\n [SerializeField] private string[] textsEn;\n [SerializeField] private float[] fontSizesEn;\n\n private int _currentLanguage = 0; // 0 = ja, 1 = en\n\n public int CurrentLanguage\n {\n get => _currentLanguage;\n set\n {\n _currentLanguage = value;\n RefreshAllText();\n }\n }\n\n void Start()\n {\n DetectLanguage();\n RefreshAllText();\n }\n\n private void DetectLanguage()\n {\n string lang = VRCPlayerApi.GetCurrentLanguage();\n\n if (lang == \"ja\" || lang == \"ja-JP\")\n {\n _currentLanguage = 0;\n }\n else\n {\n // Default to English for all non-Japanese languages\n _currentLanguage = 1;\n }\n }\n\n private void RefreshAllText()\n {\n if (textElements == null) return;\n\n string[] texts = _currentLanguage == 0 ? textsJa : textsEn;\n float[] sizes = _currentLanguage == 0 ? fontSizesJa : fontSizesEn;\n\n for (int i = 0; i \u003c textElements.Length; i++)\n {\n if (textElements[i] == null) continue;\n\n if (texts != null && i \u003c texts.Length)\n {\n textElements[i].text = texts[i];\n }\n\n if (sizes != null && i \u003c sizes.Length && sizes[i] > 0)\n {\n textElements[i].fontSize = sizes[i];\n }\n }\n }\n\n /// \u003csummary>\n /// Called from a language toggle button to switch manually.\n /// \u003c/summary>\n public void ToggleLanguage()\n {\n CurrentLanguage = _currentLanguage == 0 ? 1 : 0;\n }\n}\n```\n\n> **Key notes:**\n> - `VRCPlayerApi.GetCurrentLanguage()` returns the player's VRChat client language (e.g., `\"ja\"`, `\"en\"`, `\"ko\"`).\n> - Parallel arrays must have matching indices: `textsJa[0]` and `textsEn[0]` correspond to `textElements[0]`.\n> - CJK characters are wider and taller than Latin — use `fontSizesJa` / `fontSizesEn` to set per-language sizes for readability.\n> - To add more languages, extend the pattern with additional parallel arrays and a numeric language index.\n\n---\n\n## 8. Toggle Switch with Animator Bridge\n\nBridging Unity UI `Toggle` components to `Animator` parameters allows visual state changes\n(door open/close, light on/off) driven directly from UI toggles. This pattern also manages\ntoggle color states for visual feedback.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class ToggleAnimatorBridge : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private Toggle uiToggle;\n [SerializeField] private Animator targetAnimator;\n [SerializeField] private Image handleImage;\n\n [Header(\"Animator\")]\n [SerializeField] private string animatorBoolName = \"IsActive\";\n\n [Header(\"Colors\")]\n [SerializeField] private Color onColor = new Color(0.2f, 0.8f, 0.2f, 1.0f);\n [SerializeField] private Color offColor = new Color(0.5f, 0.5f, 0.5f, 1.0f);\n [SerializeField] private Color disabledColor = new Color(0.3f, 0.3f, 0.3f, 0.5f);\n\n private bool _initialized = false;\n\n void Start()\n {\n Initialize();\n ApplyVisualState();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n }\n\n /// \u003csummary>\n /// Hook this to Toggle.onValueChanged in the Inspector.\n /// \u003c/summary>\n public void OnToggleValueChanged()\n {\n Initialize();\n\n if (uiToggle == null) return;\n\n bool isOn = uiToggle.isOn;\n\n // Bridge to Animator\n if (targetAnimator != null)\n {\n targetAnimator.SetBool(animatorBoolName, isOn);\n }\n\n ApplyVisualState();\n }\n\n private void ApplyVisualState()\n {\n if (handleImage == null) return;\n\n if (uiToggle == null || !uiToggle.interactable)\n {\n handleImage.color = disabledColor;\n return;\n }\n\n handleImage.color = uiToggle.isOn ? onColor : offColor;\n }\n\n /// \u003csummary>\n /// Set the toggle state programmatically without triggering onValueChanged.\n /// \u003c/summary>\n public void SetToggleWithoutNotify(bool value)\n {\n if (uiToggle == null) return;\n uiToggle.SetIsOnWithoutNotify(value);\n ApplyVisualState();\n }\n}\n```\n\n> **Key notes:**\n> - Wire `OnToggleValueChanged()` to the Toggle's `onValueChanged` event in the Unity Inspector.\n> - Use `SetIsOnWithoutNotify()` when restoring state from persistence or network sync to avoid re-triggering the callback loop.\n> - The `ColorBlock` on the Toggle's `Graphic` handles hover/pressed states automatically; this pattern only manages the custom handle color.\n\n---\n\n## 9. UI Settings Persistence via PlayerObject\n\nPlayer preferences (volume, language, UI scale) should persist across rejoin.\nThis pattern uses `PlayerObject` (SDK 3.7.4+) with a sentinel value pattern:\n`-1` means \"use the world default.\"\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class UISettingsStore : UdonSharpBehaviour\n{\n [Header(\"Persisted Settings\")]\n [UdonSynced] public int savedVolume = -1; // -1 = use default\n [UdonSynced] public int savedLanguage = -1; // -1 = auto-detect\n [UdonSynced] public int savedUIScale = -1; // -1 = use default\n\n [Header(\"Defaults\")]\n [SerializeField] private int defaultVolume = 80;\n [SerializeField] private int defaultLanguage = 0;\n [SerializeField] private int defaultUIScale = 100;\n\n [Header(\"UI References\")]\n [SerializeField] private Slider volumeSlider;\n\n private bool _initialized = false;\n private VRCPlayerApi _localPlayer;\n\n void Start()\n {\n _localPlayer = Networking.LocalPlayer;\n }\n\n /// \u003csummary>\n /// Called by VRChat when this player's PlayerObject data is restored.\n /// \u003c/summary>\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (player == null) return;\n if (!player.isLocal) return;\n\n ApplySettings();\n }\n\n private void ApplySettings()\n {\n int vol = savedVolume >= 0 ? savedVolume : defaultVolume;\n int lang = savedLanguage >= 0 ? savedLanguage : defaultLanguage;\n int scale = savedUIScale >= 0 ? savedUIScale : defaultUIScale;\n\n // Apply volume\n if (volumeSlider != null)\n {\n volumeSlider.SetValueWithoutNotify(vol / 100.0f);\n }\n\n // Apply other settings via SendCustomEvent to external behaviours\n // ...\n }\n\n /// \u003csummary>\n /// Call this when the player changes volume via the UI slider.\n /// \u003c/summary>\n public void OnVolumeChanged()\n {\n if (volumeSlider == null) return;\n\n Networking.SetOwner(_localPlayer, gameObject);\n savedVolume = Mathf.RoundToInt(volumeSlider.value * 100.0f);\n RequestSerialization();\n }\n\n /// \u003csummary>\n /// Look up another player's settings store from their PlayerObjects.\n /// \u003c/summary>\n public static UISettingsStore FindForPlayer(VRCPlayerApi player)\n {\n // Returns the UISettingsStore component from the given player's PlayerObjects\n return Networking.FindComponentInPlayerObjects\u003cUISettingsStore>(player);\n }\n\n /// \u003csummary>\n /// Reset all settings to sentinel values (will use defaults on next load).\n /// \u003c/summary>\n public void ResetToDefaults()\n {\n Networking.SetOwner(_localPlayer, gameObject);\n savedVolume = -1;\n savedLanguage = -1;\n savedUIScale = -1;\n RequestSerialization();\n ApplySettings();\n }\n}\n```\n\n> **Key notes:**\n> - PlayerObject instances are created per-player by VRChat. The component on the PlayerObject prefab becomes a per-player data store.\n> - The `-1` sentinel pattern lets you distinguish \"player never set this\" from \"player explicitly chose value 0.\"\n> - `OnPlayerRestored()` fires after the player's persistent data is loaded. Apply saved state here, not in `Start()`.\n> - `Networking.FindComponentInPlayerObjects\u003cT>(player)` retrieves another player's PlayerObject component, useful for displaying their preferences.\n\n---\n\n## 10. Listener-Based Menu Event System\n\nA generic event system for menu open/close (or any UI state change) that notifies multiple\nsubscribers. Since UdonSharp lacks `List\u003cT>` and delegates, this pattern uses a fixed-size\narray with manual resize for listener management.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class MenuEventBroadcaster : UdonSharpBehaviour\n{\n [Header(\"Events\")]\n [SerializeField] private string openEventName = \"OnMenuOpened\";\n [SerializeField] private string closeEventName = \"OnMenuClosed\";\n\n private UdonSharpBehaviour[] _listeners = new UdonSharpBehaviour[0];\n private int _listenerCount = 0;\n\n private bool _isOpen = false;\n\n /// \u003csummary>\n /// Register a listener to receive menu events.\n /// \u003c/summary>\n public void AddListener(UdonSharpBehaviour listener)\n {\n if (listener == null) return;\n\n // Check for duplicate\n for (int i = 0; i \u003c _listenerCount; i++)\n {\n if (_listeners[i] == listener) return;\n }\n\n // Resize array if full\n if (_listenerCount >= _listeners.Length)\n {\n int newSize = _listeners.Length == 0 ? 4 : _listeners.Length * 2;\n UdonSharpBehaviour[] newArray = new UdonSharpBehaviour[newSize];\n for (int i = 0; i \u003c _listenerCount; i++)\n {\n newArray[i] = _listeners[i];\n }\n _listeners = newArray;\n }\n\n _listeners[_listenerCount] = listener;\n _listenerCount++;\n }\n\n /// \u003csummary>\n /// Remove a listener from the notification list.\n /// \u003c/summary>\n public void RemoveListener(UdonSharpBehaviour listener)\n {\n if (listener == null) return;\n\n for (int i = 0; i \u003c _listenerCount; i++)\n {\n if (_listeners[i] == listener)\n {\n // Shift remaining elements\n for (int j = i; j \u003c _listenerCount - 1; j++)\n {\n _listeners[j] = _listeners[j + 1];\n }\n _listeners[_listenerCount - 1] = null;\n _listenerCount--;\n return;\n }\n }\n }\n\n /// \u003csummary>\n /// Open the menu and notify all listeners.\n /// \u003c/summary>\n public void OpenMenu()\n {\n if (_isOpen) return;\n _isOpen = true;\n Broadcast(openEventName);\n }\n\n /// \u003csummary>\n /// Close the menu and notify all listeners.\n /// \u003c/summary>\n public void CloseMenu()\n {\n if (!_isOpen) return;\n _isOpen = false;\n Broadcast(closeEventName);\n }\n\n /// \u003csummary>\n /// Toggle the menu state.\n /// \u003c/summary>\n public void ToggleMenu()\n {\n if (_isOpen)\n {\n CloseMenu();\n }\n else\n {\n OpenMenu();\n }\n }\n\n private void Broadcast(string eventName)\n {\n for (int i = 0; i \u003c _listenerCount; i++)\n {\n if (_listeners[i] != null)\n {\n _listeners[i].SendCustomEvent(eventName);\n }\n }\n }\n}\n```\n\n> **Key notes:**\n> - UdonSharp does not support `List\u003cT>`, so the array is resized manually with a doubling strategy (similar to `ArrayList`).\n> - `SendCustomEvent()` calls a `public void` method by name on the target behaviour. Listeners must have public methods matching `openEventName` / `closeEventName`.\n> - Duplicate registration is prevented by a linear scan before adding.\n> - For worlds with many listeners (16+), consider pre-allocating the array size in the Inspector to avoid repeated resizing.\n> - See `patterns-utilities.md` for a more general-purpose EventBus implementation.\n\n---\n\n## 11. Finger-Based Touch Interaction for Canvas UI\n\nVR users can interact with world-space Canvas UI by physically touching buttons with their\nfingertips. This pattern tracks index finger bone positions, extrapolates the fingertip,\ndetects push events against the canvas plane, fires pointer events (Down/Drag/Up/Click),\nprovides haptic feedback, and falls back to raycast-based interaction for desktop users.\n\nThe system consists of two behaviours: `FingerPointer` tracks a single hand's finger state,\nand `FingerTouchCanvas` manages the canvas, pointer events, and desktop fallback.\n\n### FingerPointer (per-hand finger tracker)\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n/// \u003csummary>\n/// Tracks a single hand's index finger position and provides the extrapolated\n/// fingertip world position. Attach one instance per hand (left and right).\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.None)]\npublic class FingerPointer : UdonSharpBehaviour\n{\n [Header(\"Hand Configuration\")]\n [Tooltip(\"Which hand this pointer tracks\")]\n [SerializeField] private bool isLeftHand = true;\n\n [Header(\"Fingertip Extrapolation\")]\n [Tooltip(\"Lerp factor beyond the distal bone to estimate fingertip (1.0 = distal, 1.8 = typical fingertip)\")]\n [SerializeField] private float tipExtrapolation = 1.8f;\n\n [Header(\"Haptic Feedback\")]\n [SerializeField] private float hapticDuration = 0.05f;\n [SerializeField] private float hapticAmplitude = 0.5f;\n [SerializeField] private float hapticFrequency = 150.0f;\n\n private VRCPlayerApi _localPlayer;\n private HumanBodyBones _intermediateBone;\n private HumanBodyBones _distalBone;\n private bool _isVR = false;\n private bool _initialized = false;\n\n /// \u003csummary>\n /// Current extrapolated fingertip position in world space.\n /// \u003c/summary>\n [System.NonSerialized] public Vector3 FingertipPosition;\n\n /// \u003csummary>\n /// Whether this pointer has valid tracking data this frame.\n /// \u003c/summary>\n [System.NonSerialized] public bool IsTracking;\n\n void Start()\n {\n Initialize();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n _localPlayer = Networking.LocalPlayer;\n if (!Utilities.IsValid(_localPlayer)) return;\n\n _isVR = _localPlayer.IsUserInVR();\n\n if (isLeftHand)\n {\n _intermediateBone = HumanBodyBones.LeftIndexIntermediate;\n _distalBone = HumanBodyBones.LeftIndexDistal;\n }\n else\n {\n _intermediateBone = HumanBodyBones.RightIndexIntermediate;\n _distalBone = HumanBodyBones.RightIndexDistal;\n }\n }\n\n /// \u003csummary>\n /// Call once per frame from the canvas manager to update finger position.\n /// \u003c/summary>\n public void UpdateTracking()\n {\n IsTracking = false;\n\n if (!_isVR) return;\n if (!Utilities.IsValid(_localPlayer)) return;\n\n Vector3 intermediate = _localPlayer.GetBonePosition(_intermediateBone);\n Vector3 distal = _localPlayer.GetBonePosition(_distalBone);\n\n // Zero vectors indicate missing tracking data\n if (intermediate == Vector3.zero && distal == Vector3.zero) return;\n\n FingertipPosition = Vector3.LerpUnclamped(intermediate, distal, tipExtrapolation);\n IsTracking = true;\n }\n\n /// \u003csummary>\n /// Trigger haptic feedback on this hand.\n /// \u003c/summary>\n public void PlayHaptic()\n {\n if (!Utilities.IsValid(_localPlayer)) return;\n if (!_isVR) return;\n\n VRC_Pickup.PickupHand hand = isLeftHand\n ? VRC_Pickup.PickupHand.Left\n : VRC_Pickup.PickupHand.Right;\n\n _localPlayer.PlayHapticEventInHand(hand, hapticDuration, hapticAmplitude, hapticFrequency);\n }\n\n /// \u003csummary>\n /// Returns true if this pointer is configured for the left hand.\n /// \u003c/summary>\n public bool GetIsLeftHand()\n {\n return isLeftHand;\n }\n\n /// \u003csummary>\n /// Returns true if the local player is in VR.\n /// \u003c/summary>\n public bool GetIsVR()\n {\n return _isVR;\n }\n}\n```\n\n### FingerTouchCanvas (canvas touch detection and event dispatch)\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\nusing VRC.SDKBase;\n\n/// \u003csummary>\n/// Detects finger touch and desktop raycast interactions against a world-space Canvas.\n/// Fires pointer events (Down, BeginDrag, Drag, EndDrag, Up, Click) on UdonSharpBehaviour listeners.\n/// The Canvas must be in World Space render mode.\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.None)]\npublic class FingerTouchCanvas : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private RectTransform canvasRect;\n [SerializeField] private FingerPointer leftPointer;\n [SerializeField] private FingerPointer rightPointer;\n\n [Header(\"Touch Detection\")]\n [Tooltip(\"Maximum distance in local Z from canvas surface to register a touch\")]\n [SerializeField] private float pushDistanceLimit = 0.05f;\n\n [Tooltip(\"Minimum XY movement in local space to trigger a drag event\")]\n [SerializeField] private float dragThreshold = 5.0f;\n\n [Header(\"Canvas Push Effect\")]\n [Tooltip(\"Optional transform to push toward finger on press\")]\n [SerializeField] private Transform pushTarget;\n\n [Tooltip(\"Maximum push offset in local Z\")]\n [SerializeField] private float maxPushOffset = 0.01f;\n\n [Header(\"Desktop Fallback\")]\n [Tooltip(\"Maximum raycast distance for desktop interaction\")]\n [SerializeField] private float desktopRayDistance = 5.0f;\n\n [Tooltip(\"Layer mask for desktop raycast\")]\n [SerializeField] private LayerMask desktopRayMask = ~0;\n\n [Header(\"Event Listeners\")]\n [Tooltip(\"UdonBehaviours that receive OnPointerDown/OnPointerBeginDrag/OnPointerDrag/OnPointerEndDrag/OnPointerUp/OnPointerClick events\")]\n [SerializeField] private UdonSharpBehaviour[] eventListeners = new UdonSharpBehaviour[0];\n\n /// \u003csummary>\n /// The pointer index (0 = left, 1 = right, 2 = desktop) that last triggered an event.\n /// Listeners can read this to determine which pointer fired the event.\n /// \u003c/summary>\n [HideInInspector] public int lastPointerIndex;\n\n // Per-pointer state (index 0 = left, 1 = right, 2 = desktop)\n private bool[] _isPressed = new bool[3];\n private Vector2[] _pressStartLocalXY = new Vector2[3];\n private bool[] _isDragging = new bool[3];\n\n private VRCPlayerApi _localPlayer;\n private bool _isVR = false;\n private bool _initialized = false;\n private Vector3 _pushTargetBasePos;\n\n void Start()\n {\n Initialize();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n _localPlayer = Networking.LocalPlayer;\n if (!Utilities.IsValid(_localPlayer)) return;\n\n _isVR = _localPlayer.IsUserInVR();\n\n if (pushTarget != null)\n {\n _pushTargetBasePos = pushTarget.localPosition;\n }\n }\n\n void Update()\n {\n if (canvasRect == null) return;\n\n if (_isVR)\n {\n if (leftPointer != null)\n {\n leftPointer.UpdateTracking();\n ProcessFingerPointer(leftPointer, 0);\n }\n if (rightPointer != null)\n {\n rightPointer.UpdateTracking();\n ProcessFingerPointer(rightPointer, 1);\n }\n }\n else\n {\n ProcessDesktopRaycast();\n }\n\n UpdatePushEffect();\n }\n\n private void ProcessFingerPointer(FingerPointer pointer, int pointerIndex)\n {\n if (!pointer.IsTracking)\n {\n if (_isPressed[pointerIndex])\n {\n if (_isDragging[pointerIndex])\n {\n FirePointerEndDrag(pointerIndex);\n }\n FirePointerUp(pointerIndex);\n _isPressed[pointerIndex] = false;\n _isDragging[pointerIndex] = false;\n }\n return;\n }\n\n // Convert fingertip world position to canvas local space\n Vector3 localPos = canvasRect.InverseTransformPoint(pointer.FingertipPosition);\n\n // Check XY bounds against canvas rect\n bool inBoundsXY = canvasRect.rect.Contains(new Vector2(localPos.x, localPos.y));\n\n // Check Z depth: localPos.z \u003c 0 means finger is in front of canvas,\n // and we treat crossing Z=0 as the touch plane\n bool inDepth = localPos.z >= -pushDistanceLimit && localPos.z \u003c= pushDistanceLimit;\n bool isTouching = inBoundsXY && inDepth && localPos.z \u003c= 0.0f;\n\n if (isTouching && !_isPressed[pointerIndex])\n {\n // Pointer Down\n _isPressed[pointerIndex] = true;\n _isDragging[pointerIndex] = false;\n _pressStartLocalXY[pointerIndex] = new Vector2(localPos.x, localPos.y);\n pointer.PlayHaptic();\n FirePointerDown(pointerIndex);\n }\n else if (!isTouching && _isPressed[pointerIndex])\n {\n // Pointer Up\n if (_isDragging[pointerIndex])\n {\n FirePointerEndDrag(pointerIndex);\n }\n if (!_isDragging[pointerIndex] && inBoundsXY)\n {\n // Click: released on canvas without significant drag\n FirePointerClick(pointerIndex);\n }\n FirePointerUp(pointerIndex);\n _isPressed[pointerIndex] = false;\n _isDragging[pointerIndex] = false;\n }\n else if (isTouching && _isPressed[pointerIndex])\n {\n // Check for drag\n Vector2 currentXY = new Vector2(localPos.x, localPos.y);\n float dist = Vector2.Distance(currentXY, _pressStartLocalXY[pointerIndex]);\n if (dist >= dragThreshold)\n {\n if (!_isDragging[pointerIndex])\n {\n FirePointerBeginDrag(pointerIndex);\n }\n _isDragging[pointerIndex] = true;\n FirePointerDrag(pointerIndex);\n }\n }\n }\n\n private void ProcessDesktopRaycast()\n {\n if (!Utilities.IsValid(_localPlayer)) return;\n\n VRCPlayerApi.TrackingData headData =\n _localPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head);\n Vector3 origin = headData.position;\n Vector3 forward = headData.rotation * Vector3.forward;\n\n RaycastHit hit;\n bool didHit = Physics.Raycast(origin, forward, out hit, desktopRayDistance, desktopRayMask);\n\n if (!didHit || hit.collider == null)\n {\n if (_isPressed[2])\n {\n if (_isDragging[2])\n {\n FirePointerEndDrag(2);\n }\n FirePointerUp(2);\n _isPressed[2] = false;\n _isDragging[2] = false;\n }\n return;\n }\n\n // Check if the hit object is part of this canvas hierarchy\n bool isCanvasHit = false;\n Transform hitTransform = hit.collider.transform;\n Transform canvasTransform = canvasRect.transform;\n Transform check = hitTransform;\n for (int i = 0; i \u003c 20; i++)\n {\n if (check == null) break;\n if (check == canvasTransform)\n {\n isCanvasHit = true;\n break;\n }\n check = check.parent;\n }\n\n if (!isCanvasHit)\n {\n if (_isPressed[2])\n {\n if (_isDragging[2])\n {\n FirePointerEndDrag(2);\n }\n FirePointerUp(2);\n _isPressed[2] = false;\n _isDragging[2] = false;\n }\n return;\n }\n\n // Desktop uses InputUse (left mouse / VR trigger) via Input\n bool usePressed = Input.GetMouseButton(0);\n\n Vector3 localPos = canvasRect.InverseTransformPoint(hit.point);\n Vector2 localXY = new Vector2(localPos.x, localPos.y);\n\n if (usePressed && !_isPressed[2])\n {\n _isPressed[2] = true;\n _isDragging[2] = false;\n _pressStartLocalXY[2] = localXY;\n FirePointerDown(2);\n }\n else if (!usePressed && _isPressed[2])\n {\n if (_isDragging[2])\n {\n FirePointerEndDrag(2);\n }\n if (!_isDragging[2])\n {\n FirePointerClick(2);\n }\n FirePointerUp(2);\n _isPressed[2] = false;\n _isDragging[2] = false;\n }\n else if (usePressed && _isPressed[2])\n {\n float dist = Vector2.Distance(localXY, _pressStartLocalXY[2]);\n if (dist >= dragThreshold)\n {\n if (!_isDragging[2])\n {\n FirePointerBeginDrag(2);\n }\n _isDragging[2] = true;\n FirePointerDrag(2);\n }\n }\n }\n\n private void UpdatePushEffect()\n {\n if (pushTarget == null) return;\n\n bool anyPressed = _isPressed[0] || _isPressed[1] || _isPressed[2];\n Vector3 targetPos = _pushTargetBasePos;\n\n if (anyPressed)\n {\n targetPos = _pushTargetBasePos + new Vector3(0.0f, 0.0f, maxPushOffset);\n }\n\n pushTarget.localPosition = Vector3.Lerp(\n pushTarget.localPosition,\n targetPos,\n Time.deltaTime * 10.0f\n );\n }\n\n private void FirePointerDown(int pointerIndex)\n {\n lastPointerIndex = pointerIndex;\n BroadcastEvent(\"OnPointerDown\");\n }\n\n private void FirePointerUp(int pointerIndex)\n {\n lastPointerIndex = pointerIndex;\n BroadcastEvent(\"OnPointerUp\");\n }\n\n private void FirePointerClick(int pointerIndex)\n {\n lastPointerIndex = pointerIndex;\n BroadcastEvent(\"OnPointerClick\");\n }\n\n private void FirePointerDrag(int pointerIndex)\n {\n lastPointerIndex = pointerIndex;\n BroadcastEvent(\"OnPointerDrag\");\n }\n\n private void FirePointerBeginDrag(int pointerIndex)\n {\n lastPointerIndex = pointerIndex;\n BroadcastEvent(\"OnPointerBeginDrag\");\n }\n\n private void FirePointerEndDrag(int pointerIndex)\n {\n lastPointerIndex = pointerIndex;\n BroadcastEvent(\"OnPointerEndDrag\");\n }\n\n private void BroadcastEvent(string eventName)\n {\n if (eventListeners == null) return;\n\n for (int i = 0; i \u003c eventListeners.Length; i++)\n {\n if (Utilities.IsValid(eventListeners[i]))\n {\n eventListeners[i].SendCustomEvent(eventName);\n }\n }\n }\n}\n```\n\n**When to use:**\n- Building interactive panels (keyboards, control panels, menus) that VR players touch with their fingers.\n- Creating immersive UI that responds to physical hand presence rather than laser pointers.\n- Tablet or kiosk-style in-world devices where direct touch feels natural.\n\n> **Key notes:**\n> - `GetBonePosition()` returns `Vector3.zero` when tracking data is unavailable (e.g., on desktop or when the avatar lacks the bone). Always check for zero vectors before using the result.\n> - `LerpUnclamped` with a factor of ~1.8 extends the segment from intermediate to distal bone to approximate the fingertip, since VRChat does not expose a dedicated fingertip bone.\n> - `InverseTransformPoint` converts from world space to the canvas's local coordinate system, where `rect.Contains()` checks XY bounds and the Z axis represents push depth.\n> - Haptic feedback via `PlayHapticEventInHand()` fires only for the local player and only in VR.\n> - The desktop fallback uses `Physics.Raycast` from head tracking data and `Input.GetMouseButton(0)` for click detection. Ensure the canvas or its children have colliders on the correct layer.\n> - The push effect lerps the `pushTarget` transform forward when any pointer is pressed, giving tactile visual feedback.\n\n---\n\n## 12. Modular App Architecture (Plugin Lifecycle)\n\nWhen building a device with multiple screens or applications (e.g., an in-world tablet,\ncontrol panel, or information kiosk), a modular app architecture keeps each feature isolated\nwhile a central manager handles app switching, transitions, and event forwarding.\n\nEach app extends `AppModule` and receives lifecycle callbacks. The `AppManager` discovers\napps at startup, manages CanvasGroup-based transitions, syncs the active app across the\nnetwork, and forwards pickup/interaction events to the current app.\n\n### AppModule (base class for each app screen)\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Base class for modular app screens. Subclass this for each app\n/// and override the lifecycle hooks as needed. Requires a CanvasGroup\n/// on the same GameObject for transition alpha control.\n/// \u003c/summary>\n[RequireComponent(typeof(CanvasGroup))]\n[UdonBehaviourSyncMode(BehaviourSyncMode.None)]\npublic class AppModule : UdonSharpBehaviour\n{\n [Header(\"App Metadata\")]\n [Tooltip(\"Display name shown in the app launcher\")]\n public string appName = \"Unnamed App\";\n\n [Tooltip(\"Icon texture shown in the app launcher\")]\n public Texture2D appIcon;\n\n /// \u003csummary>\n /// Called when this app becomes the active app.\n /// \u003c/summary>\n public virtual void OnAppOpen()\n {\n }\n\n /// \u003csummary>\n /// Called when this app is being replaced by another app or the home screen.\n /// \u003c/summary>\n public virtual void OnAppClose()\n {\n }\n\n /// \u003csummary>\n /// Called when the device is picked up while this app is active.\n /// \u003c/summary>\n public virtual void OnDevicePickup()\n {\n }\n\n /// \u003csummary>\n /// Called when the device use button is pressed while this app is active.\n /// \u003c/summary>\n public virtual void OnDeviceUseDown()\n {\n }\n\n /// \u003csummary>\n /// Called when the device use button is released while this app is active.\n /// \u003c/summary>\n public virtual void OnDeviceUseUp()\n {\n }\n\n /// \u003csummary>\n /// Called when the device is dropped while this app is active.\n /// \u003c/summary>\n public virtual void OnDeviceDrop()\n {\n }\n\n /// \u003csummary>\n /// Called when a pointer press occurs on this app's UI.\n /// \u003c/summary>\n public virtual void OnAppPointerDown()\n {\n }\n\n /// \u003csummary>\n /// Called when a pointer release occurs on this app's UI.\n /// \u003c/summary>\n public virtual void OnAppPointerUp()\n {\n }\n}\n```\n\n### AppManager (discovery, switching, sync, and event forwarding)\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\nusing TMPro;\nusing VRC.SDKBase;\n\n/// \u003csummary>\n/// Manages a collection of AppModule instances. Handles auto-discovery,\n/// CanvasGroup-based transitions, synced app selection, and pickup event forwarding.\n/// Place all app GameObjects as children of the appsParent transform.\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class AppManager : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [Tooltip(\"Parent transform whose children contain AppModule components\")]\n [SerializeField] private Transform appsParent;\n\n [Tooltip(\"Optional: parent transform for dynamically created app icons in the launcher\")]\n [SerializeField] private Transform iconListParent;\n\n [Tooltip(\"Optional: prefab for app icon buttons (must exist in scene, not an asset)\")]\n [SerializeField] private GameObject iconButtonTemplate;\n\n [Header(\"Transition\")]\n [Tooltip(\"Speed of CanvasGroup alpha fade transitions\")]\n [SerializeField] private float transitionSpeed = 8.0f;\n\n [Header(\"Synced State\")]\n [UdonSynced, FieldChangeCallback(nameof(SyncedAppIndex))]\n private int _syncedAppIndex = -1;\n\n // Discovered apps\n private AppModule[] _apps = new AppModule[0];\n private CanvasGroup[] _canvasGroups = new CanvasGroup[0];\n private int _appCount = 0;\n\n // Transition state\n private int _currentAppIndex = -1;\n private int _targetAppIndex = -1;\n private bool _isTransitioning = false;\n private int _previousAppIndex = -1;\n\n // Pending open state — carries the requested operation from OpenApp/CloseCurrentApp\n // into OnOwnershipTransferred so the callback knows which app to open.\n // Note: Networking.SetOwner is locally immediate (post-2021.2.2), so a request that\n // already owns the object can execute synchronously; this field exists for the\n // ownership-transfer code path only.\n // -2 = no pending operation, -1 = pending close, >= 0 = pending open index\n private int _pendingOpenIndex = -2;\n\n /// \u003csummary>\n /// Synced property: when the synced value changes, trigger a transition.\n /// \u003c/summary>\n public int SyncedAppIndex\n {\n get => _syncedAppIndex;\n set\n {\n _syncedAppIndex = value;\n BeginTransition(value);\n }\n }\n\n void Start()\n {\n DiscoverApps();\n InitializeAllAppsHidden();\n\n if (iconButtonTemplate != null)\n {\n iconButtonTemplate.SetActive(false);\n }\n\n BuildIconList();\n }\n\n private void DiscoverApps()\n {\n if (appsParent == null) return;\n\n int childCount = appsParent.childCount;\n\n // First pass: count valid apps\n int count = 0;\n for (int i = 0; i \u003c childCount; i++)\n {\n Transform child = appsParent.GetChild(i);\n AppModule app = child.GetComponent\u003cAppModule>();\n if (Utilities.IsValid(app))\n {\n count++;\n }\n }\n\n _apps = new AppModule[count];\n _canvasGroups = new CanvasGroup[count];\n _appCount = count;\n\n // Second pass: collect references\n int idx = 0;\n for (int i = 0; i \u003c childCount; i++)\n {\n Transform child = appsParent.GetChild(i);\n AppModule app = child.GetComponent\u003cAppModule>();\n if (Utilities.IsValid(app))\n {\n _apps[idx] = app;\n _canvasGroups[idx] = child.GetComponent\u003cCanvasGroup>();\n idx++;\n }\n }\n }\n\n private void InitializeAllAppsHidden()\n {\n for (int i = 0; i \u003c _appCount; i++)\n {\n if (Utilities.IsValid(_canvasGroups[i]))\n {\n _canvasGroups[i].alpha = 0.0f;\n _canvasGroups[i].interactable = false;\n _canvasGroups[i].blocksRaycasts = false;\n }\n }\n }\n\n private void BuildIconList()\n {\n if (iconListParent == null) return;\n if (iconButtonTemplate == null) return;\n\n for (int i = 0; i \u003c _appCount; i++)\n {\n GameObject iconObj = Object.Instantiate(iconButtonTemplate);\n iconObj.transform.SetParent(iconListParent, false);\n iconObj.SetActive(true);\n iconObj.name = \"AppIcon_\" + i.ToString();\n\n // Set icon texture if a RawImage is present on the template\n RawImage rawImage =\n iconObj.GetComponentInChildren\u003cRawImage>();\n if (rawImage != null && Utilities.IsValid(_apps[i]) && _apps[i].appIcon != null)\n {\n rawImage.texture = _apps[i].appIcon;\n }\n\n // Set label if a Text component is present\n TextMeshProUGUI label =\n iconObj.GetComponentInChildren\u003cTextMeshProUGUI>();\n if (label != null && Utilities.IsValid(_apps[i]))\n {\n label.text = _apps[i].appName;\n }\n }\n }\n\n /// \u003csummary>\n /// Open an app by index. Call from icon button OnClick events.\n /// The button name must contain the index (e.g., \"AppIcon_2\").\n /// \u003c/summary>\n public void OpenApp(int appIndex)\n {\n if (appIndex \u003c 0 || appIndex >= _appCount) return;\n\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local == null) return;\n\n if (!Networking.IsOwner(local, gameObject))\n {\n _pendingOpenIndex = appIndex;\n Networking.SetOwner(local, gameObject);\n return;\n }\n\n ExecuteOpenApp(appIndex);\n }\n\n /// \u003csummary>\n /// Close the current app and return to the home state (no app active).\n /// \u003c/summary>\n public void CloseCurrentApp()\n {\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local == null) return;\n\n if (!Networking.IsOwner(local, gameObject))\n {\n _pendingOpenIndex = -1;\n Networking.SetOwner(local, gameObject);\n return;\n }\n\n ExecuteOpenApp(-1);\n }\n\n private void ExecuteOpenApp(int appIndex)\n {\n SyncedAppIndex = appIndex;\n RequestSerialization();\n }\n\n public override void OnOwnershipTransferred(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n if (_pendingOpenIndex != -2)\n {\n int idx = _pendingOpenIndex;\n _pendingOpenIndex = -2;\n ExecuteOpenApp(idx);\n }\n }\n\n /// \u003csummary>\n /// Called by icon buttons. Parse the button name to extract the app index.\n /// Button name format: \"AppIcon_N\" where N is the app index.\n /// \u003c/summary>\n public void OnIconButtonClicked(string buttonName)\n {\n if (buttonName == null) return;\n\n string prefix = \"AppIcon_\";\n if (buttonName.Length \u003c= prefix.Length) return;\n\n string idxStr = buttonName.Substring(prefix.Length);\n\n // Manual int parse\n int result = 0;\n bool valid = true;\n for (int i = 0; i \u003c idxStr.Length; i++)\n {\n char c = idxStr[i];\n if (c \u003c '0' || c > '9')\n {\n valid = false;\n break;\n }\n result = result * 10 + (c - '0');\n }\n\n if (valid && idxStr.Length > 0)\n {\n OpenApp(result);\n }\n }\n\n private void BeginTransition(int targetIndex)\n {\n if (targetIndex == _currentAppIndex) return;\n\n _previousAppIndex = _currentAppIndex;\n _targetAppIndex = targetIndex;\n _isTransitioning = true;\n\n // Notify the previous app that it is closing\n if (_previousAppIndex >= 0 && _previousAppIndex \u003c _appCount)\n {\n if (Utilities.IsValid(_apps[_previousAppIndex]))\n {\n _apps[_previousAppIndex].OnAppClose();\n }\n }\n\n // Prepare the target app's CanvasGroup\n if (_targetAppIndex >= 0 && _targetAppIndex \u003c _appCount)\n {\n if (Utilities.IsValid(_canvasGroups[_targetAppIndex]))\n {\n // Enable raycasts immediately so it can receive input once visible\n _canvasGroups[_targetAppIndex].blocksRaycasts = true;\n }\n }\n }\n\n void Update()\n {\n if (!_isTransitioning) return;\n\n float step = transitionSpeed * Time.deltaTime;\n bool fadeOutDone = true;\n bool fadeInDone = true;\n\n // Fade out previous app\n if (_previousAppIndex >= 0 && _previousAppIndex \u003c _appCount)\n {\n CanvasGroup prevCg = _canvasGroups[_previousAppIndex];\n if (Utilities.IsValid(prevCg))\n {\n prevCg.alpha = Mathf.MoveTowards(prevCg.alpha, 0.0f, step);\n if (prevCg.alpha > 0.001f)\n {\n fadeOutDone = false;\n }\n else\n {\n prevCg.alpha = 0.0f;\n prevCg.interactable = false;\n prevCg.blocksRaycasts = false;\n }\n }\n }\n\n // Fade in target app\n if (_targetAppIndex >= 0 && _targetAppIndex \u003c _appCount)\n {\n CanvasGroup targetCg = _canvasGroups[_targetAppIndex];\n if (Utilities.IsValid(targetCg))\n {\n targetCg.alpha = Mathf.MoveTowards(targetCg.alpha, 1.0f, step);\n if (targetCg.alpha \u003c 0.999f)\n {\n fadeInDone = false;\n }\n else\n {\n targetCg.alpha = 1.0f;\n targetCg.interactable = true;\n }\n }\n }\n\n bool done = fadeOutDone && fadeInDone;\n\n if (done)\n {\n _isTransitioning = false;\n _currentAppIndex = _targetAppIndex;\n\n // Notify the new app that it is now active\n if (_currentAppIndex >= 0 && _currentAppIndex \u003c _appCount)\n {\n if (Utilities.IsValid(_apps[_currentAppIndex]))\n {\n _apps[_currentAppIndex].OnAppOpen();\n }\n }\n }\n }\n\n // ----- Pickup/Interaction event forwarding -----\n\n public override void OnPickup()\n {\n ForwardToCurrentApp(\"OnDevicePickup\");\n }\n\n public override void OnDrop()\n {\n ForwardToCurrentApp(\"OnDeviceDrop\");\n }\n\n public override void OnPickupUseDown()\n {\n ForwardToCurrentApp(\"OnDeviceUseDown\");\n }\n\n public override void OnPickupUseUp()\n {\n ForwardToCurrentApp(\"OnDeviceUseUp\");\n }\n\n private void ForwardToCurrentApp(string eventName)\n {\n if (_currentAppIndex \u003c 0 || _currentAppIndex >= _appCount) return;\n\n AppModule currentApp = _apps[_currentAppIndex];\n if (Utilities.IsValid(currentApp))\n {\n currentApp.SendCustomEvent(eventName);\n }\n }\n\n /// \u003csummary>\n /// Returns the number of discovered apps.\n /// \u003c/summary>\n public int GetAppCount()\n {\n return _appCount;\n }\n\n /// \u003csummary>\n /// Returns the currently active app index (-1 if none).\n /// \u003c/summary>\n public int GetCurrentAppIndex()\n {\n return _currentAppIndex;\n }\n}\n```\n\n**When to use:**\n- Building a multi-screen device (tablet, kiosk, terminal) where each screen is a self-contained feature.\n- Creating a plugin-style architecture where new apps can be added by placing a new child GameObject under the apps parent.\n- Any scenario requiring CanvasGroup-based animated transitions between UI panels with network sync.\n\n> **Key notes:**\n> - `AppModule` uses `virtual` lifecycle methods. Subclasses override only the hooks they need (e.g., a clock app only overrides `OnAppOpen` to start its timer).\n> - `CanvasGroup.alpha` controls visibility, `interactable` controls whether UI elements respond to input, and `blocksRaycasts` controls whether the panel intercepts pointer events. All three must be managed together for correct transitions.\n> - The `[FieldChangeCallback]` attribute on `SyncedAppIndex` ensures the property setter runs on all clients when the synced value changes, triggering the transition on remote players.\n> - App discovery iterates `appsParent` children at `Start()`. Adding or removing apps at runtime is not supported — all apps must be present in the scene hierarchy at load time.\n> - Pickup events (`OnPickup`, `OnDrop`, `OnPickupUseDown`, `OnPickupUseUp`) are forwarded from the manager to the active app via `SendCustomEvent`, allowing each app to respond to device interactions independently.\n> - Icon buttons are instantiated from a scene template at startup. Wire each button's OnClick to call `OnIconButtonClicked` with the button's name.\n\n---\n\n## See Also\n\n- [patterns-core.md](patterns-core.md) - Basic UI patterns (button handler, slider display), initialization, interaction\n- [patterns-networking.md](patterns-networking.md) - Synced game state, object pooling, NetworkCallable (see also: Pattern 12 synced app selection)\n- [patterns-utilities.md](patterns-utilities.md) - Array helpers, event bus, relay communication\n- [patterns-performance.md](patterns-performance.md) - Update handler, platform optimization (see also: Pattern 11 per-frame finger tracking)\n- [persistence.md](persistence.md) - PlayerData/PlayerObject API details\n- [api.md](api.md) - VRCPlayerApi, VRCCameraSettings, GetBonePosition, PlayHapticEventInHand reference\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":61197,"content_sha256":"5c2d0d03287cb89ebdd9dc1a4877c2219d8952a6f8634b68e1df9be1c65991e7"},{"filename":"references/patterns-utilities.md","content":"# UdonSharp Utility Patterns\n\nArray helpers, array utility helpers (List<T> alternatives), event bus, GameObject relay communication, pseudo-struct double-cast, and abstract class callback patterns.\n\n## Array Helpers\n\n```csharp\npublic class ArrayHelpers : UdonSharpBehaviour\n{\n // Find index in array\n public int FindIndex(GameObject[] array, GameObject target)\n {\n for (int i = 0; i \u003c array.Length; i++)\n {\n if (array[i] == target) return i;\n }\n return -1;\n }\n\n // Shuffle array (Fisher-Yates)\n public void ShuffleArray(int[] array)\n {\n for (int i = array.Length - 1; i > 0; i--)\n {\n int j = Random.Range(0, i + 1);\n int temp = array[i];\n array[i] = array[j];\n array[j] = temp;\n }\n }\n\n // Resize array (create new)\n public GameObject[] ResizeArray(GameObject[] original, int newSize)\n {\n GameObject[] newArray = new GameObject[newSize];\n int copyLength = Mathf.Min(original.Length, newSize);\n System.Array.Copy(original, newArray, copyLength);\n return newArray;\n }\n}\n```\n\n## Array Utility Helpers\n\nUdonSharp does not support `List\u003cT>`. The following static-style helpers use `System.Array.Copy` to provide list-like operations on plain arrays. Each operation returns a **new array**; the original is never modified.\n\n> **Performance warning:** Every call allocates a new array and copies elements. Do not call these in `Update()` or any hot path. Prefer pre-sized arrays with a manual count variable for high-frequency code.\n\n**Template:** [assets/templates/ArrayUtils.cs](../assets/templates/ArrayUtils.cs)\n\nProvides `Add`, `Contains`, `AddUnique`, `Remove`, `RemoveAt`, and `Insert` operations for `GameObject[]`, plus `FindIndex` and `ShuffleArray` for `int[]`. All operations return new arrays; none mutate the source. UdonSharp does not support generic methods, so one set of signatures per element type is required — duplicate as needed for `UdonSharpBehaviour[]`, `int[]`, etc.\n\n---\n\n## Event Bus Pattern\n\n### Problem\n\nC# delegates and events are unavailable in UdonSharp. How can one behaviour notify several others when something changes?\n\n### Solution\n\nMaintain a `UdonSharpBehaviour[]` subscriber list. Raising an event iterates the list and calls `SendCustomEvent(methodName)` on each entry.\n\n**Template:** [assets/templates/EventBus.cs](../assets/templates/EventBus.cs)\n\n> **Hot-path caveat.** `RaiseEvent` calls `SendCustomEvent` per subscriber, which scales with subscriber count and incurs per-call dispatch cost. Use `EventBus` for low-frequency one-to-many broadcasts (Interact, toggle changes, Inspector-wired hooks). For per-frame or many-instance broadcasts, see [`Event Dispatch & Cross-Behaviour Call Cost Tiers`](patterns-performance.md#event-dispatch--cross-behaviour-call-cost-tiers) in `patterns-performance.md`.\n\nThe `EventBus` class keeps a `UdonSharpBehaviour[]` array capped at `MaxListeners` (32). `RegisterListener` appends with duplicate check; `UnregisterListener` compacts the array in place. `RaiseEvent(string eventMethodName)` iterates the list, skips null entries with an in-place compaction pass, and calls `SendCustomEvent(eventMethodName)` on each live subscriber.\n\n**Consumer example:**\n\n```csharp\npublic class DoorController : UdonSharpBehaviour\n{\n [SerializeField] private EventBus doorBus;\n [SerializeField] private Animator doorAnimator;\n\n void Start()\n {\n doorBus.RegisterListener(this);\n }\n\n // Called by EventBus.RaiseEvent(\"OnDoorOpened\")\n public void OnDoorOpened()\n {\n doorAnimator.SetTrigger(\"Open\");\n }\n}\n```\n\n**Producer example:**\n\n```csharp\npublic class DoorTrigger : UdonSharpBehaviour\n{\n [SerializeField] private EventBus doorBus;\n\n public override void OnPlayerTriggerEnter(VRCPlayerApi player)\n {\n if (player.isLocal)\n {\n doorBus.RaiseEvent(\"OnDoorOpened\");\n }\n }\n}\n```\n\n---\n\n## GameObject Relay Communication\n\n### Problem\n\nDirect references couple event producers tightly to their consumers. How can a behaviour react to a signal without the producer knowing its type?\n\n### Solution\n\nUse `GameObject.SetActive` as a signalling mechanism. The producer toggles a relay GameObject; any behaviour on that GameObject reacts in `OnEnable` or `OnDisable`.\n\n**Producer — sends the signal:**\n\n```csharp\npublic class LightSwitchTrigger : UdonSharpBehaviour\n{\n [SerializeField] private GameObject lightsOnRelay;\n [SerializeField] private GameObject lightsOffRelay;\n\n private bool _lightsOn = false;\n\n public override void Interact()\n {\n _lightsOn = !_lightsOn;\n\n if (_lightsOn)\n {\n // Pulse the relay: activate then deactivate next frame\n lightsOnRelay.SetActive(true);\n lightsOnRelay.SetActive(false);\n }\n else\n {\n lightsOffRelay.SetActive(true);\n lightsOffRelay.SetActive(false);\n }\n }\n}\n```\n\n**Consumer — reacts to the signal:**\n\n```csharp\n/// \u003csummary>\n/// Attach to the lightsOnRelay GameObject.\n/// OnEnable fires every time the producer calls SetActive(true).\n/// \u003c/summary>\npublic class LightsOnResponder : UdonSharpBehaviour\n{\n [SerializeField] private Light[] sceneLights;\n\n void OnEnable()\n {\n for (int i = 0; i \u003c sceneLights.Length; i++)\n {\n if (sceneLights[i] != null)\n {\n sceneLights[i].enabled = true;\n }\n }\n }\n\n void OnDisable()\n {\n // OnDisable is called immediately after OnEnable in the pulse pattern above.\n // Use a separate relay GameObject for each signal direction to keep concerns clear.\n }\n}\n```\n\n**When to prefer this over EventBus:**\n- Very simple one-shot signals where subscriber registration overhead is unnecessary\n- Signals that must persist across scene loads (relay GameObject survives)\n- Visual debugging: relay active state is visible in the Hierarchy\n\n---\n\n## Pseudo-Struct via object[] Double-Cast\n\n### Problem\n\nUdonSharp lacks full `struct` support and has no generics. You cannot define:\n\n```csharp\nstruct ItemData { int Id; string Name; float Price; }\nList\u003cItemData> items = new List\u003cItemData>();\n```\n\n`DataDictionary` is available but has no compile-time type checking — a typo in a key string silently returns null at runtime.\n\n### Solution\n\nDefine a **type class**: a `UdonSharpBehaviour` subclass whose sole purpose is to carry typed data. A static-style `New(...)` factory packs fields into an `object[]` and casts it through `object` to the type class:\n\n```csharp\nreturn (ItemData)(object)(new object[] { id, name, price });\n```\n\nA companion **extension class** reverses the cast to read individual fields:\n\n```csharp\nreturn (int)(((object[])(object)val)[0]);\n```\n\nThis exploits the UdonSharp runtime's willingness to round-trip `UdonSharpBehaviour`-derived types through an intermediate `object` cast — an empirically observed behaviour in UdonSharp's runtime type system. This pattern is used in production VRChat worlds but is not officially documented by VRChat.\n\n### Type Class and Extension\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Data carrier for a catalogue item.\n/// Not a real MonoBehaviour — never attach directly to a GameObject.\n/// \u003c/summary>\n[AddComponentMenu(\"\")]\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class ItemData : UdonSharpBehaviour\n{\n // Field layout: public so ItemDataExt can reference them without magic numbers\n public const int IdxId = 0;\n public const int IdxName = 1;\n public const int IdxPrice = 2;\n\n /// \u003csummary>\n /// Creates an immutable ItemData instance.\n /// \u003c/summary>\n public static ItemData New(int id, string name, float price)\n {\n return (ItemData)(object)(new object[] { id, name, price });\n }\n}\n```\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Getter extensions for ItemData.\n/// Usage: myItem.Id(), myItem.Name(), myItem.Price()\n/// \u003c/summary>\n[AddComponentMenu(\"\")]\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class ItemDataExt : UdonSharpBehaviour\n{\n public static int Id(ItemData val)\n {\n return (int)(((object[])(object)val)[ItemData.IdxId]);\n }\n\n public static string Name(ItemData val)\n {\n return (string)(((object[])(object)val)[ItemData.IdxName]);\n }\n\n public static float Price(ItemData val)\n {\n return (float)(((object[])(object)val)[ItemData.IdxPrice]);\n }\n}\n```\n\n### Usage\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class CatalogueDisplay : UdonSharpBehaviour\n{\n private ItemData[] _catalogue;\n\n void Start()\n {\n // Build a typed array of pseudo-structs\n _catalogue = new ItemData[]\n {\n ItemData.New(1, \"Health Potion\", 50f),\n ItemData.New(2, \"Iron Sword\", 300f),\n ItemData.New(3, \"Leather Boots\", 120f),\n };\n\n LogCatalogue();\n }\n\n private void LogCatalogue()\n {\n for (int i = 0; i \u003c _catalogue.Length; i++)\n {\n ItemData item = _catalogue[i];\n int id = ItemDataExt.Id(item);\n string name = ItemDataExt.Name(item);\n float price = ItemDataExt.Price(item);\n\n Debug.Log($\"[{id}] {name} — {price}G\");\n }\n }\n\n // \"Updating\" an entry means replacing it with a new instance (immutable)\n public void MarkdownPrice(int index, float newPrice)\n {\n if (index \u003c 0 || index >= _catalogue.Length) return;\n\n ItemData old = _catalogue[index];\n _catalogue[index] = ItemData.New(\n ItemDataExt.Id(old),\n ItemDataExt.Name(old),\n newPrice\n );\n }\n}\n```\n\n**Limitations:**\n\n- Instances are **immutable by design**. To \"update\" a field, replace the entry in the array with a new `New(...)` call (see `MarkdownPrice` above).\n- The `[AddComponentMenu(\"\")]` attribute hides the type class from Unity's Add Component menu — it is a data carrier, not a real behaviour, and should never appear in Inspector component lists.\n- Do not rely on `GetComponent\u003cItemData>()` — the type class is never attached to a GameObject.\n\n**When to use:**\n\n- Typed rows parsed from JSON or downloaded string data\n- Structured entries in fixed-size arrays (inventory, leaderboard, catalogue)\n- Any case where `DataDictionary` string-key access feels error-prone\n\n---\n\n## Abstract Class Callback Pattern\n\n### Problem\n\nThe `interface` keyword is blocked in UdonSharp. When building modular systems — a data loader that must notify its caller when finished — you cannot define `ILoadCallback`. Using bare `SendCustomEvent(stringName)` loses type safety: callers receive no arguments and must re-read public fields manually, with no compiler guard against mismatched names.\n\n### Solution\n\nDefine an **abstract base class** with abstract callback methods. Concrete handlers subclass it and override those methods. A **mediator** bridges the string-based `SendCustomEvent` call from the worker to the typed method on the callback reference.\n\n```text\nWorker ──SendCustomEvent──▶ Mediator ──typed call──▶ AbstractBase ──override──▶ ConcreteHandler\n```\n\nThis three-layer structure gives you:\n\n| Layer | Responsibility |\n|---|---|\n| **Worker** | Does the work; stores results in `public` fields; calls `callback.SendCustomEvent(nameof(OnResultReady))` |\n| **Mediator** | Receives the string event; reads worker's public fields; calls the typed abstract method |\n| **Abstract base** | Declares the typed callback signature; concrete subclasses override it |\n\n### Comparison\n\n| Approach | Type safety | Argument passing | Compile-time check |\n|---|---|---|---|\n| `SendCustomEvent(string)` | None | None (read public fields manually) | No |\n| Abstract callback via mediator | Full | Typed parameters | Yes |\n\n### Implementation\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Abstract base for any component that needs to receive process results.\n/// Subclass this and override OnProcessComplete.\n/// \u003c/summary>\n[AddComponentMenu(\"\")]\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic abstract class ProcessCallbackBase : UdonSharpBehaviour\n{\n /// \u003csummary>\n /// Called by ProcessMediator when DataProcessor finishes.\n /// \u003c/summary>\n /// \u003cparam name=\"success\">Whether the operation succeeded.\u003c/param>\n /// \u003cparam name=\"data\">Processed output lines.\u003c/param>\n public abstract void OnProcessComplete(bool success, string[] data);\n}\n```\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Worker: performs the data processing and notifies a mediator when done.\n/// Results are written to public fields before the event fires so the\n/// mediator can read them synchronously.\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class DataProcessor : UdonSharpBehaviour\n{\n [Header(\"Callback\")]\n [SerializeField] private ProcessMediator _mediator;\n\n // Public result fields — written before SendCustomEvent fires\n [HideInInspector] public bool ResultSuccess;\n [HideInInspector] public string[] ResultData;\n\n public void Process(string[] inputLines)\n {\n // Simulate work\n string[] output = new string[inputLines.Length];\n for (int i = 0; i \u003c inputLines.Length; i++)\n {\n output[i] = inputLines[i].Trim();\n }\n\n ResultSuccess = true;\n ResultData = output;\n\n // Notify mediator via string event.\n // String literal used instead of nameof(ProcessMediator.OnResultReady) because\n // UdonSharp's nameof support for cross-class members is unreliable at runtime.\n _mediator.SendCustomEvent(\"OnResultReady\");\n }\n}\n```\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Mediator: bridges the string-based worker callback to the typed abstract method.\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class ProcessMediator : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private DataProcessor _worker;\n [SerializeField] private ProcessCallbackBase _callback;\n\n // Called by DataProcessor via SendCustomEvent\n public void OnResultReady()\n {\n if (_callback == null) return;\n\n // Read typed results from worker's public fields\n bool success = _worker.ResultSuccess;\n string[] data = _worker.ResultData;\n\n // Forward as a typed call — compiler enforces the signature\n _callback.OnProcessComplete(success, data);\n }\n}\n```\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing TMPro;\n\n/// \u003csummary>\n/// Concrete handler: receives typed results and updates the UI.\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class MyResultHandler : ProcessCallbackBase\n{\n [SerializeField] private TextMeshProUGUI _statusLabel;\n [SerializeField] private TextMeshProUGUI[] _lineLabels;\n\n public override void OnProcessComplete(bool success, string[] data)\n {\n if (_statusLabel != null)\n {\n _statusLabel.text = success ? \"Done\" : \"Failed\";\n }\n\n int count = Mathf.Min(data.Length, _lineLabels.Length);\n for (int i = 0; i \u003c count; i++)\n {\n if (_lineLabels[i] != null)\n {\n _lineLabels[i].text = data[i];\n }\n }\n }\n}\n```\n\n**Notes:**\n\n- The mediator holds the `ProcessCallbackBase` reference as the **abstract type**, not the concrete type. This means any subclass of `ProcessCallbackBase` can be plugged in without changing the mediator.\n- The `[AddComponentMenu(\"\")]` on `ProcessCallbackBase` keeps it out of Unity's Add Component menu.\n- `abstract` methods are `public` in the abstract class, but calling them via `SendCustomEvent` on an abstract class reference is unreliable: abstract methods have no concrete Udon bytecode body, so the Udon VM cannot dispatch to them by name. The mediator therefore uses a direct typed call (`_callback.OnProcessComplete(...)`) against the concrete instance. This is intentional — it is the typed boundary.\n- The worker's public result fields (`ResultSuccess`, `ResultData`) act as a temporary buffer. Read them synchronously inside `OnResultReady`; they may be overwritten if a second `Process` call fires before your handler completes.\n\n**When to use:**\n\n- Data loading pipelines where a downloader or decoder notifies a display controller\n- UI systems that need to react to events from multiple independent worker types\n- Multi-step workflows where each stage hands off to a typed \"done\" handler\n\n---\n\n## Cancellable Delayed Event\n\n### Problem\n\n`SendCustomEventDelayedSeconds` has no cancellation API. Once scheduled, the callback will fire even if the caller's state has changed and the event is no longer wanted. The generation-counter debounce (see [Delayed Event Debounce in patterns-networking.md](patterns-networking.md)) handles \"soft cancel\" — it lets the callback fire but makes it a no-op. That is sufficient for debounce cases but not for situations where the callback absolutely must not execute: side effects inside the callback (audio playback, network requests, object destruction) will still run through the guard check even if they then return early.\n\n### Solution\n\nInstantiate a helper `GameObject` that carries a tiny `UdonSharpBehaviour`. Schedule `SendCustomEventDelayedSeconds` on the helper itself. To cancel, call `Destroy(helperGameObject)` before the delay expires — the destroyed behaviour never executes its scheduled callback.\n\n**Trade-off:** Allocates a `GameObject` per timer instance. Use the generation-counter pattern for high-frequency debounce; use this pattern only when the callback truly must not fire.\n\n**When to use:**\n- Retry timers that must be cancelled when the user changes their action before the retry fires\n- Load timeout timers where a successful load must cleanly suppress the \"timed out\" callback\n- Any case where the callback has irreversible side effects (network events, audio, state mutation)\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Tiny helper behaviour instantiated per timer.\n/// Destroying its GameObject before the delay expires cancels the callback.\n/// \u003c/summary>\n[AddComponentMenu(\"\")]\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class CancellableTimer : UdonSharpBehaviour\n{\n // Written by the factory before the delay is scheduled.\n [HideInInspector] public UdonSharpBehaviour CallbackTarget;\n [HideInInspector] public string CallbackMethod;\n\n /// \u003csummary>\n /// Called by SendCustomEventDelayedSeconds on this behaviour.\n /// Fires CallbackTarget.SendCustomEvent(CallbackMethod) and then\n /// destroys the helper GameObject.\n /// \u003c/summary>\n public void _Fire()\n {\n if (CallbackTarget != null)\n {\n CallbackTarget.SendCustomEvent(CallbackMethod);\n }\n\n // Clean up the helper object after firing.\n Destroy(gameObject);\n }\n}\n```\n\n**Usage — create and cancel a timer:**\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class RetryController : UdonSharpBehaviour\n{\n [SerializeField] private GameObject _timerPrefab; // Prefab with CancellableTimer attached\n [SerializeField] private float _retryDelay = 5f;\n\n private GameObject _pendingTimer;\n\n /// \u003csummary>\n /// Schedules a retry. Cancels any previously pending retry first.\n /// \u003c/summary>\n public void ScheduleRetry()\n {\n CancelPendingRetry();\n\n if (_timerPrefab == null) { Debug.LogError(\"[RetryController] Timer prefab not assigned\"); return; }\n\n _pendingTimer = VRCInstantiate(_timerPrefab);\n CancellableTimer timer = _pendingTimer.GetComponent\u003cCancellableTimer>();\n if (timer == null) { Debug.LogError(\"[RetryController] CancellableTimer component missing\"); Destroy(_pendingTimer); _pendingTimer = null; return; }\n timer.CallbackTarget = this;\n timer.CallbackMethod = nameof(OnRetryFired);\n\n // The callback fires on the helper; destroying _pendingTimer before\n // retryDelay seconds cancels it without any generation-counter bookkeeping.\n timer.SendCustomEventDelayedSeconds(nameof(CancellableTimer._Fire), _retryDelay);\n }\n\n /// \u003csummary>\n /// Cancels the pending retry if one is scheduled.\n /// \u003c/summary>\n public void CancelPendingRetry()\n {\n if (_pendingTimer != null)\n {\n Destroy(_pendingTimer);\n _pendingTimer = null;\n }\n }\n\n /// \u003csummary>\n /// Invoked by the timer when the delay expires without cancellation.\n /// \u003c/summary>\n public void OnRetryFired()\n {\n _pendingTimer = null;\n Debug.Log(\"[RetryController] Retry timer fired — executing retry logic.\");\n // ... retry logic here\n }\n}\n```\n\n---\n\n## Re-Entrance Guard\n\n### Problem\n\nDuring an event broadcast loop — `RaiseEvent` iterating a subscriber array and calling `SendCustomEvent` on each listener — a listener's handler may itself call `RaiseEvent` on the same event bus. This creates either infinite recursion (stack overflow in Udon) or corrupted iteration (the array is modified while being iterated).\n\n### Solution\n\nAdd a `bool _isEmitting` guard to the event bus. Set it `true` immediately before the iteration and `false` immediately after. If `RaiseEvent` is called while `_isEmitting` is already `true`, skip the call (log a warning in development builds). This mirrors the pattern used in most production event systems.\n\n**Cross-reference:** See the [Event Bus Pattern](#event-bus-pattern) in this file for the base implementation that this guard extends.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Event bus with re-entrance guard.\n/// Prevents recursive RaiseEvent calls from corrupting the subscriber iteration.\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class GuardedEventBus : UdonSharpBehaviour\n{\n private const int MaxListeners = 32;\n\n [SerializeField] private UdonSharpBehaviour[] _listeners\n = new UdonSharpBehaviour[MaxListeners];\n private int _listenerCount = 0;\n\n // Re-entrance guard: true while RaiseEvent is iterating _listeners.\n private bool _isEmitting = false;\n\n public void RegisterListener(UdonSharpBehaviour listener)\n {\n if (listener == null) return;\n\n // Duplicate check\n for (int i = 0; i \u003c _listenerCount; i++)\n {\n if (_listeners[i] == listener) return;\n }\n\n if (_listenerCount >= MaxListeners)\n {\n Debug.LogWarning(\"[GuardedEventBus] Listener limit reached.\");\n return;\n }\n\n _listeners[_listenerCount] = listener;\n _listenerCount++;\n }\n\n public void UnregisterListener(UdonSharpBehaviour listener)\n {\n for (int i = 0; i \u003c _listenerCount; i++)\n {\n if (_listeners[i] == listener)\n {\n // Compact in place\n _listeners[i] = _listeners[_listenerCount - 1];\n _listeners[_listenerCount - 1] = null;\n _listenerCount--;\n return;\n }\n }\n }\n\n /// \u003csummary>\n /// Raises \u003cparamref name=\"eventMethodName\"/> on all registered listeners.\n /// Re-entrant calls (from within a listener handler) are silently dropped.\n /// \u003c/summary>\n public void RaiseEvent(string eventMethodName)\n {\n if (_isEmitting)\n {\n // A listener is calling RaiseEvent from inside its own handler.\n // Dropping this call prevents infinite recursion and iteration corruption.\n Debug.LogWarning(\n $\"[GuardedEventBus] Re-entrant RaiseEvent('{eventMethodName}') dropped.\");\n return;\n }\n\n _isEmitting = true;\n\n for (int i = 0; i \u003c _listenerCount; i++)\n {\n if (_listeners[i] != null)\n {\n _listeners[i].SendCustomEvent(eventMethodName);\n }\n }\n\n _isEmitting = false;\n }\n}\n```\n\n**Notes:**\n- If you need deferred re-entrant events (fire after the current broadcast completes), capture the call in a small pending-event queue (a `string[]` with a head/tail counter) and drain it after `_isEmitting = false`.\n- `_isEmitting` does not need `[UdonSynced]`; it is a local execution-flow flag with no network meaning.\n\n---\n\n## UdonEvent Pseudo-Delegate\n\n### Problem\n\nC# delegates are blocked in UdonSharp. When a system needs a runtime-swappable, one-to-one callback — for example, a Strategy pattern where an external module overrides a hook point — there is no built-in mechanism.\n\n**Difference from Event Bus:** The [Event Bus Pattern](#event-bus-pattern) is one-to-many broadcast; `UdonAction` is one-to-one and reassignable at runtime.\n\n**Difference from Abstract Callback:** The [Abstract Class Callback Pattern](#abstract-class-callback-pattern) provides compile-time typed signatures via an abstract base class; `UdonAction` is stringly-typed but lighter weight (no mediator class required) and can be swapped by external code at any time.\n\n### Solution\n\nStore `{ UdonSharpBehaviour target, string eventName }` as a two-element `object[]` and cast it through the pseudo-struct double-cast technique (see [Pseudo-Struct via object\\[\\] Double-Cast](#pseudo-struct-via-object-double-cast)) to give it a named type. A companion class provides `New()`, `Invoke()`, and `SetTarget()` factory/extension methods.\n\n**When to use:**\n- Strategy pattern: swap the algorithm at runtime without changing the caller\n- Hook points that external modules (loaded after world init) can register for\n- Single-subscriber callbacks that must be reassigned without re-wiring Inspector references\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Pseudo-delegate type: wraps a (target, eventName) pair.\n/// Not a real MonoBehaviour — never attach directly to a GameObject.\n/// \u003c/summary>\n[AddComponentMenu(\"\")]\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class UdonAction : UdonSharpBehaviour\n{\n // Field layout indices\n public const int IdxTarget = 0;\n public const int IdxEventName = 1;\n}\n```\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n/// \u003csummary>\n/// Factory and extension methods for UdonAction.\n/// \u003c/summary>\n[AddComponentMenu(\"\")]\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class UdonActionExt : UdonSharpBehaviour\n{\n /// \u003csummary>\n /// Creates a new UdonAction pointing to target.eventName.\n /// \u003c/summary>\n public static UdonAction New(UdonSharpBehaviour target, string eventName)\n {\n return (UdonAction)(object)(new object[] { target, eventName });\n }\n\n /// \u003csummary>\n /// Invokes the callback. No-op if action or target is null.\n /// \u003c/summary>\n public static void Invoke(UdonAction action)\n {\n if (action == null) return;\n\n object[] raw = (object[])(object)action;\n UdonSharpBehaviour target = (UdonSharpBehaviour)raw[UdonAction.IdxTarget];\n string eventName = (string)raw[UdonAction.IdxEventName];\n\n if (target == null || string.IsNullOrEmpty(eventName)) return;\n\n target.SendCustomEvent(eventName);\n }\n\n /// \u003csummary>\n /// Returns a new UdonAction with the same event name but a different target.\n /// (UdonAction instances are immutable; reassignment creates a new instance.)\n /// \u003c/summary>\n public static UdonAction SetTarget(UdonAction action, UdonSharpBehaviour newTarget)\n {\n if (action == null) return UdonActionExt.New(newTarget, \"\");\n\n object[] raw = (object[])(object)action;\n string eventName = (string)raw[UdonAction.IdxEventName];\n\n return UdonActionExt.New(newTarget, eventName);\n }\n\n /// \u003csummary>\n /// Returns the event name stored in the action (useful for debugging).\n /// \u003c/summary>\n public static string GetEventName(UdonAction action)\n {\n if (action == null) return \"\";\n return (string)(((object[])(object)action)[UdonAction.IdxEventName]);\n }\n}\n```\n\n**Usage — Strategy pattern with swappable callback:**\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class LoadOrchestrator : UdonSharpBehaviour\n{\n // The active success handler — can be reassigned by external modules.\n private UdonAction _onSuccess;\n\n void Start()\n {\n // Default handler: show a simple status label.\n _onSuccess = UdonActionExt.New(this, nameof(DefaultSuccessHandler));\n }\n\n /// \u003csummary>\n /// External modules call this to override the success hook.\n /// \u003c/summary>\n public void SetSuccessCallback(UdonSharpBehaviour target, string methodName)\n {\n _onSuccess = UdonActionExt.New(target, methodName);\n }\n\n public void SimulateLoad()\n {\n Debug.Log(\"[LoadOrchestrator] Load complete — invoking success callback.\");\n UdonActionExt.Invoke(_onSuccess);\n }\n\n public void DefaultSuccessHandler()\n {\n Debug.Log(\"[LoadOrchestrator] Default success handler called.\");\n }\n}\n```\n\n---\n\n\n## See Also\n\n- [patterns-core.md](patterns-core.md) - Initialization, interaction, timer, audio, pickup, animation, UI\n- [patterns-networking.md](patterns-networking.md) - Object pooling, game state, NetworkCallable\n- [patterns-performance.md](patterns-performance.md) - Partial class, update handler, performance optimization\n- [api.md](api.md) - VRCPlayerApi, Networking, and UdonSharp API reference\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":29872,"content_sha256":"e41f281e0732227ba43c321ecb6ae81c09b10d599e0cfeb6ef324a38d09ce9a2"},{"filename":"references/patterns-video.md","content":"# UdonSharp Video Player Patterns\n\nState machine, server-time sync, late-joiner handling, AVPro texture stabilization,\nerror retry with fallback, synced playlist management, and platform-specific URL selection.\n\n## 1. Video Player State Machine\n\n### State Definitions\n\nUse a `byte` constant block for the state enum. `byte` is the smallest synced type (1 byte),\ncosts less bandwidth than `int`, and is sufficient for up to 255 distinct states.\n\n| Value | Name | Description |\n|-------|------|-------------|\n| `0` | `Idle` | No video loaded, player is dormant |\n| `1` | `Loading` | `PlayURL` called, waiting for network/decode |\n| `2` | `Ready` | Video loaded, buffered, not yet started |\n| `3` | `Playing` | Video actively playing |\n| `4` | `Paused` | Video paused mid-playback |\n| `5` | `Error` | Irrecoverable error; retry logic may attempt recovery |\n\n### State Transition Table\n\n| From \\ To | Idle | Loading | Ready | Playing | Paused | Error |\n|-----------|------|---------|-------|---------|--------|-------|\n| Idle | — | `PlayURL` | — | — | — | — |\n| Loading | Cancel | — | `OnVideoReady` | — | — | `OnVideoError` |\n| Ready | Stop | — | — | Play | — | — |\n| Playing | Stop | — | — | — | Pause | `OnVideoError` |\n| Paused | Stop | — | — | Resume | — | `OnVideoError` |\n| Error | Reset | Retry | — | — | — | — |\n\n### Code Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Video.Components;\nusing VRC.SDK3.Video.Components.AVPro;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class VideoPlayerStateMachine : UdonSharpBehaviour\n{\n // State constants (byte to minimise sync bandwidth)\n private const byte StateIdle = 0;\n private const byte StateLoading = 1;\n private const byte StateReady = 2;\n private const byte StatePlaying = 3;\n private const byte StatePaused = 4;\n private const byte StateError = 5;\n\n [Header(\"Player References\")]\n [SerializeField] private BaseVRCVideoPlayer _videoPlayer;\n\n [UdonSynced, FieldChangeCallback(nameof(PlayerState))]\n private byte _playerState = StateIdle;\n\n // Listener array for observer pattern\n [SerializeField] private UdonSharpBehaviour[] _listeners;\n\n // Listeners must implement a public method with this name\n private const string EVENT_STATE_CHANGED = \"OnVideoStateChanged\";\n\n public byte PlayerState\n {\n get => _playerState;\n set\n {\n _playerState = value;\n NotifyListeners();\n }\n }\n\n // Owner-only: transition to a new state\n private void SetState(byte newState)\n {\n if (!Networking.IsOwner(gameObject)) return;\n PlayerState = newState;\n RequestSerialization();\n }\n\n // === Video Events ===\n\n public override void OnVideoReady()\n {\n if (_playerState == StateLoading)\n {\n SetState(StateReady);\n }\n }\n\n public override void OnVideoStart()\n {\n SetState(StatePlaying);\n }\n\n public override void OnVideoEnd()\n {\n SetState(StateIdle);\n }\n\n public override void OnVideoError(VideoError error)\n {\n SetState(StateError);\n }\n\n // === Public API ===\n\n public void RequestPlay()\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n SetState(StateLoading);\n // Caller is responsible for calling _videoPlayer.PlayURL(url)\n }\n\n public void RequestPause()\n {\n if (_playerState != StatePlaying) return;\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n _videoPlayer.Pause();\n SetState(StatePaused);\n }\n\n public void RequestResume()\n {\n if (_playerState != StatePaused) return;\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n _videoPlayer.Play();\n SetState(StatePlaying);\n }\n\n public void RequestStop()\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n _videoPlayer.Stop();\n SetState(StateIdle);\n }\n\n // === Listener Notification ===\n\n private void NotifyListeners()\n {\n if (_listeners == null) return;\n foreach (UdonSharpBehaviour listener in _listeners)\n {\n if (listener != null)\n {\n listener.SendCustomEvent(EVENT_STATE_CHANGED);\n }\n }\n }\n}\n```\n\n> **Note on non-owner state**: Non-owners do not call `SetState` directly — they receive state changes via `FieldChangeCallback` on deserialization. Between `OnVideoStart` firing locally and the next deserialization, a non-owner's `_playerState` may be stale. UI components should check `activeHandler.IsPlaying` / `activeHandler.IsPaused` in addition to `_playerState` for accurate local display.\n\n> **Rate limit coordination**: The examples in this document call `_videoPlayer.PlayURL()` directly\n> for clarity. In production worlds with multiple video players, wrap all `PlayURL` calls through\n> a shared rate limit scheduler to avoid VRChat's 5-second rate limit collision.\n> See the [Rate Limit Resolver](patterns-performance.md#rate-limit-resolver) pattern.\n\n---\n\n## 2. Server-Time Playback Position Sync\n\n### Core Formula\n\nThe playback position is never synced directly. Instead, the owner records *when* they\nstarted the video (using server clock), and every client recomputes the current position\nindependently:\n\n```text\ncurrentPosition = syncStartTime\n + (Networking.GetServerTimeInMilliseconds() - syncClockTime) / 1000f\n * syncSpeed\n```\n\nThis formula is drift-resistant: a late joiner receives the same `syncClockTime` /\n`syncStartTime` pair and arrives at the same position regardless of when they joined.\n\n### Synced Variable Layout\n\n| Variable | Type | Purpose |\n|----------|------|---------|\n| `syncUrl` | `VRCUrl` | Currently playing URL |\n| `syncClockTime` | `int` | Server timestamp (ms) captured when sync was committed |\n| `syncStartTime` | `float` | Playback offset (seconds) at the moment the sync was committed |\n| `syncSpeed` | `float` | Playback rate (1.0 = normal, 0.5 = half-speed) |\n\n> **int precision note**: `Networking.GetServerTimeInMilliseconds()` returns a signed 32-bit\n> integer. It wraps after ~24.8 days of uptime. For sessions shorter than a few hours this is\n> irrelevant, but for always-on worlds consider using the difference modulo `int.MaxValue` or\n> switching to `GetServerTimeInSeconds()` (double) to avoid overflow.\n> **The examples below use `int` for simplicity; production code should convert to `long` or\n> `double` for sessions exceeding 24 hours.**\n\n### Drift Correction\n\nBecause video decoders can drift slightly, clients run a periodic check. If the difference\nbetween the expected position (formula result) and the actual player position exceeds a\nthreshold (~1 second), the client seeks to the correct position.\n\n### Code Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Video.Components;\nusing VRC.SDK3.Video.Components.AVPro;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class PlaybackTimeSynchronizer : UdonSharpBehaviour\n{\n [SerializeField] private BaseVRCVideoPlayer _videoPlayer;\n\n [UdonSynced] private VRCUrl _syncUrl = VRCUrl.Empty;\n [UdonSynced] private int _syncClockTime = 0; // server ms at commit\n [UdonSynced] private float _syncStartTime = 0f; // playback offset at commit\n [UdonSynced] private float _syncSpeed = 1f;\n\n private const float DriftThreshold = 1.0f; // seconds before corrective seek\n private const float DriftCheckPeriod = 10.0f; // seconds between checks\n private float _nextDriftCheck = 0f;\n private bool _isPlaying = false;\n\n // === Owner: commit current sync state ===\n\n public void UpdateSyncedPosition()\n {\n if (!Networking.IsOwner(gameObject)) return;\n\n _syncClockTime = Networking.GetServerTimeInMilliseconds();\n _syncStartTime = _videoPlayer.GetTime();\n _syncSpeed = 1f; // extend here for variable-speed support\n RequestSerialization();\n }\n\n // === All clients: apply synced state ===\n\n private float CalcExpectedPosition()\n {\n int elapsed = Networking.GetServerTimeInMilliseconds() - _syncClockTime;\n return _syncStartTime + (elapsed / 1000f) * _syncSpeed;\n }\n\n public void ApplySyncedPosition()\n {\n float expected = CalcExpectedPosition();\n float duration = _videoPlayer.GetDuration();\n\n // Don't seek past the end of the video\n if (duration > 0f && expected >= duration)\n {\n return;\n }\n\n _videoPlayer.SetTime(expected);\n }\n\n // === Playback state — consumers must call these when playback state changes ===\n // Note: drift correction is suppressed while paused to avoid spurious seeks.\n\n public void OnPlay()\n {\n _isPlaying = true;\n }\n\n public void OnPause()\n {\n _isPlaying = false;\n }\n\n // === Drift check (called from Update) ===\n\n void Update()\n {\n if (!_isPlaying) return;\n if (Time.time \u003c _nextDriftCheck) return;\n _nextDriftCheck = Time.time + DriftCheckPeriod;\n\n float expected = CalcExpectedPosition();\n float actual = _videoPlayer.GetTime();\n float drift = Mathf.Abs(expected - actual);\n\n if (drift > DriftThreshold)\n {\n _videoPlayer.SetTime(expected);\n }\n }\n\n public override void OnDeserialization()\n {\n ApplySyncedPosition();\n }\n}\n```\n\n---\n\n## 3. Late Joiner Video Sync\n\n### Flow\n\n```text\nOnDeserialization\n → URL in synced state?\n → PlayURL(syncUrl)\n → OnVideoReady\n → CalcExpectedPosition()\n → elapsed > duration? YES → stay Idle (video ended)\n NO → SetTime(expected) → Play\n```\n\nThe critical constraint: **seek before `OnVideoReady` is silently ignored by the\nvideo player**. Always wait for `OnVideoReady` to seek.\n\n### Edge Cases\n\n| Condition | Action |\n|-----------|--------|\n| Video has already ended (`elapsed >= duration`) | Set state to `Idle`, do not call `Play()` |\n| Duration is 0 (live stream) | Skip the elapsed check; always play from current position |\n| Same URL already loaded | Skip `PlayURL`; go straight to seek |\n\n### Code Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Video.Components;\nusing VRC.SDK3.Video.Components.AVPro;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class LateJoinerVideoSync : UdonSharpBehaviour\n{\n [SerializeField] private BaseVRCVideoPlayer _videoPlayer;\n\n [UdonSynced] private VRCUrl _syncUrl = VRCUrl.Empty;\n [UdonSynced] private int _syncClockTime = 0;\n [UdonSynced] private float _syncStartTime = 0f;\n\n private bool _pendingSeek = false;\n private VRCUrl _currentUrl;\n\n public override void OnDeserialization()\n {\n // Only load if there is a URL to play\n if (_syncUrl == null || string.IsNullOrEmpty(_syncUrl.Get())) return;\n\n // If already playing the same URL, just sync position without reloading\n if (_currentUrl != null && _currentUrl.Get() == _syncUrl.Get())\n {\n ApplySync();\n return;\n }\n\n _pendingSeek = true;\n _currentUrl = _syncUrl;\n // In production, route through UrlLoadScheduler (see Rate Limit Resolver pattern)\n _videoPlayer.PlayURL(_syncUrl);\n // Seek is deferred to OnVideoReady\n }\n\n public override void OnVideoReady()\n {\n if (!_pendingSeek) return;\n _pendingSeek = false;\n ApplySync();\n }\n\n private void ApplySync()\n {\n float elapsed = (Networking.GetServerTimeInMilliseconds() - _syncClockTime) / 1000f;\n float expected = _syncStartTime + elapsed;\n float duration = _videoPlayer.GetDuration();\n\n // Video ended before this player joined\n if (duration > 0f && expected >= duration)\n {\n _videoPlayer.Stop();\n return;\n }\n\n _videoPlayer.SetTime(expected);\n _videoPlayer.Play();\n }\n\n public override void OnVideoError(VideoError error)\n {\n _pendingSeek = false;\n }\n}\n```\n\n---\n\n## 4. AVPro Texture Blit Buffering\n\n### Problem\n\nAVPro's output texture is updated asynchronously by the native video decoder. Between\ndecoder frames, the texture reference may briefly point to an invalid or blank buffer,\ncausing visible flickering on the display surface.\n\n### Solution\n\nBlit the AVPro output into a stable `RenderTexture` every `LateUpdate`. Because the\n`RenderTexture` retains its last written content, viewers see the previous good frame\nrather than a blank one during the brief gap. Assign the `RenderTexture` (not the AVPro\nsource texture) to the display material or `RawImage`.\n\n> **Note**: Unity's built-in `VideoPlayer` component does **not** have this issue.\n> Apply this pattern only when using an AVPro-based video player component.\n\n### Namespace Required\n\n```csharp\nusing VRC.SDK3.Rendering; // for VRCGraphics.Blit\n```\n\n### Code Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Video.Components.AVPro;\nusing VRC.SDK3.Rendering;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class AVProTextureStabilizer : UdonSharpBehaviour\n{\n [Header(\"Source\")]\n [SerializeField] private VRCAvProVideoPlayer _avProPlayer;\n\n [Header(\"Output\")]\n [SerializeField] private RenderTexture _stableTexture;\n [SerializeField] private Renderer[] _displayRenderers;\n\n private bool _initialized = false;\n\n void Start()\n {\n Initialize();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n // Assign the stable RenderTexture to all display surfaces\n foreach (Renderer r in _displayRenderers)\n {\n if (r != null)\n {\n r.material.mainTexture = _stableTexture;\n }\n }\n }\n\n void LateUpdate()\n {\n Initialize(); // defensive guard\n\n // Retrieve the live AVPro texture\n Texture sourceTexture = _avProPlayer.GetCurrentTexture();\n\n // Source may be null immediately after video start or during seeks\n if (sourceTexture == null) return;\n\n // Blit into the stable RenderTexture, preserving last-good frame on null\n VRCGraphics.Blit(sourceTexture, _stableTexture);\n }\n}\n```\n\n---\n\n## 5. Video Error Retry with Player Fallback\n\n### VideoError Response Table\n\n| `VideoError` value | Meaning | Recommended action |\n|--------------------|---------|-------------------|\n| `InvalidURL` | URL is malformed or unsupported | Permanent — do not retry |\n| `AccessDenied` | Domain not on trusted list | Permanent — do not retry |\n| `RateLimited` | Too many requests to the CDN | Retry after **5.5 seconds** |\n| `PlayerError` | Decoder / codec mismatch | Retry N times; then try alternate player |\n| `Unknown` | Unclassified failure | Retry once; give up on second failure |\n\n### Retry Logic\n\n```text\nOnVideoError\n → permanent error? → SetState(Error), notify user, stop\n → retriable error?\n → retryCount \u003c maxRetries?\n → schedule RetryLoad after retryDelay seconds\n → if PlayerError and retryCount >= maxRetries/2 → swap player type\n → retryCount >= maxRetries → SetState(Error), give up\n```\n\n### Code Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Video.Components;\nusing VRC.SDK3.Video.Components.AVPro;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class VideoErrorHandler : UdonSharpBehaviour\n{\n [Header(\"Players (primary / fallback)\")]\n [SerializeField] private BaseVRCVideoPlayer _primaryPlayer;\n [SerializeField] private BaseVRCVideoPlayer _fallbackPlayer;\n\n [Header(\"Retry Configuration\")]\n [SerializeField] private int _maxRetries = 3;\n [SerializeField] private float _defaultRetryDelay = 2.0f;\n [SerializeField] private float _rateLimitedDelay = 5.5f;\n\n [UdonSynced] private VRCUrl _currentUrl = VRCUrl.Empty;\n\n private int _retryCount = 0;\n private BaseVRCVideoPlayer _activePlayer;\n private bool _usingFallback = false;\n\n void Start()\n {\n _activePlayer = _primaryPlayer;\n }\n\n // Call this to start playback\n public void LoadUrl(VRCUrl url)\n {\n if (!Networking.IsOwner(gameObject))\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n\n _currentUrl = url;\n _retryCount = 0;\n _usingFallback = false;\n _activePlayer = _primaryPlayer;\n\n RequestSerialization();\n _activePlayer.PlayURL(_currentUrl);\n }\n\n public override void OnVideoError(VideoError error)\n {\n // Permanent errors: don't retry\n if (error == VideoError.InvalidURL || error == VideoError.AccessDenied)\n {\n NotifyPermanentError(error);\n return;\n }\n\n // Rate limit: long wait, same player\n if (error == VideoError.RateLimited)\n {\n _retryCount++;\n if (_retryCount > _maxRetries)\n {\n NotifyPermanentError(error);\n return;\n }\n SendCustomEventDelayedSeconds(nameof(RetryLoad), _rateLimitedDelay);\n return;\n }\n\n // PlayerError or Unknown: increment counter\n _retryCount++;\n\n if (_retryCount > _maxRetries)\n {\n NotifyPermanentError(error);\n return;\n }\n\n // Switch to fallback player at the midpoint of retries for PlayerError\n if (error == VideoError.PlayerError && _retryCount > _maxRetries / 2)\n {\n _usingFallback = !_usingFallback;\n _activePlayer = _usingFallback ? _fallbackPlayer : _primaryPlayer;\n }\n\n SendCustomEventDelayedSeconds(nameof(RetryLoad), _defaultRetryDelay);\n }\n\n public void RetryLoad()\n {\n if (_currentUrl == null || string.IsNullOrEmpty(_currentUrl.Get())) return;\n // In production, route through UrlLoadScheduler (see Rate Limit Resolver pattern)\n _activePlayer.PlayURL(_currentUrl);\n }\n\n private void NotifyPermanentError(VideoError error)\n {\n _retryCount = 0;\n Debug.LogWarning($\"[VideoErrorHandler] Permanent error: {error}. Giving up.\");\n // Notify state machine or UI here\n }\n\n public override void OnVideoReady()\n {\n _retryCount = 0; // successful load clears the counter\n }\n}\n```\n\n---\n\n## 6. Synced Playlist / Queue Management\n\n### Queue Structure\n\n| Synced variable | Type | Purpose |\n|-----------------|------|---------|\n| `_queueUrls` | `VRCUrl[]` | Ordered list of URLs |\n| `_queuePlayerTypes` | `byte[]` | Player type per entry (0 = Unity, 1 = AVPro) |\n| `_queueHead` | `int` | Index of the currently playing entry |\n\nThe queue is FIFO: `_queueHead` advances on each video end. Add appends to the logical\ntail; the owner compacts the array only when removing mid-queue entries.\n\n### Repeat Modes\n\n| Mode constant | Behaviour |\n|---------------|-----------|\n| `RepeatNone` | Stop after the last entry |\n| `RepeatOne` | Replay the current `_queueHead` URL indefinitely |\n| `RepeatAll` | Reset `_queueHead` to 0 after the last entry |\n\n### Code Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Video.Components;\nusing VRC.SDK3.Video.Components.AVPro;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class SyncedPlaylistManager : UdonSharpBehaviour\n{\n private const byte RepeatNone = 0;\n private const byte RepeatOne = 1;\n private const byte RepeatAll = 2;\n\n [Header(\"Player References\")]\n [SerializeField] private BaseVRCVideoPlayer _unityPlayer;\n [SerializeField] private BaseVRCVideoPlayer _avProPlayer;\n\n [UdonSynced] private VRCUrl[] _queueUrls = new VRCUrl[0];\n [UdonSynced] private byte[] _queuePlayerTypes = new byte[0];\n [UdonSynced] private int _queueHead = 0;\n [UdonSynced] private byte _repeatMode = RepeatNone;\n\n // Owner-only: add a URL to the end of the queue\n public void AddToQueue(VRCUrl url, byte playerType)\n {\n if (!Networking.IsOwner(gameObject)) return;\n\n int len = _queueUrls.Length;\n VRCUrl[] newUrls = new VRCUrl[len + 1];\n byte[] newTypes = new byte[len + 1];\n\n for (int i = 0; i \u003c len; i++)\n {\n newUrls[i] = _queueUrls[i];\n newTypes[i] = _queuePlayerTypes[i];\n }\n newUrls[len] = url;\n newTypes[len] = playerType;\n\n _queueUrls = newUrls;\n _queuePlayerTypes = newTypes;\n RequestSerialization();\n\n // Start playback if queue was empty\n if (len == 0) PlayCurrentEntry();\n }\n\n // Owner-only: advance to the next queue entry\n public void AdvanceQueue()\n {\n if (!Networking.IsOwner(gameObject)) return;\n\n if (_repeatMode == RepeatOne)\n {\n PlayCurrentEntry(); // replay same index\n return;\n }\n\n int nextIndex = _queueHead + 1;\n\n if (nextIndex >= _queueUrls.Length)\n {\n if (_repeatMode == RepeatAll)\n {\n _queueHead = 0;\n }\n else\n {\n // RepeatNone: queue exhausted\n _queueHead = _queueUrls.Length;\n RequestSerialization();\n return;\n }\n }\n else\n {\n _queueHead = nextIndex;\n }\n\n RequestSerialization();\n PlayCurrentEntry();\n }\n\n // Owner-only: shuffle remaining entries (Fisher-Yates on indices after head)\n public void ShuffleRemaining()\n {\n if (!Networking.IsOwner(gameObject)) return;\n\n int start = _queueHead + 1;\n int end = _queueUrls.Length - 1;\n\n for (int i = end; i > start; i--)\n {\n int j = Random.Range(start, i + 1);\n\n VRCUrl tempUrl = _queueUrls[i];\n byte tempType = _queuePlayerTypes[i];\n _queueUrls[i] = _queueUrls[j];\n _queuePlayerTypes[i] = _queuePlayerTypes[j];\n _queueUrls[j] = tempUrl;\n _queuePlayerTypes[j] = tempType;\n }\n\n RequestSerialization();\n }\n\n public void SetRepeatMode(byte mode)\n {\n if (!Networking.IsOwner(gameObject)) return;\n _repeatMode = mode;\n RequestSerialization();\n }\n\n private void PlayCurrentEntry()\n {\n if (_queueHead \u003c 0 || _queueHead >= _queueUrls.Length) return;\n\n VRCUrl url = _queueUrls[_queueHead];\n byte playerType = _queuePlayerTypes[_queueHead];\n\n BaseVRCVideoPlayer player = (playerType == 1) ? _avProPlayer : _unityPlayer;\n player.PlayURL(url);\n }\n\n public override void OnVideoEnd()\n {\n AdvanceQueue();\n }\n}\n```\n\n---\n\n## 7. Platform-Specific URL Selection\n\n### Problem\n\nPC and Quest (Android) support different video codecs, resolutions, and bitrates. A URL\nthat works on PC may fail or perform poorly on Quest, and vice versa.\n\n### Solution A — Compile-Time (Recommended for URL Arrays)\n\nUse `#if UNITY_ANDROID` preprocessor directives to select between two sets of URL arrays.\nThe inactive branch is stripped at build time, resulting in smaller bundles.\n\n### Solution B — Runtime\n\nRead a platform tag at runtime and choose from paired fields. Suitable for individual\nURL pairs where separate compile-time builds are impractical (e.g., single-URL picker UI).\n\n### Code Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Video.Components;\nusing VRC.SDK3.Video.Components.AVPro;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class PlatformUrlSelector : UdonSharpBehaviour\n{\n // === Solution A: Compile-time selection (URL arrays) ===\n#if UNITY_ANDROID\n [Header(\"Quest URLs\")]\n [SerializeField] private VRCUrl[] _platformUrls;\n#else\n [Header(\"PC URLs\")]\n [SerializeField] private VRCUrl[] _platformUrls;\n#endif\n\n // === Solution B: Runtime selection (individual URL pairs) ===\n\n [Header(\"Runtime URL Pairs\")]\n [SerializeField] private VRCUrl _pcUrl;\n [SerializeField] private VRCUrl _questUrl;\n\n [SerializeField] private BaseVRCVideoPlayer _videoPlayer;\n\n // Play a specific index from the platform-appropriate array (Solution A)\n public void PlayByIndex(int index)\n {\n if (index \u003c 0 || index >= _platformUrls.Length) return;\n // In production, route through UrlLoadScheduler (see Rate Limit Resolver pattern)\n _videoPlayer.PlayURL(_platformUrls[index]);\n }\n\n // Play using the runtime pair (Solution B)\n public void PlayWithRuntimeSelection()\n {\n#if UNITY_ANDROID\n VRCUrl selected = _questUrl;\n#else\n VRCUrl selected = _pcUrl;\n#endif\n if (selected != null && !string.IsNullOrEmpty(selected.Get()))\n {\n // In production, route through UrlLoadScheduler (see Rate Limit Resolver pattern)\n _videoPlayer.PlayURL(selected);\n }\n }\n}\n```\n\n> **Recommendation**: Use compile-time selection (`#if UNITY_ANDROID`) for URL arrays\n> and playlists — the branch is eliminated entirely at build time, reducing asset size.\n> Use runtime selection only when a single pair of URLs needs to be chosen dynamically\n> (for example, when the URL is entered by a user at runtime).\n\n---\n\n## See Also\n\n- [events.md](events.md) — `OnVideoReady`, `OnVideoEnd`, `OnVideoError`, and all video callbacks\n- [patterns-networking.md](patterns-networking.md) — Ownership model, `RequestSerialization`, synced array helpers\n- [patterns-performance.md](patterns-performance.md) — Frame budget management, `LateUpdate` cost\n- [web-loading.md](web-loading.md) — `VRCUrl` trusted domains and rate limits\n- [image-loading-vram.md](image-loading-vram.md) — GPU texture lifecycle (applies to `RenderTexture` targets)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":26209,"content_sha256":"bcd1b01db2716b76f2a1257e221c087ce47c0696e9a7f772fda9051d63e4e4c8"},{"filename":"references/persistence.md","content":"# VRChat Persistence Reference\n\nComprehensive guide to persistent data storage in VRChat worlds (SDK 3.7.4+).\n\n**Supported SDK Versions**: 3.7.4+ (2024 onward)\n\n## Overview\n\nVRChat Persistence allows saving data across sessions. There are two main systems:\n\n| System | Purpose | Best for |\n|--------|---------|----------|\n| **PlayerData** | Key-value storage per player | Settings, scores, unlocks |\n| **PlayerObject** | Synced UdonBehaviour per player | Complex player state, frequent updates |\n\n## Storage Layer Decision Tree\n\nBefore choosing an API, decide which storage layer fits your data. UdonSharp offers five layers with different scope and lifetime:\n\n```text\nDoes another player need to see this value?\n├─ No ─── Does it need to survive a rejoin / session change?\n│ ├─ No ──→ Non-synced field (plain private field on UdonSharpBehaviour)\n│ └─ Yes ─→ PlayerData (key-value, per-player, cross-session)\n│\n└─ Yes ── Is it tied to a specific player, or shared by the world?\n │\n ├─ Per-player ─── Does the player need a synced GameObject in the scene?\n │ ├─ No ──→ PlayerData (other players read via OnPlayerDataUpdated;\n │ │ late joiners receive it via OnPlayerRestored)\n │ └─ Yes ─→ PlayerObject (synced UdonBehaviour per player)\n │\n └─ Shared ─────── Does a late joiner need to see the current value?\n ├─ No ──→ SendCustomNetworkEvent (fire-and-forget, no payload)\n └─ Yes ─→ [UdonSynced] variable (Continuous or Manual)\n```\n\n### Quick Reference Table\n\n| Layer | Scope | Lifetime | Capacity | Typical use |\n|-------|-------|----------|----------|-------------|\n| **Non-synced field** | Self only | Until scene reload | Unlimited | UI state, timers, cooldown flags |\n| **[UdonSynced]** | All players in instance | Instance lifetime (late joiners receive current value) | ~200 B continuous / ~280KB (280,496 bytes) manual per behaviour | Shared game state, scores, toggles |\n| **SendCustomNetworkEvent** | All players in instance | Instant (no persistence, late joiners miss it) | Event name only (no payload) | Sound effects, particle triggers, one-shot notifications |\n| **PlayerData** | Per player, readable by all | Cross-session (permanent until deleted) | 100 KB per player per world | Settings, unlocks, high scores |\n| **PlayerObject** | Per player, synced behaviour | Instance lifetime (+ cross-session if `VRCEnablePersistence` is on) | One UdonBehaviour per player | Complex per-player state with frequent updates |\n\n### Common Mistakes\n\n| Mistake | Why it fails | Fix |\n|---------|-------------|-----|\n| Using `[UdonSynced]` for data that only the local player needs | Wastes sync bandwidth, adds ownership complexity | Use a non-synced field |\n| Using `SendCustomNetworkEvent` for state that late joiners need | Late joiners never receive past events | Use `[UdonSynced]` for the state, events for one-shot effects |\n| Duplicating the same value in both `[UdonSynced]` and `PlayerData` | Two sources of truth drift apart; hard to debug | Pick one: synced for real-time shared state, PlayerData for cross-session |\n| Writing PlayerData before `OnPlayerRestored` fires | Write is silently ignored | Always guard writes with a `_dataReady` flag set in `OnPlayerRestored` |\n| Changing a PlayerObject prefab or scene wiring without checking saved Network IDs | Persistence keys are bound to Network IDs; breaking the mapping loses saved data | Audit Network IDs before restructuring; keep a mapping document |\n\n> **Cross-reference:** For choosing between Continuous, Manual, and NoVariableSync *within* the synced layer, see the sync selection rule in [../rules/udonsharp-sync-selection.md](../rules/udonsharp-sync-selection.md). For bandwidth budgeting, see [networking-bandwidth.md](networking-bandwidth.md).\n\n---\n\n## PlayerData\n\n### Basic Concept\n\nPlayerData is a key-value database for storing simple data per player:\n\n- Each world can store up to **100 KB** of PlayerData per player\n- Data persists across sessions and instances\n- Only the local player can modify their own data\n- Other players can read (but not write) your data\n\n### Setup\n\n1. Enable persistence on your UdonBehaviour in the Inspector\n2. Wait for `OnPlayerRestored` before accessing data\n3. Use `PlayerData` static methods to read/write\n\n### Reading Data\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Persistence;\n\npublic class LoadPlayerData : UdonSharpBehaviour\n{\n private bool dataReady = false;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n dataReady = true;\n\n // TryGet pattern - returns false if key doesn't exist\n if (PlayerData.TryGetInt(player, \"highScore\", out int score))\n {\n Debug.Log($\"Loaded high score: {score}\");\n }\n else\n {\n Debug.Log(\"No saved high score found\");\n }\n\n // Multiple values\n PlayerData.TryGetString(player, \"username\", out string name);\n PlayerData.TryGetFloat(player, \"volume\", out float vol);\n PlayerData.TryGetBool(player, \"tutorialDone\", out bool tutorial);\n }\n}\n```\n\n### Writing Data\n\n```csharp\npublic void SaveHighScore(int score)\n{\n if (!dataReady)\n {\n Debug.LogWarning(\"Data not ready yet!\");\n return;\n }\n\n VRCPlayerApi local = Networking.LocalPlayer;\n\n // Can only write to your own data\n PlayerData.SetInt(local, \"highScore\", score);\n PlayerData.SetString(local, \"lastPlayed\", System.DateTime.UtcNow.ToString());\n}\n```\n\n### Supported Types\n\n| Type | Methods | Size |\n|------|--------|------|\n| `bool` | `SetBool` / `TryGetBool` | 1 byte |\n| `int` | `SetInt` / `TryGetInt` | 4 bytes |\n| `float` | `SetFloat` / `TryGetFloat` | 4 bytes |\n| `double` | `SetDouble` / `TryGetDouble` | 8 bytes |\n| `string` | `SetString` / `TryGetString` | Variable |\n| `byte[]` | `SetBytes` / `TryGetBytes` | Variable |\n| `Vector2` | `SetVector2` / `TryGetVector2` | 8 bytes |\n| `Vector3` | `SetVector3` / `TryGetVector3` | 12 bytes |\n| `Vector4` | `SetVector4` / `TryGetVector4` | 16 bytes |\n| `Quaternion` | `SetQuaternion` / `TryGetQuaternion` | 16 bytes |\n| `Color` | `SetColor` / `TryGetColor` | 16 bytes |\n\n### Utility Methods\n\n```csharp\n// Check if key exists\nbool exists = PlayerData.HasKey(player, \"keyName\");\n\n// Delete a key\nPlayerData.DeleteKey(Networking.LocalPlayer, \"oldKey\");\n\n// Get all keys (for debugging)\nstring[] keys = PlayerData.GetKeys(player);\n```\n\n### Checking Data Usage (SDK 3.10.0+)\n\nSince SDK 3.10.0, VRChat exposes runtime APIs to query storage usage programmatically via\n`VRCPlayerApi` methods and the `OnPersistenceUsageUpdated` event. See\n[Persistence Storage Information API](#persistence-storage-information-api-sdk-3100) for the\nfull API reference and a complete monitoring example.\n\n## PlayerObject\n\n### Basic Concept\n\nPlayerObject is a more powerful system for per-player state management. When a player joins a world, VRChat automatically instantiates one copy of a designated prefab for each player and assigns that instance to them:\n\n- Each player gets their own auto-instantiated instance of the PlayerObject prefab\n- Instances are **owned by the player they belong to**\n- Supports **synced variables** (`[UdonSynced]`) for real-time visibility to all players\n- Supports **multiple UdonBehaviours** on the same prefab (combines toward the 100 KB limit)\n- Better for **frequently changing data** that must also be visible to others\n- Up to **100 KB** per player (separate quota from PlayerData's 100 KB)\n- Data stored on VRChat servers and is accessible cross-platform and cross-instance\n\n### Required Components\n\nAll three components must be on the same root GameObject of your prefab:\n\n| Component | Purpose |\n|-----------|---------|\n| `VRCPlayerObject` | Marks the prefab as a per-player object; triggers auto-instantiation |\n| `UdonBehaviour` | Holds `[UdonSynced]` variables and logic |\n| `VRCEnablePersistence` | Opts the UdonBehaviour's synced data into cloud persistence |\n\n> Note: `VRCEnablePersistence` must be placed on the **same GameObject** as each `UdonBehaviour` whose data you want persisted. A PlayerObject prefab with no `VRCEnablePersistence` still instantiates per-player but does not persist data.\n\n### Setup\n\n1. Create a prefab in your project\n2. Add `VRC Player Object` component to the root of the prefab\n3. Add your `UdonSharpBehaviour` script as an `UdonBehaviour` component\n4. Add `VRC Enable Persistence` component to the same root GameObject\n5. Place **one instance** of the prefab in the scene — VRChat handles instantiation for all players automatically\n\n### OnPlayerRestored on PlayerObjects\n\n`OnPlayerRestored` fires on the PlayerObject's UdonBehaviour when that player's persistent data has been loaded from the cloud. It fires **once per player** and is your signal that the `[UdonSynced]` fields contain the restored values.\n\nKey behaviors:\n- Fires on **all PlayerObject instances** in the scene, not just the local player's\n- `player` argument identifies which player's data was loaded\n- The instance is **not valid for gameplay use** until `OnPlayerRestored` has fired for it\n- Late-joining players will have `OnPlayerRestored` fire for all already-present players\n\n### Usage Example: Basic PlayerObject with Persistence\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n// VRCPlayerObject + VRCEnablePersistence must be on this same GameObject in Inspector\n// Note: Networking.SetOwner() is NOT needed here — VRChat automatically assigns\n// ownership of each PlayerObject instance to the player it belongs to.\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class PlayerStats : UdonSharpBehaviour\n{\n [UdonSynced] public int level = 1;\n [UdonSynced] public int experience = 0;\n [UdonSynced] public int gold = 100;\n\n private bool dataRestored = false;\n private VRCPlayerApi ownerPlayer;\n\n // Fires when this player's persistent data has been loaded from the cloud\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n // Each PlayerObject instance only holds data for its own player.\n // Networking.GetOwner returns the player this instance belongs to.\n if (!Networking.IsOwner(player, gameObject)) return;\n\n ownerPlayer = player;\n dataRestored = true;\n\n if (player.isLocal)\n {\n Debug.Log($\"My stats loaded: Level {level}, XP {experience}, Gold {gold}\");\n }\n }\n\n // Only the owning player should modify their own synced variables\n public void AddExperience(int xp)\n {\n if (!Networking.IsOwner(gameObject)) return;\n if (!dataRestored) return;\n\n experience += xp;\n\n // Simple level threshold: 100 XP per level\n int threshold = level * 100;\n if (experience >= threshold)\n {\n level++;\n experience -= threshold;\n Debug.Log($\"Level up! Now level {level}\");\n }\n\n RequestSerialization(); // Sync to all clients and persist to cloud\n }\n\n public void SpendGold(int amount)\n {\n if (!Networking.IsOwner(gameObject)) return;\n if (!dataRestored) return;\n if (gold \u003c amount) return;\n\n gold -= amount;\n RequestSerialization();\n }\n}\n```\n\n### Usage Example: OnPlayerRestored with Late-Joiner Safety\n\nA late joiner receives `OnPlayerRestored` for **all** players already in the instance. Guard\nagainst acting on other players' data if you only care about the local player.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n// VRChat automatically assigns ownership of each PlayerObject to its player.\n// Networking.SetOwner() is not required for PlayerObject behaviours.\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class PlayerBadge : UdonSharpBehaviour\n{\n [UdonSynced] public int prestigeRank = 0;\n [UdonSynced] public bool hasBetaBadge = false;\n\n private bool initialized = false;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n // This fires for every player's PlayerObject, not just the local one.\n // Always check ownership so you don't act on another player's instance.\n if (!Networking.IsOwner(player, gameObject)) return;\n\n initialized = true;\n\n if (player.isLocal)\n {\n // Safe to read own restored data here\n Debug.Log($\"Badge loaded — Prestige: {prestigeRank}, Beta: {hasBetaBadge}\");\n ApplyBadgeVisuals();\n }\n else\n {\n // Another player's object was restored; update their visible badge\n ApplyBadgeVisuals();\n }\n }\n\n // Called by world logic when the local player earns prestige\n public void GrantPrestige()\n {\n if (!Networking.IsOwner(gameObject)) return;\n if (!initialized) return;\n\n prestigeRank++;\n RequestSerialization();\n ApplyBadgeVisuals();\n }\n\n private void ApplyBadgeVisuals()\n {\n // Update badge renderer, UI, etc. based on current field values\n Debug.Log($\"Applying badge visuals: rank={prestigeRank}\");\n }\n}\n```\n\n## PlayerData vs PlayerObject\n\n| Aspect | PlayerData | PlayerObject |\n|--------|-----------|-------------|\n| Type | Key-value store | Synced UdonBehaviour on auto-instantiated prefab |\n| Storage quota | 100 KB per player | 100 KB per player (separate from PlayerData) |\n| API access | `PlayerData.SetInt()` / `TryGetInt()` static methods | Direct `[UdonSynced]` field access |\n| Visibility to others | Not synced (local read of others' data via API) | Fully synced via `[UdonSynced]` + `RequestSerialization()` |\n| Data format | Typed key-value pairs | Arbitrary serializable fields |\n| Update cost | Per-write cloud write | Normal UdonSynced bandwidth (~11 KB/s total) |\n| Complexity | Low — simple method calls | Higher — requires prefab setup and ownership logic |\n| Best for | Settings, scores, unlocks that rarely change | Per-player game state visible to all, frequent updates |\n| Requires `OnPlayerRestored` guard | Yes | Yes |\n| Available since | SDK 3.7.4 | SDK 3.7.4 |\n\n### Selection Guidelines\n\n**Use PlayerData when:**\n- Storing player preferences (volume, graphics quality)\n- Recording one-time unlocks (achievements, cosmetics)\n- Saving high scores and statistics\n- Data changes infrequently (not every frame)\n- You do not need other players to see the values in real time\n\n**Use PlayerObject when:**\n- Managing real-time player stats (health, inventory, currency)\n- Data must be visible and synced to other players\n- State is complex enough to benefit from full UdonBehaviour logic\n- You need multiple tightly-coupled variables updated together atomically\n\n## Persistence Storage Information API (SDK 3.10.0+)\n\nSince SDK 3.10.0, VRChat exposes methods to query how much persistence storage each player is using. This applies to **both** PlayerData and PlayerObject data combined.\n\n### API Methods\n\n| Method | Returns | Description |\n|--------|---------|-------------|\n| `player.GetPlayerDataStorageUsage()` | `int` (bytes) | Current persistence bytes used by this player |\n| `player.GetPlayerDataStorageLimit()` | `int` (bytes) | Maximum bytes allowed (typically 102400 = 100 KB) |\n| `player.RequestStorageUsageUpdate()` | `void` | Requests a fresh usage value from the server |\n\n### OnPersistenceUsageUpdated Event\n\n`OnPersistenceUsageUpdated` fires on the local player's UdonBehaviours when updated storage\nusage data is available (e.g., after a `RequestStorageUsageUpdate()` call or after a write).\nThe event signature takes the player whose usage changed.\n\n### Storage Monitoring Example\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Persistence;\n\npublic class StorageMonitor : UdonSharpBehaviour\n{\n [SerializeField] private UnityEngine.UI.Text usageLabel;\n\n // How often (seconds) to request a fresh usage figure\n private const float RefreshInterval = 30f;\n\n private bool dataReady = false;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n dataReady = true;\n\n // Show initial usage and schedule periodic refresh\n ShowUsage(player);\n SendCustomEventDelayedSeconds(nameof(RequestRefresh), RefreshInterval);\n }\n\n // Called by VRChat when fresh storage usage data is available.\n // Always fires on the local player's UdonBehaviours.\n public override void OnPersistenceUsageUpdated()\n {\n ShowUsage(Networking.LocalPlayer);\n }\n\n // Periodic refresh event\n public void RequestRefresh()\n {\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local == null || !local.IsValid()) return;\n\n local.RequestStorageUsageUpdate();\n\n // Schedule next refresh\n SendCustomEventDelayedSeconds(nameof(RequestRefresh), RefreshInterval);\n }\n\n private void ShowUsage(VRCPlayerApi player)\n {\n if (player == null || !player.IsValid()) return;\n\n int used = player.GetPlayerDataStorageUsage();\n int limit = player.GetPlayerDataStorageLimit();\n float percent = limit > 0 ? (used / (float)limit) * 100f : 0f;\n\n string text = $\"Storage: {used} / {limit} bytes ({percent:F1}%)\";\n Debug.Log(text);\n\n if (usageLabel != null)\n {\n usageLabel.text = text;\n }\n\n if (used > limit * 0.9f)\n {\n Debug.LogWarning(\"[StorageMonitor] Approaching persistence storage limit!\");\n }\n }\n}\n```\n\n### When to Use the Storage API\n\n- Display a storage usage meter in a settings or debug UI\n- Warn players before they hit the 100 KB limit\n- Gate \"save\" actions if usage is critically high\n- Debug storage growth during development\n\n## Storage Limits\n\n### PlayerData Limits\n\n| Limit | Value |\n|-------|-------|\n| Total per player per world | 100 KB |\n| String max length | ~50 characters |\n| Key name max length | 128 characters |\n\n### PlayerObject Limits\n\n| Limit | Value |\n|-------|-------|\n| Total per player per world | 100 KB |\n| Per UdonBehaviour with VRC Enable Persistence | 108 bytes per variable type |\n\n### Bandwidth Considerations\n\n- PlayerData writes are **not rate-limited** but should be used sparingly\n- PlayerObject uses normal sync bandwidth (~11 KB/s total)\n- Avoid saving every frame; throttle to significant changes\n\n## Error Handling\n\n```csharp\npublic void SafeSaveData(string key, int value)\n{\n VRCPlayerApi local = Networking.LocalPlayer;\n\n if (local == null || !local.IsValid())\n {\n Debug.LogError(\"Local player not valid\");\n return;\n }\n\n // Check if we've restored yet\n if (!dataReady)\n {\n Debug.LogWarning(\"Waiting for OnPlayerRestored\");\n return;\n }\n\n PlayerData.SetInt(local, key, value);\n}\n```\n\n## Common Patterns\n\n### Settings Manager\n\n```csharp\npublic class SettingsManager : UdonSharpBehaviour\n{\n [SerializeField] private UnityEngine.UI.Slider volumeSlider;\n [SerializeField] private UnityEngine.UI.Toggle musicToggle;\n\n private bool initialized = false;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n // Load settings\n if (PlayerData.TryGetFloat(player, \"musicVolume\", out float vol))\n {\n volumeSlider.value = vol;\n }\n\n if (PlayerData.TryGetBool(player, \"musicEnabled\", out bool enabled))\n {\n musicToggle.isOn = enabled;\n }\n\n initialized = true;\n }\n\n // Called by UI events\n public void OnVolumeChanged()\n {\n if (!initialized) return;\n PlayerData.SetFloat(Networking.LocalPlayer, \"musicVolume\", volumeSlider.value);\n }\n\n public void OnMusicToggled()\n {\n if (!initialized) return;\n PlayerData.SetBool(Networking.LocalPlayer, \"musicEnabled\", musicToggle.isOn);\n }\n}\n```\n\n### Daily Login Reward\n\n```csharp\npublic class DailyReward : UdonSharpBehaviour\n{\n public int rewardAmount = 100;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n string today = System.DateTime.UtcNow.ToString(\"yyyy-MM-dd\");\n\n if (PlayerData.TryGetString(player, \"lastLogin\", out string lastLogin))\n {\n if (lastLogin != today)\n {\n // New day - give reward\n GiveReward(player);\n PlayerData.SetString(player, \"lastLogin\", today);\n }\n else\n {\n Debug.Log(\"Already claimed today's reward\");\n }\n }\n else\n {\n // First time player\n GiveReward(player);\n PlayerData.SetString(player, \"lastLogin\", today);\n }\n }\n\n private void GiveReward(VRCPlayerApi player)\n {\n if (PlayerData.TryGetInt(player, \"gold\", out int gold))\n {\n PlayerData.SetInt(player, \"gold\", gold + rewardAmount);\n }\n else\n {\n PlayerData.SetInt(player, \"gold\", rewardAmount);\n }\n\n Debug.Log($\"Daily reward: +{rewardAmount} gold!\");\n }\n}\n```\n\n### Data Aging / Pruning\n\nWhen using PlayerData to accumulate records over time (visit logs, event history, activity\ntimestamps), the stored JSON can grow unbounded and eventually hit the **100 KB per-player\nstorage limit**. A date-based pruning strategy trims old entries on each session start so\nthe data footprint stays predictable.\n\n**Algorithm overview:**\n\n1. Read the JSON dictionary from PlayerData in `OnPlayerRestored`\n2. Group entries by calendar date (derived from Unix-timestamp keys)\n3. Sort the date groups in descending order (newest first)\n4. Keep only the most recent N days of data (e.g., 7 days)\n5. Rebuild the dictionary with retained entries only\n6. Write the pruned JSON back to PlayerData\n\n```csharp\nusing System;\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDK3.Data;\nusing VRC.SDK3.Persistence;\nusing VRC.SDKBase;\n\n/// \u003csummary>\n/// Demonstrates date-based pruning of accumulated PlayerData entries.\n/// Each entry is keyed by a Unix timestamp (seconds). On restore, entries\n/// older than \u003csee cref=\"retainDays\"/> calendar days are discarded.\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class DataAgingExample : UdonSharpBehaviour\n{\n /// \u003csummary>How many calendar days of data to keep.\u003c/summary>\n [SerializeField] private int retainDays = 7;\n\n // Use a GUID suffix in real projects to avoid key collisions\n private const string DataKey = \"AppName_ActivityLog_a1b2c3d4\";\n\n private bool dataReady = false;\n\n public override void OnPlayerRestored(VRCPlayerApi player)\n {\n if (!player.isLocal) return;\n\n dataReady = true;\n\n // Initialize with an empty JSON object if no data exists yet\n if (!PlayerData.HasKey(player, DataKey))\n {\n PlayerData.SetString(player, DataKey, \"{}\");\n return;\n }\n\n if (!PlayerData.TryGetString(player, DataKey, out string json))\n {\n return;\n }\n\n PruneOldEntries(player, json);\n }\n\n /// \u003csummary>\n /// Groups entries by local calendar date, keeps the most recent\n /// \u003csee cref=\"retainDays\"/> days, and writes the trimmed data back.\n /// \u003c/summary>\n private void PruneOldEntries(VRCPlayerApi player, string json)\n {\n if (!VRCJson.TryDeserializeFromJson(json, out DataToken rootToken))\n {\n Debug.LogWarning(\"[DataAging] Failed to parse stored JSON\");\n return;\n }\n\n DataDictionary allEntries = rootToken.DataDictionary;\n DataList entryKeys = allEntries.GetKeys();\n\n if (entryKeys.Count == 0) return;\n\n // --- Step 1: Group entry keys by calendar date ---\n // dateGroups maps \"date-as-unix-string\" -> DataList of original keys\n DataDictionary dateGroups = new DataDictionary();\n\n for (int i = 0; i \u003c entryKeys.Count; i++)\n {\n string keyStr = entryKeys[i].String;\n\n if (!long.TryParse(keyStr, out long unixSeconds))\n {\n continue;\n }\n\n // Normalize to midnight of that calendar day using arithmetic.\n // Avoids fragile DateTime-to-DateTimeOffset explicit cast in UdonSharp.\n // Note: this produces a UTC-midnight bucket; all entries use the same\n // reference frame so date grouping remains consistent.\n long dayStartSeconds = (unixSeconds / 86400L) * 86400L;\n string dateKey = dayStartSeconds.ToString();\n\n if (!dateGroups.ContainsKey(dateKey))\n {\n dateGroups[dateKey] = new DataList();\n }\n\n dateGroups[dateKey].DataList.Add(entryKeys[i]);\n }\n\n // --- Step 2: Sort dates descending (newest first) ---\n DataList dateList = dateGroups.GetKeys().DeepClone();\n dateList.Sort();\n dateList.Reverse();\n\n // Account for whether today's date is already present in the data.\n // If today is included, keep retainDays entries; otherwise keep\n // retainDays - 1 so that today (once recorded) stays within budget.\n // Compute today's midnight Unix timestamp using arithmetic (avoids cast fragility)\n long nowSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();\n long todayStartSeconds = (nowSeconds / 86400L) * 86400L;\n string todayKey = todayStartSeconds.ToString();\n\n bool todayPresent = dateGroups.ContainsKey(todayKey);\n int safeRetainDays = Mathf.Max(1, retainDays);\n int keepCount = Mathf.Max(1, todayPresent ? safeRetainDays : safeRetainDays - 1);\n\n // No pruning needed if the date count is within limits\n if (dateList.Count \u003c= keepCount) return;\n\n // --- Step 3: Rebuild dictionary with only the retained days ---\n DataDictionary pruned = new DataDictionary();\n\n for (int d = 0; d \u003c keepCount; d++)\n {\n DataList keys = dateGroups[dateList[d].String].DataList;\n\n for (int k = 0; k \u003c keys.Count; k++)\n {\n string origKey = keys[k].String;\n pruned[origKey] = allEntries[origKey];\n }\n }\n\n // --- Step 4: Serialize and write back ---\n if (VRCJson.TrySerializeToJson(pruned, JsonExportType.Minify, out DataToken result))\n {\n PlayerData.SetString(player, DataKey, result.String);\n\n int removed = entryKeys.Count - pruned.Count;\n Debug.Log($\"[DataAging] Pruned {removed} entries, kept {pruned.Count}\");\n }\n }\n\n // ----- Public helpers for adding new entries -----\n\n /// \u003csummary>\n /// Records the current UTC time as a new entry with the given payload.\n /// Call this from gameplay logic to accumulate data over sessions.\n /// \u003c/summary>\n public void RecordEntry(string payload)\n {\n if (!dataReady) return;\n\n VRCPlayerApi local = Networking.LocalPlayer;\n if (local == null || !local.IsValid()) return;\n\n if (!PlayerData.TryGetString(local, DataKey, out string json))\n {\n json = \"{}\";\n }\n\n if (!VRCJson.TryDeserializeFromJson(json, out DataToken token))\n {\n return;\n }\n\n DataDictionary dict = token.DataDictionary;\n string nowKey = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();\n dict[nowKey] = payload;\n\n if (VRCJson.TrySerializeToJson(dict, JsonExportType.Minify, out DataToken updated))\n {\n PlayerData.SetString(local, DataKey, updated.String);\n }\n }\n}\n```\n\n**When to use:**\n\n- Visit/session logs that accumulate one or more entries per day\n- Activity history (e.g., items collected, quests completed) stored as timestamped JSON\n- Any PlayerData string that grows with each session and risks hitting the 100 KB limit\n\n**Key considerations:**\n\n- PlayerData has a **100 KB total limit per player per world** -- pruning prevents silent write failures when the budget is exhausted\n- Prune in `OnPlayerRestored` (session start) so the cost is paid once, not on every write\n- Use **GUID-suffixed key names** (`\"MyApp_DataKey_\u003cguid>\"`) to avoid collisions with other scripts in the same world\n- The retain window is measured in **calendar days**, not a rolling 24-hour period, so partial days at the boundary are kept intact\n- Combine with the [Persistence Storage Information API](#persistence-storage-information-api-sdk-3100) to monitor actual byte usage at runtime\n\n## Debugging\n\n### Checking Saved Data\n\n```csharp\npublic void DebugPrintAllData()\n{\n VRCPlayerApi local = Networking.LocalPlayer;\n string[] keys = PlayerData.GetKeys(local);\n\n Debug.Log($\"=== PlayerData for {local.displayName} ===\");\n foreach (string key in keys)\n {\n Debug.Log($\" {key}\");\n }\n Debug.Log($\"=== Total: {keys.Length} keys ===\");\n}\n```\n\n### Common Issues\n\n| Issue | Cause | Solution |\n|-------|-------|----------|\n| Data not saving | Writing before `OnPlayerRestored` | Wait for event |\n| Data not loading | Key doesn't exist | Use `TryGet` pattern |\n| Data size exceeded | Too much data | Compress or split data |\n| Wrong player's data | Writing to non-local player | Check `player.isLocal` |\n\n## Best Practices\n\n1. **Always wait for `OnPlayerRestored`** before accessing PlayerData\n2. **Use TryGet pattern** to handle missing keys gracefully\n3. **Throttle saves** - don't save every frame\n4. **Keep keys short** - they count against storage limit\n5. **Test with fresh data** - clear persistence during development\n6. **Document your keys** - maintain a list of all used keys\n7. **Version your data** - include a version key for migration\n\n## See Also\n\n- [sync-examples.md](sync-examples.md) - Practical patterns for syncing persistent state with other players\n- [networking.md](networking.md) - Ownership and serialization needed for PlayerObject sync\n- [context-preservation.md](context-preservation.md) - Task-context notes for persistence, source-of-truth, ownership, and restore decisions\n- [events.md](events.md) - `OnPlayerRestored` and `OnPersistenceUsageUpdated` event reference\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":30451,"content_sha256":"9a784188e1d1f97730da5a34347d7b7a64bf4ec75e872acc20f8622859a5129a"},{"filename":"references/sdk-migration.md","content":"# VRChat SDK Migration Guide (3.7 to 3.10)\n\nStep-by-step guide for upgrading UdonSharp worlds across major SDK versions.\n\n**Applies to**: SDK 3.7.x through 3.10.3\n\n> **Deprecation Notice**: SDK versions below 3.9.0 were deprecated on **December 2, 2025**.\n> New world uploads are no longer possible with those versions.\n> Worlds that have not yet migrated past 3.9.0 must update to continue publishing.\n\n## Version markers\n\nVersion-specific notes in this skill use three marker forms. Match them verbatim when adding new annotations:\n\n- `(requires SDK X.Y.Z+)` — feature gate. The API, attribute, or component exists only from that version onward.\n- `(fixed in SDK X.Y.Z)` — a bug that existed in earlier SDKs is resolved starting with this version.\n- `(unresolved as of SDK X.Y.Z)` — a bug is still open in this version. Include a tracking link (canny, GitHub issue) when available.\n\n---\n\n## SDK 3.7.x to 3.8.x\n\n### New Features\n\n#### GetComponent\u003cT> for UdonSharpBehaviour Types (SDK 3.8.0+)\n\nBefore SDK 3.8, using the generic `GetComponent\u003cT>()` form on types derived from `UdonSharpBehaviour` was unreliable. SDK 3.8 added proper support for the generic form on direct subclasses and through inheritance hierarchies.\n\n```csharp\n// SDK 3.7: Required cast syntax for reliability\nMyScript s = (MyScript)(object)GetComponent(typeof(MyScript));\n\n// SDK 3.8+: Generic form works correctly\nMyScript s = GetComponent\u003cMyScript>();\n\n// SDK 3.8+: Also works through inheritance\npublic class BaseGimmick : UdonSharpBehaviour { }\npublic class DerivedGimmick : BaseGimmick { }\n\nBaseGimmick base = GetComponent\u003cBaseGimmick>(); // finds DerivedGimmick too\nDerivedGimmick derived = GetComponent\u003cDerivedGimmick>();\nBaseGimmick[] all = GetComponents\u003cBaseGimmick>(); // plural form also works\n```\n\n**Note**: Getting `UdonBehaviour` itself (the raw type, not your subclass) still requires the non-generic cast form: `(UdonBehaviour)GetComponent(typeof(UdonBehaviour))`.\n\n#### [NetworkCallable] — Parameterized Network Events (SDK 3.8.1+)\n\nBefore SDK 3.8.1, network events had no parameter support. Callers had to pre-load synced variables and call `RequestSerialization()` before sending an event, creating race conditions.\n\n`[NetworkCallable]` adds up to 8 typed parameters per network call, eliminating the pre-serialization pattern.\n\n```csharp\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class DamageSystem : UdonSharpBehaviour\n{\n // Before (SDK 3.7): set synced var, RequestSerialization, then fire event\n // After (SDK 3.8.1): pass parameters directly\n\n [NetworkCallable]\n public void TakeDamage(int damage, int attackerId)\n {\n // Parameters arrive atomically — no race condition\n Debug.Log($\"Took {damage} damage from player {attackerId}\");\n }\n\n public void SendDamage(int damage, int attackerId)\n {\n SendCustomNetworkEvent(\n NetworkEventTarget.All,\n nameof(TakeDamage),\n damage,\n attackerId\n );\n }\n}\n```\n\nConstraints on `[NetworkCallable]` methods:\n- Method must be `public`\n- Cannot be `static`, `virtual`, or `override`\n- No method overloading\n- Maximum 8 parameters\n- Parameter types must be syncable types (same set as `[UdonSynced]`)\n- Default rate limit: 5 calls/sec/event; configurable up to 100/sec via `[NetworkCallable(n)]`\n\n#### New NetworkEventTarget Values (SDK 3.8.1+)\n\nTwo new targets were added to `NetworkEventTarget`:\n\n| Target | Description |\n|--------|-------------|\n| `NetworkEventTarget.Others` | All players except the sender |\n| `NetworkEventTarget.Self` | Local player only (equivalent to direct local call) |\n\n`Others` is particularly useful for effects and sounds: the sender plays the effect locally, then broadcasts to `Others` so it is not played twice.\n\n#### PhysBone Dependency Sorting (SDK 3.8.0+)\n\nPhysBone components in parent-child relationships are now automatically sorted so parent chains evaluate before their children. Worlds that worked around evaluation-order instability with manual ordering may be able to remove those workarounds after upgrading.\n\n#### Drone API: VRCDroneInteractable (SDK 3.8.0+)\n\nSDK 3.8.0 introduced `VRCDroneInteractable` for creating drone-type vehicles. This is a new component category and does not replace or change existing components.\n\n### Breaking Changes\n\nNone that affect standard UdonSharp worlds. The `GetComponent\u003cT>` behavior change is purely additive.\n\n### Migration Checklist: 3.7.x to 3.8.x\n\n- [ ] Replace synced-variable-before-event patterns with `[NetworkCallable]` where appropriate\n- [ ] Replace `NetworkEventTarget.All` + local guard logic with `NetworkEventTarget.Others` where you were filtering out the sender\n- [ ] Replace verbose `(MyScript)(object)GetComponent(typeof(MyScript))` casts with `GetComponent\u003cMyScript>()` in existing scripts\n- [ ] Review PhysBone chain ordering; remove manual workarounds that are no longer needed\n\n---\n\n## SDK 3.8.x to 3.9.x\n\n### New Features\n\n#### Camera Dolly Udon API (SDK 3.9.0+)\n\nSDK 3.9.0 added a Udon-accessible Camera Dolly system. Camera dolly tracks can be authored in the scene and controlled at runtime from UdonSharp, enabling cinematic camera movement sequences.\n\nKey usage pattern: create a Camera Dolly track in the scene, assign a `VRCCameraDolly` reference in your UdonBehaviour, then call the track control methods.\n\n#### Auto Hold Mode Simplification for Pickups (SDK 3.9.0+)\n\nThe `Auto Hold` field on `VRC_Pickup` was simplified. The old three-value enum (`Yes / No / AutoDetect`) was replaced with a simple checkbox (`Yes / No`). The `AutoDetect` option (which attempted to infer hold behavior from object size) is no longer available.\n\n```csharp\n// SDK 3.8 Inspector: Auto Hold = AutoDetect | Yes | No\n// SDK 3.9 Inspector: Auto Hold = checked (Yes) | unchecked (No)\n\n// AutoDetect is gone. Review each pickup and set the checkbox explicitly.\n```\n\nWorlds upgrading from 3.8 should audit all `VRC_Pickup` components and confirm the auto hold setting is intentional, since `AutoDetect` no longer exists as a fallback.\n\n#### VRCCameraSettings API (SDK 3.9.0+)\n\nRead-only access to the player's active camera properties. Namespace: `VRC.SDK3.Rendering`.\n\n```csharp\nusing VRC.SDK3.Rendering;\n\n// Two static instances\nVRCCameraSettings screen = VRCCameraSettings.ScreenCamera; // main view\nVRCCameraSettings photo = VRCCameraSettings.PhotoCamera; // in-game photo cam\n\n// Properties (all read-only)\nint width = screen.PixelWidth;\nint height = screen.PixelHeight;\nfloat fov = screen.FieldOfView;\nbool active = screen.Active;\n\n// Event: fires when any camera property changes\npublic override void OnVRCCameraSettingsChanged(VRCCameraSettings camera)\n{\n if (camera != VRCCameraSettings.ScreenCamera) return; // filter photo cam\n Debug.Log($\"Resolution: {camera.PixelWidth}x{camera.PixelHeight}\");\n}\n```\n\nCamera properties cannot be set from Udon; the API is read-only.\n\n#### Network ID Utility Improvements (SDK 3.9.0+)\n\nSDK 3.9.0 included improvements to the Network ID Utility tool in the VRChat SDK panel. The tool assigns and manages network IDs used by `VRC_ObjectSync` and related components. Previously the tool had reliability issues with complex scenes; the 3.9.0 improvements reduced the likelihood of duplicate or missing IDs after scene edits.\n\n**Action required**: After upgrading to SDK 3.9.x, open **VRChat SDK > Utilities > Network ID Utility** and run a scan to confirm IDs are clean.\n\n### Breaking Changes\n\n- **Auto Hold `AutoDetect` removed**: Any `VRC_Pickup` that previously used `AutoDetect` now defaults to `No`. Pickups that relied on auto-detection for hold behavior will need the checkbox set explicitly.\n\n### Migration Checklist: 3.8.x to 3.9.x\n\n- [ ] Open every scene and run **Network ID Utility** to verify no duplicate or missing network IDs\n- [ ] Audit all `VRC_Pickup` components: `AutoDetect` is gone; verify each pickup's hold mode is set to the intended `Yes` or `No`\n- [ ] Add `OnVRCCameraSettingsChanged` handling where scripts need to react to resolution or FOV changes (optional new capability)\n- [ ] Review Camera Dolly sequences if upgrading from a custom dolly implementation\n\n---\n\n## SDK 3.9.x to 3.10.x\n\n### New Features\n\n#### VRChat Dynamics for Worlds (SDK 3.10.0+)\n\nThe largest addition of the 3.10 series: **PhysBones**, **Contacts**, and **VRC Constraints** are now available in worlds, not just on avatars.\n\n**PhysBones** — physics-based bone chains for ropes, flags, chains, and interactive objects.\n\n```csharp\npublic class GrabbableRope : UdonSharpBehaviour\n{\n public override void OnPhysBoneGrab(PhysBoneGrabInfo info)\n {\n Debug.Log($\"Grabbed by {info.player?.displayName}\");\n }\n\n public override void OnPhysBoneRelease(PhysBoneReleaseInfo info)\n {\n Debug.Log(\"Released\");\n }\n}\n\n// PhysBone API\nVRCPhysBone pb = GetComponent\u003cVRCPhysBone>();\nbool grabbed = pb.IsGrabbed();\npb.ForceReleaseGrab(); // force-release a grab\npb.ForceReleasePose(); // reset a bent chain\n```\n\n**Contacts** — collision detection between `VRC Contact Sender` and `VRC Contact Receiver` components.\n\n```csharp\npublic class ContactButton : UdonSharpBehaviour\n{\n public override void OnContactEnter(ContactEnterInfo info)\n {\n if (info.isAvatar)\n Debug.Log($\"Pressed by {info.player?.displayName}\");\n else\n Debug.Log(\"Pressed by world object\");\n }\n\n public override void OnContactExit(ContactExitInfo info) { }\n}\n```\n\n**VRC Constraints** — cross-platform replacements for Unity's built-in constraint components. Unity Constraints are disabled on Quest/Android; VRC Constraints work on all platforms.\n\n```csharp\nusing VRC.SDK3.Dynamics.Constraint.Components;\n\npublic class ConstraintController : UdonSharpBehaviour\n{\n public VRCPositionConstraint posConstraint;\n\n public void EnableFollow() => posConstraint.IsActive = true;\n public void DisableFollow() => posConstraint.IsActive = false;\n\n public void SetWeight(float w) => posConstraint.SetSourceWeight(0, w);\n}\n```\n\nVRC Constraint types: `VRCPositionConstraint`, `VRCRotationConstraint`, `VRCScaleConstraint`, `VRCParentConstraint`, `VRCAimConstraint`, `VRCLookAtConstraint`. All share a common Udon API (`IsActive`, `GlobalWeight`, `GetSource` / `SetSource`, etc.). See [dynamics.md](dynamics.md) for the full reference.\n\n#### Persistence Storage Information API (SDK 3.10.0+)\n\nNew `VRCPlayerApi` methods to query how much persistence storage (PlayerData + PlayerObject combined) a player is consuming.\n\n```csharp\nint used = player.GetPlayerDataStorageUsage(); // bytes\nint limit = player.GetPlayerDataStorageLimit(); // bytes (typically 102400)\nplayer.RequestStorageUsageUpdate(); // request a fresh value from server\n```\n\nThe `OnPersistenceUsageUpdated` event fires when updated usage data arrives:\n\n```csharp\n// Always fires on the local player's UdonBehaviours.\npublic override void OnPersistenceUsageUpdated()\n{\n int used = Networking.LocalPlayer.GetPlayerDataStorageUsage();\n int limit = Networking.LocalPlayer.GetPlayerDataStorageLimit();\n Debug.Log($\"Storage: {used}/{limit} bytes\");\n}\n```\n\nSee [persistence.md](persistence.md) for a full monitoring example.\n\n#### EventTiming Extensions (SDK 3.10.2+)\n\n`SendCustomEventDelayedSeconds` and `SendCustomEventDelayedFrames` gained two new `EventTiming` values:\n\n| EventTiming | When | Use case |\n|-------------|------|----------|\n| `EventTiming.Update` | Update loop | General logic (was the only option) |\n| `EventTiming.LateUpdate` | After all Update calls | Post-animation logic |\n| `EventTiming.FixedUpdate` | Physics tick | Physics-synchronized callbacks |\n| `EventTiming.PostLateUpdate` | After LateUpdate | Camera follow, post-IK corrections |\n\n```csharp\n// Schedule a physics-safe callback\nSendCustomEventDelayedSeconds(nameof(PhysicsStep), 1.0f, EventTiming.FixedUpdate);\n\n// Schedule a camera-follow update after IK\nSendCustomEventDelayedFrames(nameof(UpdateCamera), 1, EventTiming.PostLateUpdate);\n```\n\n`FixedUpdate` and `PostLateUpdate` are new in SDK 3.10.2; `Update` and `LateUpdate` existed since SDK 3.7.1.\n\n#### PhysBone Collider Callbacks (SDK 3.10.0+)\n\nIn addition to grab/release events, PhysBones now fire three collider-interaction callbacks on any UdonBehaviour attached to the same GameObject as the `VRC Phys Bone` component:\n\n| Event | Trigger |\n|-------|---------|\n| `OnPhysBoneColliderEnter(PhysBoneColliderInfo info)` | A PhysBone collider starts intersecting the bone chain |\n| `OnPhysBoneColliderStay(PhysBoneColliderInfo info)` | A PhysBone collider continues intersecting each frame |\n| `OnPhysBoneColliderExit(PhysBoneColliderInfo info)` | A PhysBone collider stops intersecting |\n\nKeep `OnPhysBoneColliderStay` handlers lightweight; it fires every frame and can create significant overhead.\n\n### Breaking Changes\n\n#### VRCContactReceiver.UpdateContentTypes() Signature Change (SDK 3.10.1)\n\nThe parameter type of `UpdateContentTypes()` changed from `IEnumerable\u003cstring>` to `string[]`. Since `List\u003cT>` is not available in UdonSharp, correct code already used `string[]` directly. If any script was passing a collection via an interface reference, update to a `string[]` literal or array variable.\n\n```csharp\n// Correct (works in all 3.10.x)\nstring[] types = new string[] { \"Hand\", \"Finger\" };\nreceiver.UpdateContentTypes(types);\n```\n\n#### Unity Constraints — Quest Impact\n\nUnity's built-in constraint components (`PositionConstraint`, `ParentConstraint`, etc.) are **disabled on Quest/Android**. Worlds that previously worked PC-only may discover Quest visitors see no constraint behavior. Replace all Unity Constraints with their VRC Constraint equivalents before uploading a cross-platform world.\n\n| Unity Constraint | VRC Replacement |\n|-----------------|----------------|\n| `PositionConstraint` | `VRCPositionConstraint` |\n| `RotationConstraint` | `VRCRotationConstraint` |\n| `ScaleConstraint` | `VRCScaleConstraint` |\n| `ParentConstraint` | `VRCParentConstraint` |\n| `AimConstraint` | `VRCAimConstraint` |\n| `LookAtConstraint` | `VRCLookAtConstraint` |\n\nNamespace for all VRC Constraints: `VRC.SDK3.Dynamics.Constraint.Components`.\n\n### Migration Checklist: 3.9.x to 3.10.x\n\n- [ ] Replace all Unity Constraint components with VRC Constraint equivalents (mandatory for Quest support)\n- [ ] Add `using VRC.SDK3.Dynamics.Constraint.Components;` to any script that references VRC Constraints\n- [ ] Verify `VRCContactReceiver.UpdateContentTypes()` calls pass `string[]` (not a list or interface type)\n- [ ] Implement `OnPersistenceUsageUpdated` if your world writes PlayerData and you want to warn players of storage limits\n- [ ] Audit `SendCustomEventDelayed*` calls: use `EventTiming.FixedUpdate` for physics-coupled callbacks, `EventTiming.PostLateUpdate` for camera/IK callbacks, instead of frame-delay workarounds\n- [ ] For worlds using PhysBones in world space: avoid placing them inside `Instantiate()`-created objects (PhysBones in instantiated objects may not be network-synced; use scene-placed objects or VRChat Object Pool instead)\n- [ ] Test the Contact-based interactions with multiple players; `Allow Self` / `Allow Others` settings on `VRC Contact Receiver` do not apply to world-object senders\n\n#### SDK 3.10.3 changes\n\nSmall surface, but each item has non-obvious consequences documented elsewhere — this entry just routes you to them.\n\n- **`VRCPlayerApi.isVRCPlus`** (bool) added. Evaluated per-client; read after `OnPlayerRestored`, not inside `OnPlayerJoined`. Never `[UdonSynced]` the result. Full timing and anti-sync rationale: `api.md` (VRCPlayerApi Properties > isVRCPlus subsection). Worked example: `patterns-core.md` (VRC+ Detection — Reading `isVRCPlus`).\n- **VRCRaycast**: avatar-side component (added 3.10.3). Udon runtime access is not indicated by the release notes. World builders should design collider/layer setup with avatar-driven raycasts in mind — see `unity-vrc-world-sdk-3/references/components.md`.\n- **Mirror rendering internals**: VRChat's mirror pipeline moved from `OnWillRenderObject` to `Camera.onPreCull` for 2026.1.3 client parity. Udon scripts do not interact with either hook, so no script-side migration is required.\n- **Toon Standard shader** (avatar-only): not covered by this skill.\n\n---\n\n## See Also\n\n- [networking.md](networking.md) — `[NetworkCallable]`, sync modes, NetworkEventTarget reference\n- [dynamics.md](dynamics.md) — PhysBones, Contacts, VRC Constraints full API\n- [persistence.md](persistence.md) — PlayerData, PlayerObject, storage monitoring\n- [events.md](events.md) — EventTiming, all Udon event signatures\n- [constraints.md](constraints.md) — `GetComponent\u003cT>` behavior, UdonSharp compile constraints\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":16802,"content_sha256":"85e542e0c5bb7cd82790d54812da3600f1e31faa31175a39d411bcfde40878e4"},{"filename":"references/sync-examples.md","content":"# Sync Pattern Examples\n\nPractical pattern collection for synced gimmicks.\nRefer to the Decision Tree in `../rules/udonsharp-sync-selection.md` for pattern selection criteria.\n\n---\n\n## Pattern 1: No Sync (Local Only)\n\n**Criteria**: Operations that do not affect other players. No `[UdonSynced]` required.\n\n```csharp\n// LocalCounter: Local counter (0 synced variables, 0 bytes)\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class LocalCounter : UdonSharpBehaviour\n{\n [SerializeField] Text CounterText;\n int buttonCount; // Local only, no sync needed\n\n public override void Interact()\n {\n ++buttonCount;\n CounterText.text = buttonCount.ToString();\n }\n}\n```\n\n**Use cases**:\n- Personal settings (volume, display toggles)\n- Local effects (gun firing particles)\n- Player-specific UI display\n\n---\n\n## Pattern 2: Events Only (No Synced Variables)\n\n**Criteria**: Visible to other players, but no state sharing needed for late joiners.\n\n### 2a. Play Effects for All Players\n\n```csharp\n// HitTarget: Target hit (0 synced variables, 0 bytes)\n// Uses SendCustomNetworkEvent(All) to execute a temporary action for everyone\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class HitTarget : UdonSharpBehaviour\n{\n public void OnParticleCollision(GameObject other)\n {\n if (!Utilities.IsValid(other)) return;\n if (!other.GetComponent\u003cShootGun>()) return;\n if (Networking.LocalPlayer != Networking.GetOwner(other)) return;\n\n // Notify all players of the hit\n SendCustomNetworkEvent(NetworkEventTarget.All, \"Hit\");\n }\n\n public void Hit()\n {\n if (!gameObject.activeSelf) return;\n gameObject.SetActive(false);\n SendCustomEventDelayedSeconds(\"Respawn\", 5.0f);\n }\n\n public void Respawn()\n {\n gameObject.SetActive(true);\n }\n}\n```\n\n**Note**: Late joiners will not know whether the target has been hit. Use only for temporary effects.\n\n### 2b. Owner Delegation Pattern\n\n```csharp\n// VoteYesButton: Non-owner sends event to owner\n// The button side has no synced variables\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class VoteYesButton : UdonSharpBehaviour\n{\n [SerializeField] VoteSystemCore voteSystemCore;\n [SerializeField] AudioSource audioSource;\n\n public override void Interact()\n {\n if (voteSystemCore.voted) return;\n\n // Delegate vote to owner (only owner modifies synced variables)\n voteSystemCore.SendCustomNetworkEvent(\n NetworkEventTarget.Owner, \"VoteToYes\");\n voteSystemCore.voted = true;\n audioSource.PlayOneShot(audioSource.clip);\n }\n}\n```\n\n**Key point**: `voted` is a local flag (prevents double voting). Synced data is consolidated in VoteSystemCore.\n\n### 2c. Owner-Only State Management + Broadcast to All\n\n```csharp\n// EventOnlyLock: Owner decides -> broadcasts to all (0 synced variables, 0 bytes)\n// Late joiners will not know the unlock state (suitable for temporary gimmicks)\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class EventOnlyLock : UdonSharpBehaviour\n{\n [SerializeField] GameObject KeyObject;\n\n public void OnTriggerEnter(Collider other)\n {\n if (Networking.LocalPlayer != Networking.GetOwner(gameObject)) return;\n if (other.gameObject != KeyObject) return;\n\n SendCustomNetworkEvent(NetworkEventTarget.All, \"Unlock\");\n }\n\n public void Unlock()\n {\n gameObject.SetActive(false);\n }\n}\n```\n\n**EventOnlyLock vs SyncedLock comparison**:\n\n| | EventOnlyLock | SyncedLock |\n|---|-----------|-------------|\n| Synced variables | 0 (0B) | 1 `bool` (1B) |\n| Late joiner | State unknown | Receives correct state |\n| Use case | Temporary effects | Persistent gimmicks |\n\n---\n\n## Pattern 3: Synced Variables (Late Joiner Support)\n\n**Criteria**: Late joiners need to receive the current state.\n\n### 3a. Minimal State (1-2 Variables)\n\n```csharp\n// SyncedCounter: 1 synced int (4 bytes)\n// Non-owner sends event to owner -> owner updates synced variable\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class SyncedCounter : UdonSharpBehaviour\n{\n [SerializeField] Text CounterText;\n [UdonSynced] int SyncedButtonCount; // Only synced variable\n\n void Start() => ShowCount();\n\n public override void Interact()\n {\n // Non-owner delegates to owner\n SendCustomNetworkEvent(NetworkEventTarget.Owner, \"AddCount\");\n }\n\n public void AddCount() // Only executed by owner\n {\n ++SyncedButtonCount;\n RequestSerialization();\n ShowCount();\n }\n\n public override void OnDeserialization() => ShowCount();\n\n void ShowCount()\n {\n CounterText.text = SyncedButtonCount.ToString();\n }\n}\n```\n\n```csharp\n// SyncedLock: 1 synced bool (1 byte)\n// Same lock gimmick as EventOnlyLock, but with late joiner support\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class SyncedLock : UdonSharpBehaviour\n{\n [SerializeField] GameObject KeyObject;\n [SerializeField] GameObject DoorObject;\n [UdonSynced] bool SyncedIsUnlocked; // Only synced variable\n\n void Start()\n {\n // Late joiner support: wait briefly then apply synced state\n SendCustomEventDelayedSeconds(\"RefreshDoor\", 5.0f);\n }\n\n public void RefreshDoor()\n {\n if (SyncedIsUnlocked) Unlock();\n }\n\n public void OnTriggerEnter(Collider other)\n {\n if (SyncedIsUnlocked) return;\n if (Networking.LocalPlayer != Networking.GetOwner(gameObject)) return;\n if (other.gameObject != KeyObject) return;\n\n SendCustomNetworkEvent(NetworkEventTarget.All, \"Unlock\");\n SyncedIsUnlocked = true;\n RequestSerialization();\n }\n\n public void Unlock()\n {\n DoorObject.SetActive(false);\n }\n}\n```\n\n### 3b. Game State Machine\n\n```csharp\n// ShootingGameCore: Manages entire game with 4 synced variables\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class ShootingGameCore : UdonSharpBehaviour\n{\n // --- Synced variables (total ~38 bytes) ---\n [UdonSynced] public bool SyncedInGame; // 1B: Game in progress\n [UdonSynced] public bool SyncedInBattle; // 1B: In battle\n [UdonSynced] public string SyncedHighScorePlayerName; // ~32B: High scorer name\n [UdonSynced] public int SyncedHighScore; // 4B: High score\n\n // --- Local variables (not synced) ---\n int score; // Each player's local score\n float GameLength; // Constant (no sync needed)\n float startGameTime; // For local calculation\n bool lateJoined; // Local flag\n // ...\n}\n```\n\n**Design points**:\n- `score` is local (per player) -> no sync needed\n- `GameLength` is a constant -> no sync needed\n- `startGameTime` is locally calculated from `Time.time` -> no sync needed\n- Only the high score needs to be persistent shared state -> synced\n\n### 3c. Aggregation/Voting Pattern\n\n```csharp\n// VoteSystemCore: Vote aggregation (9 bytes)\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class VoteSystemCore : UdonSharpBehaviour\n{\n // --- Synced variables (total 9 bytes) ---\n [UdonSynced] int SyncedYesCount; // 4B\n [UdonSynced] int SyncedNoCount; // 4B\n [UdonSynced] bool SyncedOpenResult; // 1B\n\n // --- Local variables ---\n public bool voted; // Double-vote prevention (local, no sync needed)\n\n public void VoteToYes() // Only executed by owner\n {\n ++SyncedYesCount;\n RequestSerialization();\n RefreshCount();\n }\n\n public override void OnDeserialization()\n {\n RefreshCount(); // All clients: reflect received state in display\n }\n}\n```\n\n---\n\n## Pattern 4: Managing Multiple Values with FieldChangeCallback\n\n```csharp\n// DualCounterSync: Detect individual changes with FieldChangeCallback (8 bytes)\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class DualCounterSync : UdonSharpBehaviour\n{\n [SerializeField] Text InteractCountText;\n [SerializeField] Text TriggerEnterCountText;\n\n [UdonSynced][FieldChangeCallback(nameof(InteractCount))]\n int _interactCount; // 4B\n\n [UdonSynced][FieldChangeCallback(nameof(TriggerEnterCount))]\n int _triggerEnterCount; // 4B\n\n public int InteractCount\n {\n get => _interactCount;\n set { _interactCount = value; ShowInteractCount(); }\n }\n\n public int TriggerEnterCount\n {\n get => _triggerEnterCount;\n set { _triggerEnterCount = value; ShowTriggerEnterCount(); }\n }\n}\n```\n\n**OnDeserialization vs FieldChangeCallback**:\n\n| Approach | Pros | Cons |\n|------|------|------|\n| `OnDeserialization()` | Simple, full update | Cannot tell which variable changed |\n| `FieldChangeCallback` | Detects individual variable changes | Requires property definitions |\n\n**When to use**: 1-2 variables -> OnDeserialization is sufficient. 3+ variables needing individual responses -> FieldChangeCallback.\n\n---\n\n## Pattern Comparison Table\n\n| Pattern | Synced vars | Bytes | Late Joiner | Use case |\n|---------|------------|---------|-------------|---------|\n| 1. No sync | 0 | 0 | N/A | Personal effects, local UI |\n| 2. Events only | 0 | 0 | State unknown | Temporary actions, effects |\n| 3a. Minimal state | 1-2 | 1-4 | Supported | Counters, toggles |\n| 3b. Game state | 3-5 | ~38 | Supported | Game progression management |\n| 3c. Aggregation | 2-3 | ~9 | Supported | Voting, score aggregation |\n| 4. FieldChange | 2+ | 8+ | Supported | Individual detection of multiple values |\n\n---\n\n## Data Budget Reference (Per-Pattern Reference Values)\n\nThe following is a summary of synced data amounts for the patterns above. Use for data budget estimation when designing worlds.\n\n| Pattern | Example use | Synced vars | Type | Bytes |\n|---------|--------|------------|-----|-------|\n| No Sync (Pattern 1) | Local counter | 0 | - | 0 |\n| Events Only (Pattern 2a) | Play effects for all | 0 | - | 0 |\n| Events Only (Pattern 2c) | Temporary unlock | 0 | - | 0 |\n| Minimal state (Pattern 3a) | Counter | 1 | int | 4 |\n| Minimal state (Pattern 3a) | Lock (late joiner support) | 1 | bool | 1 |\n| FieldChange (Pattern 4) | Multiple value management | 2 | int x2 | 8 |\n| Aggregation (Pattern 3c) | Voting system | 3 | int x2 + bool | 9 |\n| Game state (Pattern 3b) | Shooting management | 4 | bool x2 + string + int | ~38 |\n\n> **Guideline**: For small to medium worlds, the total across all behaviours typically stays **under 100 bytes**.\n\n## See Also\n\n- [networking.md](networking.md) - Sync mode selection, ownership rules, and bandwidth limits explained\n- [networking-bandwidth.md](networking-bandwidth.md) - Bandwidth throttling, bit packing, and data size optimization\n- [persistence.md](persistence.md) - Persisting player data across sessions with PlayerData and PlayerObject\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10743,"content_sha256":"631bf1c5b3137a9c6e7e3559a752c5da65c9d1b71be795827809ff3217561e9c"},{"filename":"references/testing.md","content":"# UdonSharp Testing and Debugging Guide\n\nPractical guide for testing and debugging UdonSharp worlds in VRChat.\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\n## Table of Contents\n\n- [ClientSim — Editor Testing](#clientsim--editor-testing)\n- [Build and Test — Runtime Testing](#build-and-test--runtime-testing)\n- [Multi-Client Testing](#multi-client-testing)\n- [Debug.Log Usage](#debuglog-usage)\n- [Testing Checklist](#testing-checklist)\n\n---\n\n## ClientSim — Editor Testing\n\nClientSim (Client Simulator) replicates VRChat client behavior in the Unity Editor without requiring a live VRChat build. It is included in the VRChat Worlds SDK — no separate installation is needed.\n\n### Starting a ClientSim Session\n\n1. Open your world scene in the Unity Editor.\n2. Press **Play** in the Editor.\n\nClientSim starts automatically. Use **Mouse + Keyboard** or a **Gamepad** to control the local player.\n\n### Disabling ClientSim\n\nOpen **VRChat SDK > ClientSim Settings** and uncheck **Enable ClientSim**.\n\n### What ClientSim Simulates\n\n- Local player movement, pickups, interactions, UI, and station usage\n- Udon variable inspection during Play Mode\n- `OnDeserialization` events for the local player\n- `OnPlayerRestored` when spawning simulated remote players (event fires only; these simulated players do not perform real network synchronization)\n- Basic server-time simulation\n\n### What ClientSim Does NOT Simulate\n\n| Not Simulated | Why This Matters |\n|---|---|\n| Real networked remote players (ClientSim can add simulated remote players that fire join/restore events, but they do not perform actual network synchronization) | Ownership transfers, sync conflicts, and `OnDeserialization` from a real remote client are not testable |\n| Full networking serialization | `OnPostSerialization` and `OnDeserialization` data structures differ from live VRChat |\n| Network congestion (`Networking.IsClogged`) | Rate limiting and congestion behavior cannot be tested |\n| Multi-user sync conflicts | Race conditions and ownership fights are invisible |\n| Camera dolly animations | Camera dolly has no ClientSim preview support |\n\n> **Critical**: Always test your world in VRChat before publishing. ClientSim catches logic bugs but cannot validate networking behavior.\n\n---\n\n## Build and Test — Runtime Testing\n\nBuild and Test launches your world in actual VRChat clients from the Unity Editor, giving full runtime fidelity.\n\n### Prerequisites\n\n1. Sign in via **VRChat SDK > Show Control Panel > Authentication**.\n2. In **VRChat SDK > Show Control Panel > Settings**, specify your VRChat installation path.\n3. In the **Builder** tab, click **Setup Layers for VRChat** and apply the collision matrix.\n\n### Single-Client Build and Test\n\n1. In the **Builder** tab, set **Number of Clients** to `1`.\n2. Click **Build & Test**.\n\nVRChat launches with your world loaded. The local client is the **instance master** and owns all GameObjects by default.\n\n### Build and Reload\n\nSet **Number of Clients** to `0`. This rebuilds the world and moves the already-running client into the new instance — significantly faster for iteration since no login sequence is required.\n\n---\n\n## Multi-Client Testing\n\nNetworking bugs (ownership, sync, late joiners) require multiple clients. Build and Test supports this natively.\n\n### Setup\n\n1. Set **Number of Clients** to `2` or higher.\n2. Click **Build & Test**.\n\nUnity launches the specified number of VRChat clients simultaneously. Switch between windows to control each client independently.\n\n### Client Roles\n\n- **First client to load** → instance master and default object owner\n- **Subsequent clients** → non-master; cannot modify objects they do not own\n\n### What to Test with Multiple Clients\n\n| Scenario | What to Verify |\n|---|---|\n| **Ownership transfer** | Call `Networking.SetOwner()` on client A; verify client B sees the update |\n| **Synced variable propagation** | Modify a `[UdonSynced]` field and call `RequestSerialization()`; verify all clients reflect the new value |\n| **NetworkCallable events** | Trigger a `[NetworkCallable]` method from one client; verify it fires on all clients |\n| **SendCustomNetworkEvent** | Send `NetworkEventTarget.All` event; verify it fires on each client |\n| **Late joiner state** | Connect a third client after state has changed; verify the new client receives the current state via `OnDeserialization` |\n| **Master handoff** | Close the master client; verify non-master clients elect a new master and ownership cascades correctly |\n| **Pool behavior** | Call `TryToSpawn()` on the pool owner client; verify spawned objects appear on all clients |\n\n### Late Joiner Testing Procedure\n\n1. Start with 2 clients and modify world state (e.g., toggle a synced bool, change a score).\n2. Without resetting, open a **third** client.\n3. Verify the new client immediately sees the current state — not the initial state.\n\nThis is the most reliable way to catch missing `RequestSerialization()` calls and incorrect late-joiner initialization.\n\n---\n\n## Debug.Log Usage\n\n### Basic Logging\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\npublic class ExampleScript : UdonSharpBehaviour\n{\n void Start()\n {\n Debug.Log(\"[ExampleScript] Start() called\");\n Debug.Log($\"[ExampleScript] Local player: {Networking.LocalPlayer.displayName}\");\n }\n\n public override void Interact()\n {\n Debug.Log(\"[ExampleScript] Interact() triggered\");\n }\n\n public override void OnDeserialization()\n {\n Debug.Log($\"[ExampleScript] OnDeserialization — syncedValue: {syncedValue}\");\n }\n\n [UdonSynced] private int syncedValue;\n}\n```\n\n### Where Logs Appear\n\n| Environment | Location |\n|---|---|\n| Unity Editor | **Console** window; UdonSharp's runtime exception watcher also highlights the exact line that threw |\n| VRChat client | Output log at `%AppData%\\..\\LocalLow\\VRChat\\VRChat\\output_log_HH-MM-SS.txt` |\n| VRChat in-game | Press **RShift + Backtick + 3** to open the debug GUI overlay |\n\n### Prefixing Logs\n\nAdd a unique prefix to every `Debug.Log` call so you can filter by script in the Console:\n\n```csharp\n// Easy to filter in Console using the search box\nDebug.Log(\"[MyScriptName] message here\");\nDebug.LogWarning(\"[MyScriptName] unexpected state\");\nDebug.LogError(\"[MyScriptName] critical failure\");\n```\n\n### Logging Synced State\n\nLog both the local value and the sync event to diagnose deserialization issues:\n\n```csharp\n[UdonSynced] private bool _isOpen;\n\npublic void SetOpen(bool value)\n{\n if (!Networking.IsOwner(gameObject))\n {\n Debug.LogWarning(\"[Door] SetOpen called but not owner — ignoring\");\n return;\n }\n _isOpen = value;\n RequestSerialization();\n Debug.Log($\"[Door] SetOpen({value}) — RequestSerialization called\");\n}\n\npublic override void OnDeserialization()\n{\n Debug.Log($\"[Door] OnDeserialization — _isOpen: {_isOpen}\");\n ApplyState();\n}\n```\n\n### Pre-Release Cleanup\n\nRemove or disable all `Debug.Log` calls before publishing:\n\n- Excessive logging reduces runtime performance.\n- Log strings allocate memory on every call, increasing GC pressure.\n- Leaving logs in can expose internal world logic to players who inspect their output files.\n\n**Recommended pattern**: Guard logs behind a serialized flag so you can toggle them from the Inspector without modifying code:\n\n```csharp\n[SerializeField] private bool _debugMode = false;\n\nprivate void Log(string message)\n{\n if (_debugMode) Debug.Log(message);\n}\n```\n\nSet `_debugMode = false` on all behaviours before building for release.\n\n---\n\n## Testing Checklist\n\nRun through this checklist before publishing any world.\n\n### Ownership and Sync\n\n- [ ] All `[UdonSynced]` writes are guarded by `Networking.IsOwner(gameObject)`; if not owner, call `Networking.SetOwner` first (locally immediate), then write and `RequestSerialization()` (Manual sync)\n- [ ] All Manual-sync behaviours call `RequestSerialization()` after modifying synced fields\n- [ ] No ownership fights: two scripts do not compete for ownership of the same object\n\n### Late Joiner Correctness\n\n- [ ] A player joining after world state has changed sees the current state (not initial defaults)\n- [ ] `OnDeserialization` correctly restores all derived local state from synced variables\n- [ ] Objects managed by `VRCObjectPool` show correct active/inactive state to late joiners\n- [ ] `PlayerData`/`PlayerObject` data is loaded before being read (use `OnPlayerRestored`)\n\n### Network Sync\n\n- [ ] Synced variables reflect changes on all clients within a few seconds\n- [ ] `SendCustomNetworkEvent` fires correctly on `NetworkEventTarget.All` and `NetworkEventTarget.Owner`\n- [ ] `[NetworkCallable]` methods (SDK 3.8.1+) receive correct parameters on all clients\n- [ ] No per-frame `RequestSerialization()` calls that could flood the network budget\n\n### Multi-User Interaction\n\n- [ ] Two players interacting with the same object simultaneously does not corrupt state\n- [ ] Master handoff (closing the master client) does not break world functionality\n- [ ] Ownership transfers complete without leaving objects in an unowned or double-owned state\n\n### Performance\n\n- [ ] No per-frame networking calls (`RequestSerialization`, `SendCustomNetworkEvent`) without throttling\n- [ ] `Debug.Log` calls removed or guarded behind a `_debugMode` flag set to `false`\n- [ ] No `Update()` loops that could be replaced with event-driven callbacks\n\n## See Also\n\n- [api.md](api.md) - VRCObjectPool API, VRCPlayerApi methods, and Camera Dolly API reference\n- [networking.md](networking.md) - Ownership model, sync modes, and RequestSerialization\n- [context-preservation.md](context-preservation.md) - Lightweight task-context notes for resumed or ownership-sensitive work\n- [networking-antipatterns.md](networking-antipatterns.md) - Common networking mistakes and how to avoid them\n- [troubleshooting.md](troubleshooting.md) - Common errors and solutions\n- [events.md](events.md) - Complete event list including OnDeserialization and OnPlayerRestored\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10013,"content_sha256":"6a9945e72813e32322c4c3bb6597051dd634a96fbfe68cde4d29ac805eb00596"},{"filename":"references/troubleshooting.md","content":"# UdonSharp Troubleshooting Guide\n\nCommon errors, causes, and solutions for VRChat UdonSharp development.\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\n## Table of Contents\n\n- [Compile Errors](#compile-errors)\n- [Runtime Errors](#runtime-errors)\n- [Networking Issues](#networking-issues)\n- [NetworkCallable Issues (SDK 3.8.1+)](#networkcallable-issues-sdk-381)\n- [Persistence Issues (SDK 3.7.4+)](#persistence-issues-sdk-374)\n- [Dynamics Issues (SDK 3.10.0+)](#dynamics-issues-sdk-3100)\n- [VRCStation + Trigger Detection Issues](#vrcstation--trigger-detection-issues)\n- [Editor Issues](#editor-issues)\n- [Performance Issues](#performance-issues)\n- [Common Pitfalls](#common-pitfalls)\n\n---\n\n## Compile Errors\n\n### \"UdonSharp does not support X\"\n\n**Symptoms:**\n\n```text\n\nUdonSharpException: UdonSharp does not currently support [feature]\n\n```\n\n**Common unsupported features:**\n| Feature | Alternative |\n|---------|-------------|\n| `async/await` | `SendCustomEventDelayedSeconds()` |\n| `yield return` / coroutines | `SendCustomEventDelayedSeconds()` |\n| Generics `List\u003cT>` | Arrays `T[]` or `DataList` |\n| LINQ | Manual loops |\n| `dynamic` | Explicit types |\n| Multi-dimensional arrays `T[,]` | Jagged arrays `T[][]` |\n| Delegates / Events | `SendCustomEvent()` |\n| `nameof()` on types from blocked namespaces (e.g., `System.Net.HttpWebRequest`) | String literals (`\"HttpWebRequest\"`); `nameof()` works normally for UdonSharp-compatible types and method/property names |\n| `try/catch/finally` | Validate inputs, null checks |\n\n**Solution:**\nUse the alternatives documented. See `constraints.md` for the complete list.\n\n---\n\n### \"The type or namespace 'X' could not be found\"\n\n**Symptoms:**\n\n```text\n\nCS0246: The type or namespace name 'List' could not be found\n\n```\n\n**Causes:**\n1. Using an unsupported System type\n2. Missing `using` directive\n3. Assembly definition issues\n\n**Solution:**\n\n```csharp\n\n// Wrong - List\u003cT> not supported\nusing System.Collections.Generic;\nList\u003cint> numbers = new List\u003cint>();\n\n// Correct - Use arrays\nint[] numbers = new int[10];\n\n// Or use DataList for dynamic sizing\nDataList list = new DataList();\nlist.Add(new DataToken(42));\n\n```\n\n---\n\n### \"'UdonSharpBehaviour' does not contain a definition for 'X'\"\n\n**Symptoms:**\n\n```text\n\nCS1061: 'UdonSharpBehaviour' does not contain a definition for 'StartCoroutine'\n\n```\n\n**Cause:** Attempting to use MonoBehaviour methods not exposed to Udon.\n\n**Common unexposed methods and alternatives:**\n\n| Unexposed method | Alternative |\n|----------------|-------------|\n| `StartCoroutine()` | `SendCustomEventDelayedSeconds()` |\n| `StopCoroutine()` | Boolean flag check |\n| `Invoke()` | `SendCustomEvent()` |\n| `InvokeRepeating()` | `SendCustomEventDelayedSeconds()` loop |\n| `GetComponentsInChildren\u003cT>()` | Inspector references or manual search |\n| `FindObjectOfType\u003cT>()` | Inspector references |\n\n---\n\n### \"Field 'X' is not serializable\"\n\n**Symptoms:**\n\n```text\n\nUdonSharp: Field 'X' is not serializable\n\n```\n\n**Cause:** Attempting to sync an unsupported type.\n\n**Syncable types:**\n- Primitives: `bool`, `byte`, `sbyte`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`, `float`, `double`, `char`\n- Strings: `string`\n- Unity types: `Vector2`, `Vector3`, `Vector4`, `Quaternion`, `Color`, `Color32`\n- Arrays of above types\n\n**Not syncable:**\n- Custom classes/structs\n- `GameObject`, `Transform`\n- `VRCPlayerApi`\n\n**Solution:**\n\n```csharp\n\n// Wrong - Cannot sync VRCPlayerApi\n[UdonSynced] private VRCPlayerApi targetPlayer;\n\n// Correct - Sync player ID instead\n[UdonSynced] private int targetPlayerId;\n\npublic VRCPlayerApi GetTargetPlayer()\n{\n return VRCPlayerApi.GetPlayerById(targetPlayerId);\n}\n\n```\n\n---\n\n## Runtime Errors\n\n### \"NullReferenceException\"\n\n**Symptoms:**\n\n```text\n\nNullReferenceException: Object reference not set to an instance of an object\n\n```\n\n**Common causes:**\n1. Inspector references not assigned\n2. Calling `GetComponent()` on the wrong object\n3. Player left during an operation\n4. Object was destroyed\n\n**Solution:**\n\n```csharp\n\n// Always validate Inspector references\nvoid Start()\n{\n if (targetObject == null)\n {\n Debug.LogError($\"[{gameObject.name}] targetObject is not assigned!\");\n enabled = false;\n return;\n }\n}\n\n// Always check player validity\npublic override void OnPlayerTriggerEnter(VRCPlayerApi player)\n{\n if (player == null || !player.IsValid())\n {\n return;\n }\n // Safe to use player\n}\n\n// Check before accessing synced player\npublic void DoSomethingWithPlayer()\n{\n VRCPlayerApi player = VRCPlayerApi.GetPlayerById(syncedPlayerId);\n if (player == null || !player.IsValid())\n {\n Debug.LogWarning(\"Player no longer valid\");\n return;\n }\n // Safe to use player\n}\n\n```\n\n---\n\n### \"SendCustomEvent: Method 'X' not found\"\n\n**Symptoms:**\n\n```text\n\n[UdonBehaviour] SendCustomEvent: Method 'MyMethod' not found\n\n```\n\n**Causes:**\n1. Typo in method name\n2. Method is private (must be public)\n3. Method has parameters (not supported)\n\n**Solution:**\n\n```csharp\n\n// Wrong - Method is private\nprivate void MyMethod() { }\n\n// Wrong - Method has parameters\npublic void MyMethod(int value) { }\n\n// Correct - Public, parameterless\npublic void MyMethod() { }\n\n// For passing data, use SetProgramVariable first\notherScript.SetProgramVariable(\"inputValue\", 42);\notherScript.SendCustomEvent(\"ProcessInput\");\n\n```\n\n---\n\n### \"Heap ran out of memory\"\n\n**Symptoms:**\n\n```text\n\nUdon heap ran out of memory\n\n```\n\n**Causes:**\n1. Creating large numbers of objects in loops\n2. Arrays that are too large\n3. String concatenation in loops\n4. Memory leaks from arrays that are not cleared\n\n**Solution:**\n\n```csharp\n\n// Wrong - Creates new array every frame\nvoid Update()\n{\n VRCPlayerApi[] players = new VRCPlayerApi[VRCPlayerApi.GetPlayerCount()];\n VRCPlayerApi.GetPlayers(players);\n}\n\n// Correct - Reuse array, resize when needed\nprivate VRCPlayerApi[] _playerCache;\nprivate int _lastPlayerCount = 0;\n\nvoid Update()\n{\n int currentCount = VRCPlayerApi.GetPlayerCount();\n if (_playerCache == null || _playerCache.Length \u003c currentCount)\n {\n _playerCache = new VRCPlayerApi[currentCount + 10]; // Buffer\n }\n VRCPlayerApi.GetPlayers(_playerCache);\n}\n\n// Wrong - String concatenation creates garbage\nstring result = \"\";\nfor (int i = 0; i \u003c 100; i++)\n{\n result += i.ToString(); // Creates new string each iteration\n}\n\n// Correct - Use char array or limit concatenation\n// For display purposes, just show final result\n\n```\n\n---\n\n### \"ArrayIndexOutOfRangeException\"\n\n**Symptoms:**\n\n```text\n\nIndexOutOfRangeException: Index was outside the bounds of the array\n\n```\n\n**Common causes:**\n1. Array not initialized\n2. Off-by-one errors\n3. Player count changed during iteration\n\n**Solution:**\n\n```csharp\n\n// Always check array bounds\npublic void ProcessArray(int[] data)\n{\n if (data == null || data.Length == 0)\n {\n return;\n }\n\n for (int i = 0; i \u003c data.Length; i++)\n {\n // Safe access\n }\n}\n\n// Be careful with player arrays\npublic override void OnPlayerLeft(VRCPlayerApi player)\n{\n // GetPlayers() count has already changed!\n // Cache count before iteration if needed\n}\n\n```\n\n---\n\n## Networking Issues\n\n### Variables Not Syncing\n\n**Symptoms:**\n- `[UdonSynced]` variables not updating on other clients\n- State differs between players\n\n**Checklist:**\n\n1. **Is the variable properly marked?**\n\n```csharp\n\n// Correct\n[UdonSynced] private int myValue;\n\n```\n\n2. **Is the type syncable?** (See syncable types above)\n\n3. **Did you call RequestSerialization()?**\n\n```csharp\n\npublic void ChangeValue()\n{\n myValue = 42;\n RequestSerialization(); // Required for Manual sync mode!\n}\n\n```\n\n4. **Do you have ownership?**\n\n```csharp\n\npublic void ChangeValue()\n{\n if (!Networking.IsOwner(gameObject))\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n myValue = 42;\n RequestSerialization();\n}\n\n```\n\n5. **Check sync mode:**\n\n```csharp\n\n// For infrequent changes (buttons, toggles)\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\n\n// For continuous changes (position, rotation)\n[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]\n\n```\n\n---\n\n### FieldChangeCallback Not Firing\n\n**Symptoms:** Property setter not called during synchronization.\n\n**Checklist:**\n\n1. **Correct attribute syntax?**\n\n```csharp\n\n// Correct - nameof() points to PROPERTY\n[UdonSynced, FieldChangeCallback(nameof(MyProperty))]\nprivate int _myValue;\n\npublic int MyProperty\n{\n get => _myValue;\n set\n {\n _myValue = value;\n OnValueChanged();\n }\n}\n\n```\n\n2. **Using property everywhere locally?**\n\n```csharp\n\n// Wrong - Bypasses callback\n_myValue = 10;\n\n// Correct - Uses property\nMyProperty = 10;\n\n```\n\n3. **Sync mode compatibility:**\n - Works with `Manual` sync mode\n - May have timing issues with `Continuous`\n\n---\n\n### Ownership Transfer Race Conditions\n\n**Problem:** Multiple players attempting to take ownership simultaneously.\n\n**Symptoms:**\n- Unexpected ownership changes\n- State desynchronization\n- Loser's local write is overwritten when the concurrent winner's serialization arrives\n\n**Solution:**\n\n`Networking.SetOwner` is **locally immediate** on the calling client — `Networking.IsOwner(gameObject)` returns `true` synchronously after the call. There is no need to defer the action to `OnOwnershipTransferred`. Concurrent callers each succeed locally; the network resolves the durable owner by arrival order, and the loser's write is overwritten on the next deserialization. Guard writes with `IsOwner` and accept that property of the network.\n\n```csharp\n\npublic override void Interact()\n{\n if (!Networking.IsOwner(gameObject))\n {\n // SetOwner is locally immediate; IsOwner is true after this returns.\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n }\n\n DoAction();\n}\n\nprivate void DoAction()\n{\n // IsOwner is true on this frame after SetOwner; safe to write\n // synced variables here.\n RequestSerialization();\n}\n\n```\n\n> For owner-side protection during critical actions (e.g., reject ownership requests mid-transaction), use `OnOwnershipRequest` — see [networking.md §\"Ownership Arbitration with OnOwnershipRequest\"](networking.md#ownership-arbitration-with-onownershiprequest).\n\n---\n\n### Late Joiner State Issues\n\n**Problem:** Late joiners do not see the correct state.\n\n**Solution:**\n\n```csharp\n\npublic override void OnPlayerJoined(VRCPlayerApi player)\n{\n // Only owner needs to sync\n if (Networking.IsOwner(gameObject))\n {\n RequestSerialization();\n }\n}\n\n// Or use Start() for initial state\nvoid Start()\n{\n // This runs after OnDeserialization for late joiners\n ApplyState();\n}\n\n```\n\n---\n\n## NetworkCallable Issues (SDK 3.8.1+)\n\n### \"Method 'X' is not network callable\"\n\n**Symptoms:**\n\n```text\n\nMethod 'X' cannot be called as a network event\n\n```\n\n**Causes:**\n1. Missing `[NetworkCallable]` attribute\n2. Method is not `public`\n3. Method is `static`, `virtual`, or `override`\n4. Method has more than 8 parameters\n\n**Solution:**\n\n```csharp\n\n// WRONG\npublic void MyMethod(int value) { } // Missing attribute\n\nprivate void MyMethod(int value) { } // Private\n\n// CORRECT\n[NetworkCallable]\npublic void MyMethod(int value) { }\n\n```\n\n---\n\n### NetworkCallable Parameters Not Received\n\n**Symptoms:** Parameters arrive as default values (0, null, etc.)\n\n**Causes:**\n1. Parameter type is not syncable\n2. Rate limit exceeded\n3. SDK version mismatch\n\n**Checklist:**\n1. Verify parameter types are syncable (int, float, string, Vector3, etc.)\n2. Check rate limits (default 5/sec, max 100/sec)\n3. Ensure all clients are on SDK 3.8.1+\n\n```csharp\n\n// WRONG - VRCPlayerApi is not syncable\n[NetworkCallable]\npublic void SetTarget(VRCPlayerApi player) { }\n\n// CORRECT - Use player ID instead\n[NetworkCallable]\npublic void SetTarget(int playerId) { }\n\n```\n\n---\n\n### NetworkCallable Rate Limit Exceeded\n\n**Symptoms:** Events are dropped and do not reach all clients\n\n**Solution:**\n\n```csharp\n\n// Increase rate limit (max 100/sec)\n[NetworkCallable(100)]\npublic void HighFrequencyEvent(float value) { }\n\n// Or throttle on sender side\nprivate float lastSendTime;\nprivate const float SEND_INTERVAL = 0.1f;\n\npublic void SendIfReady(int value)\n{\n if (Time.time - lastSendTime \u003c SEND_INTERVAL) return;\n lastSendTime = Time.time;\n SendCustomNetworkEvent(NetworkEventTarget.All, nameof(MyEvent), value);\n}\n\n```\n\n---\n\n## Persistence Issues (SDK 3.7.4+)\n\n### PlayerData Not Loading\n\n**Symptoms:** `TryGet` always returns false, data appears empty\n\n**Causes:**\n1. Accessing before `OnPlayerRestored`\n2. Key does not exist\n3. Wrong player reference\n\n**Solution:**\n\n```csharp\n\nprivate bool dataReady = false;\n\npublic override void OnPlayerRestored(VRCPlayerApi player)\n{\n if (!player.isLocal) return;\n dataReady = true;\n\n // NOW safe to access\n if (PlayerData.TryGetInt(player, \"score\", out int score))\n {\n Debug.Log($\"Loaded score: {score}\");\n }\n}\n\npublic void SaveScore(int score)\n{\n if (!dataReady)\n {\n Debug.LogWarning(\"Data not ready!\");\n return;\n }\n PlayerData.SetInt(Networking.LocalPlayer, \"score\", score);\n}\n\n```\n\n---\n\n### PlayerData Not Saving\n\n**Symptoms:** Data does not persist across sessions\n\n**Causes:**\n1. Writing to wrong player (not local player)\n2. Exceeding storage limit (100 KB)\n3. Key name too long (max 128 characters)\n\n**Solution:**\n\n```csharp\n\n// WRONG - Trying to write to other player's data\nPlayerData.SetInt(otherPlayer, \"score\", 100); // Will fail silently\n\n// CORRECT - Write to local player only\nPlayerData.SetInt(Networking.LocalPlayer, \"score\", 100);\n\n// Debug storage usage\nstring[] keys = PlayerData.GetKeys(Networking.LocalPlayer);\nDebug.Log($\"Using {keys.Length} keys\");\n\n```\n\n---\n\n### OnPlayerRestored Not Firing\n\n**Symptoms:** Event is not called, data does not load\n\n**Causes:**\n1. VRC Enable Persistence not enabled on UdonBehaviour\n2. Script not present in scene at load time\n3. Player data is corrupted\n\n**Solution:**\n1. Check the \"VRC Enable Persistence\" checkbox in Inspector\n2. Ensure the script is active in the scene hierarchy\n3. Test with a new instance (no saved data)\n\n---\n\n## Dynamics Issues (SDK 3.10.0+)\n\n### OnContactEnter Not Firing\n\n**Symptoms:** Contact events not triggering at all\n\n**Causes:**\n1. UdonBehaviour not on the same GameObject as the Contact Receiver\n2. Content types do not match\n3. Allow Self/Allow Others is disabled\n\n**Checklist:**\n1. Ensure VRC Contact Receiver and UdonBehaviour are on the same GameObject\n2. Verify Sender's content types match Receiver's allowed types\n3. Check Allow Self/Allow Others settings (applies to avatar contacts only)\n\n```csharp\n\n// Verify receiver is on this GameObject\nvoid Start()\n{\n VRCContactReceiver receiver = GetComponent\u003cVRCContactReceiver>();\n if (receiver == null)\n {\n Debug.LogError(\"No VRCContactReceiver on this GameObject!\");\n }\n}\n\n```\n\n---\n\n### Contact Events Firing Too Frequently\n\n**Symptoms:** OnContactEnter called repeatedly, log spam\n\n**Causes:**\n1. Multiple colliders on the Sender\n2. Contacts rapidly entering and exiting\n3. No debounce logic\n\n**Solution:**\n\n```csharp\n\nprivate float lastContactTime;\nprivate const float DEBOUNCE = 0.1f;\n\npublic override void OnContactEnter(ContactEnterInfo info)\n{\n if (Time.time - lastContactTime \u003c DEBOUNCE) return;\n lastContactTime = Time.time;\n\n // Handle contact\n}\n\n```\n\n---\n\n### PhysBone Grab Not Working\n\n**Symptoms:** Cannot grab PhysBone, events do not fire\n\n**Causes:**\n1. Grabbing is disabled on VRC Phys Bone component\n2. Player's hand is too far from grab point\n3. Grab radius is too small\n\n**Solution:**\n1. Verify \"Allow Grabbing\" on VRC Phys Bone\n2. Increase \"Grab Movement\" value\n3. Test grab radius with different values\n\n---\n\n### Contact/PhysBone Player Is Null\n\n**Symptoms:** `info.player` is null when accessed\n\n**Cause:** Contact is from a world object, not from an avatar\n\n**Solution:**\n\n```csharp\n\npublic override void OnContactEnter(ContactEnterInfo info)\n{\n if (info.isAvatar)\n {\n // From avatar - player is valid\n if (info.player != null && info.player.IsValid())\n {\n Debug.Log($\"Contact from: {info.player.displayName}\");\n }\n }\n else\n {\n // From world object - player is null\n Debug.Log(\"Contact from world object\");\n }\n}\n\n```\n\n---\n\n## VRCStation + Trigger Detection Issues\n\nWhen a player sits in a VRCStation, the **PlayerLocal (Layer 10) capsule collider is effectively disabled**. This causes `OnPlayerTriggerEnter`, `OnPlayerTriggerExit`, and `OnPlayerTriggerStay` to **not fire** for seated players.\n\nThis is a [known long-standing issue](https://vrchat.canny.io/sdk-bug-reports/p/playerlocal-collision-should-remain-on-players-in-stations) (unresolved as of SDK 3.10.3).\n\n### Symptoms\n\n| Symptom | Likely Cause |\n|---------|-------------|\n| Trigger zone works for walking players but not seated | PlayerLocal collider disabled in station |\n| OnPlayerTriggerExit fires when player sits down inside zone | Collider state change triggers exit event |\n| Area effects don't activate when avatar station moves into zone | Same root cause |\n\n---\n\n### Workaround 1: Immobilize Station + Static Zone Check (Recommended)\n\nFor stations with `PlayerMobility = Immobilize`, the seated position is fixed. Compare the station Transform position to the zone Bounds at seating time. No polling is needed.\n\n> **Note:** This script tracks a single seated player. Attach one instance per VRCStation.\n> For tracking multiple stations, use the polling approach (Workaround 2) or instantiate\n> one script per station.\n\n```csharp\n\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.None)]\npublic class StationZoneCheckStatic : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private Collider zoneCollider;\n [SerializeField] private VRCStation station;\n\n private bool _isPlayerInZone = false;\n private int _seatedPlayerId = -1;\n\n public override void OnStationEntered(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n\n _seatedPlayerId = player.playerId;\n\n Bounds bounds = zoneCollider.bounds;\n // VRCStation inherits Component; explicit cast needed for UdonSharp .transform access\n Vector3 stationPosition = ((Component)station).transform.position;\n\n if (bounds.Contains(stationPosition))\n {\n _isPlayerInZone = true;\n OnPlayerEnteredZone(player);\n }\n }\n\n public override void OnStationExited(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n\n if (_isPlayerInZone)\n {\n _isPlayerInZone = false;\n OnPlayerExitedZone(player);\n }\n _seatedPlayerId = -1;\n }\n\n public override void OnPlayerLeft(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n\n // OnStationExited may NOT fire when a seated player leaves\n if (player.playerId == _seatedPlayerId)\n {\n if (_isPlayerInZone)\n {\n _isPlayerInZone = false;\n }\n _seatedPlayerId = -1;\n }\n }\n\n private void OnPlayerEnteredZone(VRCPlayerApi player)\n {\n Debug.Log($\"[StationZoneCheck] {player.displayName} entered zone (seated)\");\n }\n\n private void OnPlayerExitedZone(VRCPlayerApi player)\n {\n Debug.Log($\"[StationZoneCheck] {player.displayName} exited zone (unseated)\");\n }\n}\n\n```\n\n---\n\n### Workaround 2: Position Polling for Mobile Stations\n\nFor stations that can move (avatar stations, moving platforms), poll seated player positions periodically. This approach checks every 0.5 seconds instead of every frame to reduce overhead.\n\n```csharp\n\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.None)]\npublic class StationZoneCheckPolling : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [SerializeField] private Collider zoneCollider;\n\n [Header(\"Settings\")]\n [SerializeField] private int maxTrackedPlayers = 40;\n [SerializeField] private float pollInterval = 0.5f;\n\n private int[] _seatedPlayerIds;\n private bool[] _isInZone;\n private int _seatedCount = 0;\n private float _lastPollTime = 0f;\n\n void Start()\n {\n _seatedPlayerIds = new int[maxTrackedPlayers];\n _isInZone = new bool[maxTrackedPlayers];\n }\n\n public override void OnStationEntered(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n if (_seatedCount >= maxTrackedPlayers) return;\n\n // Avoid duplicates\n for (int i = 0; i \u003c _seatedCount; i++)\n {\n if (_seatedPlayerIds[i] == player.playerId) return;\n }\n\n _seatedPlayerIds[_seatedCount] = player.playerId;\n _isInZone[_seatedCount] = false;\n _seatedCount++;\n }\n\n public override void OnStationExited(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n RemoveSeatedPlayer(player.playerId, player);\n }\n\n public override void OnPlayerLeft(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n RemoveSeatedPlayer(player.playerId, null);\n }\n\n private void RemoveSeatedPlayer(int playerId, VRCPlayerApi player)\n {\n for (int i = 0; i \u003c _seatedCount; i++)\n {\n if (_seatedPlayerIds[i] == playerId)\n {\n if (_isInZone[i])\n {\n _isInZone[i] = false;\n if (Utilities.IsValid(player))\n {\n OnPlayerExitedZone(player);\n }\n }\n\n // Swap with last element\n _seatedCount--;\n _seatedPlayerIds[i] = _seatedPlayerIds[_seatedCount];\n _isInZone[i] = _isInZone[_seatedCount];\n return;\n }\n }\n }\n\n void Update()\n {\n if (_seatedCount == 0) return;\n if (Time.time - _lastPollTime \u003c pollInterval) return;\n _lastPollTime = Time.time;\n\n Bounds bounds = zoneCollider.bounds;\n\n for (int i = 0; i \u003c _seatedCount; i++)\n {\n VRCPlayerApi player = VRCPlayerApi.GetPlayerById(_seatedPlayerIds[i]);\n if (!Utilities.IsValid(player))\n {\n // Player left without event — clean up\n _seatedCount--;\n _seatedPlayerIds[i] = _seatedPlayerIds[_seatedCount];\n _isInZone[i] = _isInZone[_seatedCount];\n i--;\n continue;\n }\n\n bool currentlyInZone = bounds.Contains(player.GetPosition());\n\n if (currentlyInZone && !_isInZone[i])\n {\n _isInZone[i] = true;\n OnPlayerEnteredZone(player);\n }\n else if (!currentlyInZone && _isInZone[i])\n {\n _isInZone[i] = false;\n OnPlayerExitedZone(player);\n }\n }\n }\n\n private void OnPlayerEnteredZone(VRCPlayerApi player)\n {\n Debug.Log($\"[StationZonePoll] {player.displayName} entered zone\");\n }\n\n private void OnPlayerExitedZone(VRCPlayerApi player)\n {\n Debug.Log($\"[StationZonePoll] {player.displayName} exited zone\");\n }\n}\n\n```\n\n---\n\n### Workaround 3: Follow-Target Collider\n\nWhen neither static bounds check nor position polling is suitable — e.g., avatar stations on moving platforms where the zone itself also moves, or when you need standard Unity trigger events (`OnTriggerEnter`/`OnTriggerExit`) rather than manual polling.\n\n**Concept:** Spawn or enable a hidden GameObject with a trigger collider that follows the seated player's position every frame. This \"proxy collider\" enters trigger zones on behalf of the seated player, restoring normal trigger-based detection.\n\n> **Note:** This script manages a single seated player. Attach one instance per VRCStation.\n> The trigger zone's own UdonBehaviour receives standard `OnTriggerEnter`/`OnTriggerExit`\n> from the proxy collider.\n\n```csharp\n\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n/// \u003csummary>\n/// Enables a hidden trigger collider that follows the seated player every frame.\n/// The proxy enters trigger zones on behalf of the player, restoring normal\n/// OnTriggerEnter / OnTriggerExit detection while the player is seated.\n///\n/// Setup:\n/// 1. Create a child GameObject with a trigger Collider (e.g., SphereCollider).\n/// 2. Place that collider on a layer that interacts with your trigger zone layer\n/// (avoid PlayerLocal — it is disabled during station use).\n/// 3. Assign the child's Collider to followCollider in the Inspector.\n/// 4. Disable the child GameObject by default (the script enables it on seat).\n/// \u003c/summary>\n[UdonBehaviourSyncMode(BehaviourSyncMode.None)]\npublic class PlayerFollowCollider : UdonSharpBehaviour\n{\n [Header(\"References\")]\n [Tooltip(\"Trigger collider on a child GameObject (disabled by default).\")]\n [SerializeField] private Collider followCollider;\n\n private VRCPlayerApi _trackedPlayer;\n private bool _isTracking = false;\n\n public override void OnStationEntered(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n\n _trackedPlayer = player;\n _isTracking = true;\n\n // Place at current position before enabling to avoid a frame of stale position\n followCollider.transform.position = player.GetPosition();\n followCollider.gameObject.SetActive(true);\n }\n\n public override void OnStationExited(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n\n StopTracking();\n }\n\n public override void OnPlayerLeft(VRCPlayerApi player)\n {\n if (!Utilities.IsValid(player)) return;\n\n // OnStationExited may NOT fire when a seated player leaves the instance\n if (_isTracking && _trackedPlayer.playerId == player.playerId)\n {\n StopTracking();\n }\n }\n\n void Update()\n {\n if (!_isTracking) return;\n\n if (!Utilities.IsValid(_trackedPlayer))\n {\n // Player reference became invalid — clean up\n StopTracking();\n return;\n }\n\n followCollider.transform.position = _trackedPlayer.GetPosition();\n }\n\n private void StopTracking()\n {\n _isTracking = false;\n _trackedPlayer = null;\n followCollider.gameObject.SetActive(false);\n }\n}\n\n```\n\n**Key considerations:**\n- The follow collider **must not** be on the PlayerLocal layer (Layer 10) — that layer is disabled for seated players. Use a dedicated interaction layer.\n- Always validate with `Utilities.IsValid()` before accessing player data.\n- Performance: one moving collider per seated player is lightweight compared to polling multiple players against bounds.\n- Cleanup on `OnPlayerLeft` is essential — `OnStationExited` is not guaranteed when a player disconnects.\n- The trigger zone's UdonBehaviour receives standard `OnTriggerEnter`/`OnTriggerExit` from the proxy collider, so existing trigger logic works without modification.\n\n---\n\n### OnPlayerLeft Failsafe (Important)\n\n`OnStationExited` may **not fire** when a seated player leaves the instance. Always pair station tracking with `OnPlayerLeft` cleanup to prevent stale data.\n\n```csharp\n\npublic override void OnPlayerLeft(VRCPlayerApi player)\n{\n if (!Utilities.IsValid(player)) return;\n\n // Clean up any station-related state for this player\n if (player.playerId == _seatedPlayerId)\n {\n _seatedPlayerId = -1;\n _isPlayerInZone = false;\n }\n}\n\n```\n\n---\n\n### Station Disable Behavior\n\n| Action | Effect |\n|--------|--------|\n| Disable the station's **Collider** | Prevents new players from sitting, but does **not** eject seated players |\n| Disable the station's **GameObject** | Force ejects the seated player (`station.gameObject.SetActive(false)`) |\n| Call `station.ExitStation(player)` | Only works for the **local player** (`Networking.LocalPlayer`) |\n\n---\n\n### See Also\n\n- [events.md — OnStationEntered/OnStationExited](events.md#station-events) — Station event signatures and usage\n- [patterns-core.md — Trigger Zone Detection](patterns-core.md#trigger-zone) — Standard trigger zone pattern for walking players\n\n---\n\n## Editor Issues\n\n### UdonSharpBehaviour Displays as UdonBehaviour in Inspector\n\n**Cause:** Proxy system not properly synchronized.\n\n**Solution:**\n\n1. **Reimport the script:**\n - Right-click the `.cs` file -> Reimport\n\n2. **Force sync:**\n - Click the UdonBehaviour component\n - Three-dot menu -> \"Refresh UdonSharp Component\"\n\n3. **Restart Unity if unresolved**\n\n---\n\n### Changes Not Saved on Prefab\n\n**Cause:** UdonSharp uses a proxy system, and changes to the proxy are not auto-saved.\n\n**Solution:**\n\n```csharp\n\n#if UNITY_EDITOR\n// In custom editor or after programmatic changes\nUdonSharpEditorUtility.CopyProxyToUdon(behaviour);\nEditorUtility.SetDirty(behaviour);\n#endif\n\n```\n\n---\n\n### \"The associated script cannot be loaded\"\n\n**Symptoms:**\n- Inspector shows \"The associated script cannot be loaded\" on UdonBehaviour\n- UdonBehaviour component shows no linked program\n- Script compiles in IDE but doesn't run as Udon in Unity\n\n**Causes:**\n1. Script has compile errors\n2. Script GUID mismatch (meta file conflict)\n3. **UdonSharpProgramAsset (`.asset`) is missing** — most common when scripts are created by AI or outside Unity's \"Create > U# Script\" menu\n\n**Solution:**\n1. Fix all compile errors first\n2. Check the Console for detailed error messages\n3. Remove the UdonBehaviour and re-add the UdonSharpBehaviour\n4. **If the `.asset` file is missing**: Install `UdonSharpProgramAssetAutoGenerator.cs` (from `assets/templates/UdonSharpProgramAssetAutoGenerator.cs`, or obtain the implementation from `references/editor-scripting.md`) into your `Assets/Editor/` folder\n5. Reimport each affected `.cs` file (right-click -> Reimport, or make a trivial edit and save) to trigger domain reload and auto-generation\n6. Confirm the matching `.asset` file is created, then re-add the component if needed. See [Editor Scripting Reference: UdonSharpProgramAsset Auto-Generation](editor-scripting.md#udonsharpprogramasset-auto-generation) for details\n\n> **Note**: The auto-generator skips scripts that have compile errors, are `abstract`, do not inherit from `UdonSharpBehaviour`, or already have a registered `.asset`. If the `.asset` is still not generated after reimport, check the Console for error messages.\n\n> **Prevention**: When creating new `.cs` files, the auto-generator MUST be installed proactively — not after breakage. See Rule 8 in `rules/udonsharp-constraints.md` for the mandatory check-install-notify procedure.\n\n---\n\n### UdonBehaviour with Empty Program Source (Silent No-Op)\n\n**Symptoms:**\n\n- `UdonBehaviour` component is attached to the GameObject\n- The corresponding `UdonSharpProgramAsset` (`.asset`) exists in the project\n- The `.cs` script compiles without errors\n- Yet **no Udon events fire** — `Start()`, `Interact()`, `OnPlayerJoined()`, etc. never run\n- No errors, no warnings, no log output\n\n**Cause:**\n\nThe `UdonBehaviour.programSource` field is empty. Without this reference, the component has nothing to execute. Unity treats it as a valid component (no missing-script warning), so the failure is invisible until you notice the script does not run.\n\nThis commonly happens when AI agents automate Unity setup (Unity MCP servers, custom editor scripts, prefab manipulation tools) and add `UdonBehaviour` components directly without going through `AddUdonSharpComponent`.\n\n**Distinction from \"The associated script cannot be loaded\":**\n\n- That error fires when the `.asset` is missing entirely (Rule 8 violation)\n- This silent no-op fires when the `.asset` exists but is not assigned to `programSource` (Rule 9 violation)\n\n**Diagnosis:**\n\n1. Select the GameObject in the Hierarchy\n2. Inspect the `UdonBehaviour` component\n3. Check the **Program Source** field — if it shows `None (Abstract Udon Program Asset)`, this is the cause\n\n**Solution:**\n\nPrefer the UdonSharp helper that wires `programSource` automatically:\n\n```csharp\n\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UdonSharpEditor;\n\n // Creates UdonBehaviour AND sets programSource in one call\n MyScript script = gameObject.AddUdonSharpComponent\u003cMyScript>();\n#endif\n\n```\n\nIf the `UdonBehaviour` was created without `AddUdonSharpComponent`, assign manually:\n\n```csharp\n\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UnityEditor;\n\n UdonBehaviour ub = gameObject.GetComponent\u003cUdonBehaviour>();\n UdonSharpProgramAsset programAsset =\n AssetDatabase.LoadAssetAtPath\u003cUdonSharpProgramAsset>(\n \"Assets/Path/MyScript.asset\");\n Undo.RecordObject(ub, \"Assign UdonSharpProgramAsset\");\n ub.programSource = programAsset;\n EditorUtility.SetDirty(ub);\n#endif\n\n```\n\nOr, in the Inspector, drag the `.asset` from the Project window into the `Program Source` slot.\n\n**Prevention:**\n\nWhen automating UdonBehaviour creation, verify `programSource` is set as a post-step. See [Rule 9 in `rules/udonsharp-constraints.md`](../rules/udonsharp-constraints.md#9-udonbehaviour-component-wiring) for the verification procedure.\n\n---\n\n## Performance Issues\n\n### FPS Drop from Many UdonBehaviours\n\n**Checklist:**\n\n1. **Disable Update() when not needed:**\n\n```csharp\n\n// Don't do this\nvoid Update()\n{\n if (!isActive) return;\n // Processing\n}\n\n// Do this instead\npublic void Activate()\n{\n enabled = true;\n}\n\npublic void Deactivate()\n{\n enabled = false;\n}\n\nvoid Update()\n{\n // Only runs when enabled\n}\n\n```\n\n2. **Reduce cross-script calls:**\n\n```csharp\n\n// Cross-script calls have ~1.5x overhead\n// Use partial classes for large scripts instead\n\n```\n\n3. **Cache component references:**\n\n```csharp\n\n// Wrong - GetComponent every frame\nvoid Update()\n{\n GetComponent\u003cRenderer>().material.color = newColor;\n}\n\n// Correct - Cache in Start()\nprivate Renderer _renderer;\n\nvoid Start()\n{\n _renderer = GetComponent\u003cRenderer>();\n}\n\nvoid Update()\n{\n _renderer.material.color = newColor;\n}\n\n```\n\n4. **Use spatial partitioning:**\n - Only process objects near players\n - Use trigger zones to activate/deactivate\n\n---\n\n### Network Bandwidth Exceeded\n\n**Symptoms:**\n- \"Network rate limited\" warnings\n- Sync delays for all players\n\n**Solution:**\n\n1. **Reduce sync frequency:**\n\n```csharp\n\n// Don't sync every frame\nprivate float _lastSyncTime;\nprivate const float SYNC_INTERVAL = 0.1f; // 10 times per second\n\nvoid Update()\n{\n if (Time.time - _lastSyncTime > SYNC_INTERVAL)\n {\n RequestSerialization();\n _lastSyncTime = Time.time;\n }\n}\n\n```\n\n2. **Use smaller data types:**\n\n```csharp\n\n// byte = 1 byte, int = 4 bytes\n[UdonSynced] private byte smallValue; // 0-255 range\n\n// short = 2 bytes\n[UdonSynced] private short mediumValue; // -32768 to 32767\n\n```\n\n3. **Use Continuous sync mode for smoothly changing values:**\n\n```csharp\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]\npublic class SmoothSync : UdonSharpBehaviour\n{\n [UdonSynced(UdonSyncMode.Smooth)] // Interpolated locally\n private Vector3 position;\n}\n\n```\n\n---\n\n## Common Pitfalls\n\n### Start() Not Called on Inactive Objects\n\n**Problem:**\n\n```csharp\n\n// Inactive GameObjects do not call Start()\npublic class BrokenGimmick : UdonSharpBehaviour\n{\n private AudioSource audioSource;\n\n void Start()\n {\n // This is never reached if the GameObject is inactive!\n audioSource = GetComponent\u003cAudioSource>();\n }\n\n public void PlaySound()\n {\n audioSource.Play(); // NullReferenceException!\n }\n}\n\n```\n\n**Symptoms:**\n- Gimmick is placed in an inactive state\n- NullReferenceException occurs after activation\n- \"Should work but doesn't\" situation\n\n**Solution:**\n\n```csharp\n\n// OnEnable + initialization flag pattern\npublic class RobustGimmick : UdonSharpBehaviour\n{\n private AudioSource audioSource;\n private bool _initialized = false;\n\n void OnEnable()\n {\n Initialize();\n }\n\n void Start()\n {\n Initialize();\n }\n\n private void Initialize()\n {\n if (_initialized) return;\n _initialized = true;\n\n audioSource = GetComponent\u003cAudioSource>();\n }\n\n public void PlaySound()\n {\n Initialize(); // Guard against being called externally first\n if (audioSource != null)\n {\n audioSource.Play();\n }\n }\n}\n\n```\n\n**Situations where this occurs:**\n- Gimmicks placed inactive for performance optimization\n- Conditionally displayed UI or objects\n- Pooled objects (Object Pooling)\n- Gimmicks activated by triggers\n\n---\n\n### Field Initializers Not Working\n\n**Problem:**\n\n```csharp\n\n// This doesn't work as expected\npublic int maxHealth = 100; // Serialized value from Inspector wins\n\n```\n\n**Solution:**\n\n```csharp\n\n// Use Start() or explicit initialization\nprivate int _maxHealth;\n\nvoid Start()\n{\n if (_maxHealth == 0)\n {\n _maxHealth = 100;\n }\n}\n\n```\n\n---\n\n### GetComponent Returns Proxy Instead of UdonSharpBehaviour\n\n**Problem:**\n\n```csharp\n\n// Returns UdonBehaviour, not your type\nvar myScript = other.GetComponent\u003cMyScript>();\n\n```\n\n**Solution (Runtime):**\n\n```csharp\n\n// Cast works at runtime in VRChat\nvar myScript = (MyScript)other.GetComponent(typeof(UdonBehaviour));\n\n```\n\n**Solution (Editor):**\n\n```csharp\n\n#if UNITY_EDITOR\nvar myScript = other.GetUdonSharpComponent\u003cMyScript>();\n#else\nvar myScript = (MyScript)other.GetComponent(typeof(UdonBehaviour));\n#endif\n\n```\n\n---\n\n### Struct Modifications Not Persisting\n\n**Problem:**\n\n```csharp\n\ntransform.position.x = 5; // Doesn't work!\n\n```\n\n**Solution:**\n\n```csharp\n\n// Assign full struct\nVector3 pos = transform.position;\npos.x = 5;\ntransform.position = pos;\n\n```\n\n---\n\n### Cannot Cancel SendCustomEventDelayedSeconds\n\n**Problem:** No built-in way to cancel delayed events.\n\n**Solution:**\n\n```csharp\n\nprivate bool _shouldExecute = true;\n\npublic void ScheduleAction()\n{\n _shouldExecute = true;\n SendCustomEventDelayedSeconds(nameof(DelayedAction), 5f);\n}\n\npublic void CancelAction()\n{\n _shouldExecute = false;\n}\n\npublic void DelayedAction()\n{\n if (!_shouldExecute) return;\n // Do action\n}\n\n```\n\n---\n\n### VRCPlayerApi Becomes Invalid\n\n**Problem:** Holding a `VRCPlayerApi` reference, but the player has left.\n\n**Solution:**\n\n```csharp\n\n// Wrong - Storing reference\nprivate VRCPlayerApi _targetPlayer;\n\n// Correct - Store ID, get player when needed\nprivate int _targetPlayerId = -1;\n\npublic void SetTarget(VRCPlayerApi player)\n{\n _targetPlayerId = player.playerId;\n}\n\npublic VRCPlayerApi GetTarget()\n{\n if (_targetPlayerId \u003c 0) return null;\n\n VRCPlayerApi player = VRCPlayerApi.GetPlayerById(_targetPlayerId);\n if (player == null || !player.IsValid())\n {\n _targetPlayerId = -1;\n return null;\n }\n return player;\n}\n\n```\n\n---\n\n## Debugging Techniques\n\n### Logging Best Practices\n\n```csharp\n\n// Use consistent format\nprivate void Log(string message)\n{\n Debug.Log($\"[{GetType().Name}:{gameObject.name}] {message}\");\n}\n\n// Conditional logging\n[SerializeField] private bool _debugMode = false;\n\nprivate void LogDebug(string message)\n{\n if (_debugMode)\n {\n Debug.Log($\"[DEBUG:{gameObject.name}] {message}\");\n }\n}\n\n```\n\n### State Visualization\n\n```csharp\n\n// Show state in world using TextMeshPro\npublic TextMeshProUGUI debugText;\n\nvoid Update()\n{\n if (debugText != null)\n {\n debugText.text = $\"State: {_currentState}\\n\" +\n $\"Owner: {Networking.GetOwner(gameObject)?.displayName}\\n\" +\n $\"IsLocal: {Networking.IsOwner(gameObject)}\";\n }\n}\n\n```\n\n### Network Debugging\n\n```csharp\n\npublic override void OnPreSerialization()\n{\n LogDebug($\"Sending: value={_syncedValue}\");\n}\n\npublic override void OnDeserialization()\n{\n LogDebug($\"Received: value={_syncedValue}\");\n}\n\npublic override void OnOwnershipTransferred(VRCPlayerApi player)\n{\n LogDebug($\"Ownership -> {player.displayName}\");\n}\n\n```\n\n---\n\n## Quick Reference: Error -> Solution\n\n| Error | Quick fix |\n|-------|-----------|\n| \"does not support X\" | Check constraints.md for alternative |\n| NullReferenceException | Add null checks, validate Inspector refs |\n| Method not found | Make method public, remove parameters |\n| Variables not syncing | SetOwner -> change -> RequestSerialization |\n| FieldChangeCallback silent | Use property setter locally, check nameof() |\n| Heap out of memory | Reuse arrays, avoid string concat in loops |\n| Proxy issues | Reimport script, refresh component |\n| Low FPS | Disable unused Update(), cache components |\n| **NetworkCallable not working** | Add `[NetworkCallable]`, make public |\n| **PlayerData empty** | Wait for `OnPlayerRestored` first |\n| **OnContactEnter not firing** | UdonBehaviour must be on same GameObject |\n| **Contact player is null** | Check `info.isAvatar` before accessing |\n| **Trigger not firing for seated players** | PlayerLocal collider disabled in station — use position check workaround |\n| **.asset missing / script not loaded** | Install auto-generator in `Assets/Editor/`, then reimport the affected `.cs` |\n| **UdonBehaviour silently does nothing** | Check `Program Source` slot — likely empty; assign `.asset` or use `AddUdonSharpComponent` |\n\n---\n\n## Resources\n\n- [Official UdonSharp Docs](https://udonsharp.docs.vrchat.com/)\n- [VRChat Creator Docs](https://creators.vrchat.com/worlds/udon/)\n- [UdonSharp GitHub Issues](https://github.com/vrchat-community/UdonSharp/issues)\n- [VRChat Forums](https://ask.vrchat.com/) - Q&A, solutions\n- [VRChat Canny](https://feedback.vrchat.com/) - Bug reports, known issues\n\n---\n\n## Investigation Steps for Unknown Errors\n\nFor errors not covered in this document, follow these investigation steps:\n\n### Step 1: Search Official Docs (WebSearch)\n\n```yaml\n\nWebSearch: \"error message or keyword site:creators.vrchat.com\"\n\n```\n\n### Step 2: Search VRChat Forums (WebSearch)\n\n```yaml\n\nWebSearch:\n query: \"error message site:ask.vrchat.com\"\n allowed_domains: [\"ask.vrchat.com\"]\n\n```\n\nLook for solutions from community members who encountered the same issue.\n\n### Step 3: Search Canny (Known Bugs)\n\n```yaml\n\nWebSearch:\n query: \"error message site:feedback.vrchat.com\"\n allowed_domains: [\"feedback.vrchat.com\"]\n\n```\n\nCheck whether VRChat officially recognizes the bug and if workarounds exist.\n\n### Step 4: Search GitHub Issues\n\n```yaml\n\nWebSearch:\n query: \"error message site:github.com/vrchat-community/UdonSharp\"\n allowed_domains: [\"github.com\"]\n\n```\n\nCheck for UdonSharp-specific bugs and fix status.\n\n## See Also\n\n- [constraints.md](constraints.md) - Supported and unsupported C# features — the root cause of many compile errors\n- [networking.md](networking.md) - Ownership patterns and sync troubleshooting reference\n\n### Search Tips\n\n| Technique | Example |\n|------------|-----|\n| Exact match | `\"The type or namespace could not be found\"` |\n| SDK version filter | `SDK 3.10 error` |\n| Resolved filter | `solved` or check Canny status |\n| Date filter | Prioritize latest info (old solutions may not work) |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":43175,"content_sha256":"8dd9600a2cc1302c350003b593d40aeed36154033597d0ed154b4ccd7b21d6b5"},{"filename":"references/web-loading-advanced.md","content":"# Web Loading — Advanced Packed Resource Patterns\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\nAdvanced techniques for embedding multiple textures in a single `VRCStringDownloader` response\nto work around `VRCImageDownloader` limitations. For the base API reference see\n[web-loading.md](web-loading.md). For VRAM lifecycle management see [image-loading-vram.md](image-loading-vram.md).\n\n---\n\n## Overview / Motivation\n\n### Why Not Just Use VRCImageDownloader?\n\n`VRCImageDownloader` is the simplest path for texture loading, but it carries constraints that\nbecome bottlenecks in resource-heavy worlds:\n\n| Constraint | Value | Impact |\n|---|---|---|\n| Rate limit | 1 image per 5 seconds, **shared across the entire scene** | 10 images = 50+ seconds to load |\n| Resolution cap | 2048 × 2048 maximum | Server must pre-resize, or download is rejected |\n| Trusted domain list | Separate, shorter list from string loading | Limits hosting options |\n| Images per request | 1 | No way to batch a thumbnail strip into one download |\n| Format | PNG / JPG / GIF only | Cannot use GPU-compressed formats (DXT, ETC2) directly |\n\n`VRCStringDownloader` has its own 5-second rate limit, but it operates on a **separate\nqueue** from image loading. This opens an alternative path: encode texture data as Base64\ninside a string payload and decode it manually in UdonSharp. One string download can carry\nan entire pack of thumbnails, icons, or UI sprites.\n\n### Trade-offs\n\n| Concern | VRCImageDownloader | Packed String Approach |\n|---|---|---|\n| Download count for 8 textures | 8 requests × 5 s = 40+ s | 1 request (if all fit in one file) |\n| CPU cost | None (GPU decode) | Moderate (Base64 decode + `LoadRawTextureData`) |\n| File size | Compressed PNG/JPG | Base64 inflates raw data by ~33% |\n| Complexity | Low | High |\n| Platform format handling | Automatic | Manual (must serve DXT vs ETC2 per platform) |\n\nUse this pattern when: you need many small textures quickly, you are on trusted-string domains\nbut not trusted-image domains, or you need GPU-compressed formats for Quest memory savings.\n\n---\n\n## Custom Binary-Mixed Text Format\n\n### Design Goals\n\nA single downloaded string must carry:\n1. A version tag so future format changes do not break existing worlds\n2. Metadata (dimensions, format, block positions) that can be parsed without reading the whole string\n3. One or more Base64-encoded texture data blocks, each independently decodable\n\n### Format Layout\n\n```text\n[VERSION_HEADER]\\n[JSON_LENGTH_DECIMAL]\\n[JSON_METADATA_BLOCK][BASE64_BLOCK_0][BASE64_BLOCK_1]...\n```\n\n| Field | Content | Example |\n|---|---|---|\n| `VERSION_HEADER` | ASCII version tag, terminated by `\\n` | `PACK1\\n` |\n| `JSON_LENGTH_DECIMAL` | Decimal character count of the JSON block, terminated by `\\n` | `312\\n` |\n| `JSON_METADATA_BLOCK` | Plain JSON — no terminator, length given above | `{\"entries\":[...]}` |\n| `BASE64_BLOCK_N` | Raw Base64 string — no newlines, length in JSON | `AAAA...` |\n\n### JSON Metadata Schema\n\n```json\n{\n \"version\": 1,\n \"entries\": [\n {\n \"id\": \"thumb_0\",\n \"width\": 128,\n \"height\": 128,\n \"hasMipmaps\": false,\n \"dataStart\": 0,\n \"dataLength\": 65536\n },\n {\n \"id\": \"thumb_1\",\n \"width\": 128,\n \"height\": 128,\n \"hasMipmaps\": false,\n \"dataStart\": 65536,\n \"dataLength\": 65536\n }\n ]\n}\n```\n\n`dataStart` and `dataLength` are byte offsets **within the concatenated Base64 region**\n(the portion of the string after the JSON block). The parser computes the absolute string\nindex as: `jsonBlockStart + jsonLength + dataStart`.\n\n### Parsing the Format\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.StringLoading;\nusing VRC.SDK3.Data;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class PackFormatParser : UdonSharpBehaviour\n{\n private const string SupportedVersion = \"PACK1\";\n\n // Parsed header state — set by FindJsonStart, read by callers\n private int _parsedJsonStart = -1;\n private int _parsedJsonLength = -1;\n\n /**\n * Parses the version header and JSON length prefix.\n * Returns the JSON start index on success, or -1 on parse failure.\n * Also stores the JSON length in _parsedJsonLength for use by the caller.\n * UdonSharp blocks out parameters in user-defined methods; use fields instead.\n */\n private int FindJsonStart(string raw)\n {\n _parsedJsonStart = -1;\n _parsedJsonLength = -1;\n\n // Line 1: version\n int firstNewline = raw.IndexOf('\\n');\n if (firstNewline \u003c 0) return -1;\n\n string version = raw.Substring(0, firstNewline);\n if (version != SupportedVersion) return -1;\n\n // Line 2: JSON length as decimal string\n int secondNewline = raw.IndexOf('\\n', firstNewline + 1);\n if (secondNewline \u003c 0) return -1;\n\n string lengthStr = raw.Substring(firstNewline + 1, secondNewline - firstNewline - 1);\n if (!int.TryParse(lengthStr, out int parsedLength) || parsedLength \u003c= 0) return -1;\n\n _parsedJsonStart = secondNewline + 1;\n _parsedJsonLength = parsedLength;\n return _parsedJsonStart;\n }\n\n /** Extract the Base64 sub-string for one entry using pre-parsed offsets */\n private string ExtractBase64Block(string raw, int jsonStart, int jsonLength, int dataStart, int dataLength)\n {\n int blockRegionStart = jsonStart + jsonLength;\n int absoluteStart = blockRegionStart + dataStart;\n return raw.Substring(absoluteStart, dataLength);\n }\n}\n```\n\nThe key insight is that `Substring()` is called **with pre-computed absolute indices** from\nthe JSON metadata — the parser never re-scans the string character by character.\n\n---\n\n## Base64 Texture Embedding\n\n### Encoding Pipeline (Server Side)\n\nThe server compresses raw pixel data into GPU-ready format, Base64-encodes each texture\nblock, serialises the metadata JSON, then assembles the pack string and serves it over HTTPS.\n\n### Decoding in UdonSharp\n\n`System.Convert` is available in UdonSharp (verified in SDK 3.7.1+; not explicitly listed in the official allowlist but confirmed working in production worlds). Raw GPU texture data is loaded with `LoadRawTextureData`, which bypasses PNG/JPG decompression and writes the bytes directly into GPU memory.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing System;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class Base64TextureDecoder : UdonSharpBehaviour\n{\n /**\n * Decodes a Base64 string into a Texture2D using raw GPU data.\n * format must match the compression used when the data was prepared on the server.\n * Caller is responsible for calling Destroy() on the returned texture when done.\n */\n public Texture2D DecodeTexture(string base64Data, int width, int height,\n TextureFormat format, bool hasMipmaps)\n {\n if (string.IsNullOrEmpty(base64Data)) return null;\n\n byte[] rawBytes = Convert.FromBase64String(base64Data);\n if (rawBytes == null || rawBytes.Length == 0) return null;\n\n Texture2D tex = new Texture2D(width, height, format, hasMipmaps);\n tex.LoadRawTextureData(rawBytes);\n tex.Apply();\n return tex;\n }\n\n /**\n * Creates a Sprite from a decoded Texture2D for use with UnityEngine.UI.Image.\n * pixelsPerUnit controls how the sprite scales in UI layouts.\n */\n public Sprite TextureToSprite(Texture2D tex, float pixelsPerUnit = 100f)\n {\n if (tex == null) return null;\n Rect rect = new Rect(0f, 0f, tex.width, tex.height);\n Vector2 pivot = new Vector2(0.5f, 0.5f);\n return Sprite.Create(tex, rect, pivot, pixelsPerUnit);\n }\n}\n```\n\n### `LoadRawTextureData` vs `LoadImage`\n\n| Method | Input | GPU format | Decode cost |\n|---|---|---|---|\n| `LoadRawTextureData(byte[])` | Pre-compressed GPU bytes (DXT, ETC2, uncompressed) | Whatever you specify in `TextureFormat` | None — bytes go straight to GPU |\n| `LoadImage(byte[])` | PNG or JPG file bytes | Always `RGBA32` or `RGB24` | CPU decode from PNG/JPG, then re-upload |\n\nUse `LoadRawTextureData` when you have GPU-compressed data from the server.\nUse `LoadImage` only as a fallback when serving PNG/JPG via string download.\n\n### VRAM Ownership\n\nA texture created with `new Texture2D(...)` and `LoadRawTextureData` is owned by your\ncode — not by any downloader. You must call `Destroy(texture)` when done.\nSee [image-loading-vram.md](image-loading-vram.md) for the full lifecycle and the\nDispose/Destroy distinction.\n\n---\n\n## Cross-Platform Texture Compression\n\n### Why Compression Format Matters\n\n`LoadRawTextureData` requires the bytes to be in a format the GPU natively supports.\nPC (DirectX) and Android/Quest (OpenGL ES / Vulkan) support different compressed formats:\n\n| Platform | Opaque format | With-alpha format | Notes |\n|---|---|---|---|\n| PC (Windows / Mac) | `TextureFormat.DXT1` | `TextureFormat.DXT5` | DXT1 = BC1, DXT5 = BC3 |\n| Android / Quest | `TextureFormat.ETC2_RGB` | `TextureFormat.ETC2_RGBA8` | ETC2 is standard on GLES 3.0+ |\n\n> **Important:** Use the non-Crunched formats (`DXT1`, `DXT5`, `ETC2_RGB`, `ETC2_RGBA8`) with `LoadRawTextureData`. Crunched variants (`DXT1Crunched`, `ETC_RGB4Crunched`, etc.) require Unity's additional decompression step and cannot be loaded via raw byte upload.\n\nLoading DXT data on Quest or ETC2 data on PC will produce garbled visuals or a Unity error.\n\n### Compile-Time Platform Selection\n\nUse the `#if UNITY_ANDROID` preprocessor directive to choose the correct format and URL\nat build time. The world is compiled separately for PC and Android, so the correct branch\nis baked in.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class PlatformFormatSelector : UdonSharpBehaviour\n{\n // Inspector fields: assign both sets; only the platform-appropriate one is used\n [Header(\"PC pack URLs\")]\n [SerializeField] private VRCUrl[] _packUrlsPC;\n\n [Header(\"Android/Quest pack URLs\")]\n [SerializeField] private VRCUrl[] _packUrlsAndroid;\n\n /** Returns the URL array for the current build platform */\n public VRCUrl[] GetPlatformUrls()\n {\n#if UNITY_ANDROID\n return _packUrlsAndroid;\n#else\n return _packUrlsPC;\n#endif\n }\n\n /** Returns the opaque texture format for the current build platform */\n public TextureFormat GetOpaqueFormat()\n {\n#if UNITY_ANDROID\n return TextureFormat.ETC2_RGB;\n#else\n return TextureFormat.DXT1;\n#endif\n }\n\n /** Returns the alpha-capable texture format for the current build platform */\n public TextureFormat GetAlphaFormat()\n {\n#if UNITY_ANDROID\n return TextureFormat.ETC2_RGBA8;\n#else\n return TextureFormat.DXT5;\n#endif\n }\n}\n```\n\n### Server-Side Requirements\n\nThe server must maintain separate pack files per platform:\n\n```text\ndata_pc.bin — DXT1/DXT5-compressed texture data\ndata_android.bin — ETC2-compressed texture data\n```\n\nBoth files share the same format header and JSON metadata schema; only the `BASE64_BLOCK`\nbytes differ. The `TextureFormat` field in each entry's JSON should indicate the stored\nformat so the decoder does not need to infer it from the URL.\n\n---\n\n## URL Index Double-Key Pattern\n\n### The Problem\n\nA world may need hundreds of small resources (thumbnails, icons, UI sprites). Packing them\nall into a single URL file would make that file enormous. But downloading every file upfront\nwastes bandwidth and time. The solution is to **spread resources across multiple URL files**\nand only download the file that contains the resource currently needed.\n\n### Double-Key Addressing\n\nEach resource is identified by two integers:\n- `urlIndex` — which pack file to download (index into the `VRCUrl[]` array)\n- `innerIndex` — position of the resource within that pack file\n\nThe JSON metadata maps resource IDs to these pairs:\n\n```json\n{\n \"resources\": {\n \"avatar_0\": { \"urlIndex\": 0, \"innerIndex\": 0 },\n \"avatar_1\": { \"urlIndex\": 0, \"innerIndex\": 1 },\n \"avatar_2\": { \"urlIndex\": 1, \"innerIndex\": 0 },\n \"avatar_3\": { \"urlIndex\": 1, \"innerIndex\": 1 }\n }\n}\n```\n\n### UdonSharp Lookup (Parallel Arrays)\n\nUdonSharp has no Dictionary. Use parallel arrays to simulate a map from resource ID strings\nto `(urlIndex, innerIndex)` pairs. Populate these arrays from JSON at startup.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Data;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class ResourceIndex : UdonSharpBehaviour\n{\n /** Parallel arrays: _resourceIds[i] maps to (_urlIndices[i], _innerIndices[i]) */\n private string[] _resourceIds = new string[0];\n private int[] _urlIndices = new int[0];\n private int[] _innerIndices = new int[0];\n private int _count = 0;\n\n /** Populate the index from a parsed DataDictionary (the \"resources\" object) */\n public void BuildIndex(DataDictionary resourcesDict)\n {\n _count = resourcesDict.Count;\n _resourceIds = new string[_count];\n _urlIndices = new int[_count];\n _innerIndices = new int[_count];\n\n DataList keys = resourcesDict.GetKeys();\n for (int i = 0; i \u003c _count; i++)\n {\n string id = keys[i].String;\n DataDictionary entry = resourcesDict[id].DataDictionary;\n _resourceIds[i] = id;\n _urlIndices[i] = (int)entry[\"urlIndex\"].Double;\n _innerIndices[i] = (int)entry[\"innerIndex\"].Double;\n }\n }\n\n // Result fields written by GetUrlIndex / GetInnerIndex — read immediately after the call\n // UdonSharp blocks out parameters in user-defined methods; use fields instead.\n [HideInInspector] public int LastUrlIndex = -1;\n [HideInInspector] public int LastInnerIndex = -1;\n\n /**\n * Searches for resourceId and returns true if found.\n * On success, LastUrlIndex and LastInnerIndex are set to the resolved address.\n * On failure, both fields are set to -1.\n */\n public bool TryGetAddress(string resourceId)\n {\n for (int i = 0; i \u003c _count; i++)\n {\n if (_resourceIds[i] == resourceId)\n {\n LastUrlIndex = _urlIndices[i];\n LastInnerIndex = _innerIndices[i];\n return true;\n }\n }\n LastUrlIndex = -1;\n LastInnerIndex = -1;\n return false;\n }\n}\n```\n\nWhen a resource is requested, check `TryGetAddress` to find `urlIndex`, then check the\nLRU cache (next section) for that `urlIndex`. If the pack is not cached, download it;\notherwise, decode `innerIndex` from the cached data immediately.\n\n---\n\n## LRU-Style Decode Cache\n\n### Why Cache Decoded Packs\n\nDownloading and Base64-decoding a pack file is expensive. If the player navigates through\nresource pages that share pack files, you want the second visit to return instantly without\na network round-trip.\n\n### Fixed-Capacity Buffer with FIFO Eviction\n\nUdonSharp has no Dictionary. A fixed-size parallel-array buffer tracks which pack files\nhave been decoded. When the buffer is full, the **oldest entry** (index 0) is evicted —\nall remaining entries shift down by one slot. This is a simple queue-based LRU approximation\nsuitable for small capacities (2–5 entries).\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class PackedResourceCache : UdonSharpBehaviour\n{\n private const int CacheCapacity = 3;\n\n /**\n * Parallel arrays for the cache.\n * _cacheKeys[i] = urlIndex of the cached pack (-1 = empty slot)\n * _cacheData[i] = decoded string[] of Base64 blocks for that pack\n */\n private int[] _cacheKeys = new int[CacheCapacity];\n private string[][] _cacheData = new string[CacheCapacity][];\n private int _cacheCount = 0;\n\n void Start()\n {\n for (int i = 0; i \u003c CacheCapacity; i++)\n {\n _cacheKeys[i] = -1;\n }\n }\n\n /** Returns true if the pack for urlIndex is already cached */\n public bool Contains(int urlIndex)\n {\n for (int i = 0; i \u003c _cacheCount; i++)\n {\n if (_cacheKeys[i] == urlIndex) return true;\n }\n return false;\n }\n\n /**\n * Returns the decoded Base64 block array for the given urlIndex.\n * Returns null if not in cache.\n */\n public string[] Get(int urlIndex)\n {\n for (int i = 0; i \u003c _cacheCount; i++)\n {\n if (_cacheKeys[i] == urlIndex) return _cacheData[i];\n }\n return null;\n }\n\n /**\n * Adds a decoded pack to the cache.\n * If at capacity, evicts the oldest entry (index 0) first.\n */\n public void Add(int urlIndex, string[] decodedBlocks)\n {\n if (_cacheCount >= CacheCapacity)\n {\n EvictOldest();\n }\n\n _cacheKeys[_cacheCount] = urlIndex;\n _cacheData[_cacheCount] = decodedBlocks;\n _cacheCount++;\n }\n\n /** Shifts all entries down by one, dropping index 0 */\n private void EvictOldest()\n {\n for (int i = 0; i \u003c CacheCapacity - 1; i++)\n {\n _cacheKeys[i] = _cacheKeys[i + 1];\n _cacheData[i] = _cacheData[i + 1];\n }\n _cacheKeys[CacheCapacity - 1] = -1;\n _cacheData[CacheCapacity - 1] = null;\n _cacheCount = CacheCapacity - 1;\n }\n}\n```\n\n**Note**: The cache stores Base64 strings, not decoded `Texture2D` objects. Decoded textures\nare created on demand and must be managed by the caller using `Destroy()`.\nCaching raw `Texture2D` objects would consume VRAM indefinitely; caching Base64 strings\nonly uses CPU-side managed memory, which the GC can reclaim.\n\n---\n\n## Complete Example\n\nThe following `PackedResourceLoader` ties together all patterns: format parsing, platform\nformat selection, Base64 decoding, double-key addressing, and LRU caching.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\nusing System;\nusing VRC.SDKBase;\nusing VRC.SDK3.StringLoading;\nusing VRC.SDK3.Data;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class PackedResourceLoader : UdonSharpBehaviour\n{\n // ── Inspector ────────────────────────────────────────────────────────────\n\n [Header(\"Pack file URLs — PC build\")]\n [SerializeField] private VRCUrl[] _packUrlsPC;\n\n [Header(\"Pack file URLs — Android/Quest build\")]\n [SerializeField] private VRCUrl[] _packUrlsAndroid;\n\n [Header(\"Index file URL (JSON resource map)\")]\n [SerializeField] private VRCUrl _indexUrl;\n\n [Header(\"UI slots to fill (one Image per slot)\")]\n [SerializeField] private Image[] _uiSlots;\n\n [Header(\"Resource IDs to load into the UI slots\")]\n [SerializeField] private string[] _slotResourceIds;\n\n // ── Private state ────────────────────────────────────────────────────────\n\n private const string PackVersion = \"PACK1\";\n private const int CacheCapacity = 3;\n\n // Platform-selected URL array (set in Start)\n private VRCUrl[] _packUrls;\n\n // Resource index (populated after index file download)\n private string[] _resIds = new string[0];\n private int[] _resUrlIdx = new int[0];\n private int[] _resInnerIdx = new int[0];\n private int _resCount = 0;\n\n // LRU cache: parallel arrays (urlIndex -> decoded Base64 string blocks)\n private int[] _cacheKeys = new int[CacheCapacity];\n private string[][] _cacheData = new string[CacheCapacity][];\n private int _cacheCount = 0;\n\n // Download state\n private bool _indexReady = false;\n private int _pendingUrlIndex = -1; // which pack we are currently downloading\n // Note: _pendingSlotIndex tracks only ONE pending slot. This simplified example assumes\n // at most one in-flight download at a time. A production implementation should use an\n // array to track all pending slots that share the same urlIndex, since multiple UI slots\n // may reference resources from the same pack file.\n private int _pendingSlotIndex = -1; // which UI slot triggered the download\n\n // Tracks which UI slots have a runtime-loaded sprite (vs. an Inspector-assigned sprite).\n // Only destroy the old texture when _hasRuntimeSprite[slot] is true, to avoid\n // destroying Inspector-assigned or shared textures that this script did not create.\n private bool[] _hasRuntimeSprite = new bool[0];\n\n // ── Lifecycle ────────────────────────────────────────────────────────────\n\n void Start()\n {\n#if UNITY_ANDROID\n _packUrls = _packUrlsAndroid;\n#else\n _packUrls = _packUrlsPC;\n#endif\n\n for (int i = 0; i \u003c CacheCapacity; i++)\n {\n _cacheKeys[i] = -1;\n }\n\n _hasRuntimeSprite = new bool[_uiSlots.Length];\n\n // Download resource index first\n VRCStringDownloader.LoadUrl(_indexUrl, (IUdonEventReceiver)this);\n }\n\n // ── Download callbacks ───────────────────────────────────────────────────\n\n public override void OnStringLoadSuccess(IVRCStringDownload result)\n {\n if (!_indexReady)\n {\n // First download is always the index file\n ParseIndexFile(result.Result);\n _indexReady = true;\n // Begin loading all UI slots\n SendCustomEvent(nameof(LoadNextSlot));\n return;\n }\n\n // Subsequent downloads are pack files\n if (_pendingUrlIndex \u003c 0) return;\n ParseAndCachePack(result.Result, _pendingUrlIndex);\n\n // Apply the texture for the slot that triggered this download\n if (_pendingSlotIndex >= 0)\n {\n ApplySlotTexture(_pendingSlotIndex);\n }\n\n _pendingUrlIndex = -1;\n _pendingSlotIndex = -1;\n }\n\n public override void OnStringLoadError(IVRCStringDownload result)\n {\n Debug.LogError($\"[PackedResourceLoader] Download error {result.ErrorCode}: {result.Error}\");\n _pendingUrlIndex = -1;\n _pendingSlotIndex = -1;\n }\n\n // ── Index parsing ────────────────────────────────────────────────────────\n\n private void ParseIndexFile(string json)\n {\n if (!VRCJson.TryDeserializeFromJson(json, out DataToken root)) return;\n\n DataDictionary rootDict = root.DataDictionary;\n if (!rootDict.TryGetValue(\"resources\", out DataToken resToken)) return;\n\n DataDictionary resDict = resToken.DataDictionary;\n DataList keys = resDict.GetKeys();\n int count = keys.Count;\n\n _resIds = new string[count];\n _resUrlIdx = new int[count];\n _resInnerIdx = new int[count];\n _resCount = count;\n\n for (int i = 0; i \u003c count; i++)\n {\n string id = keys[i].String;\n DataDictionary entry = resDict[id].DataDictionary;\n _resIds[i] = id;\n _resUrlIdx[i] = (int)entry[\"urlIndex\"].Double;\n _resInnerIdx[i] = (int)entry[\"innerIndex\"].Double;\n }\n }\n\n // ── Slot loading ─────────────────────────────────────────────────────────\n\n private int _slotLoadCursor = 0;\n\n public void LoadNextSlot()\n {\n if (_slotLoadCursor >= _uiSlots.Length) return;\n int slotIdx = _slotLoadCursor;\n _slotLoadCursor++;\n\n if (slotIdx >= _slotResourceIds.Length) return;\n string resId = _slotResourceIds[slotIdx];\n\n // Resolve address\n int urlIdx = -1, innerIdx = -1;\n for (int i = 0; i \u003c _resCount; i++)\n {\n if (_resIds[i] == resId)\n {\n urlIdx = _resUrlIdx[i];\n innerIdx = _resInnerIdx[i];\n break;\n }\n }\n if (urlIdx \u003c 0) return;\n\n // Cache hit: apply immediately, then continue to next slot without rate-limit delay\n for (int i = 0; i \u003c _cacheCount; i++)\n {\n if (_cacheKeys[i] == urlIdx)\n {\n ApplyTextureFromCache(slotIdx, _cacheData[i], innerIdx);\n SendCustomEvent(nameof(LoadNextSlot));\n return;\n }\n }\n\n // Cache miss: download pack\n if (urlIdx >= _packUrls.Length) return;\n _pendingUrlIndex = urlIdx;\n _pendingSlotIndex = slotIdx;\n VRCStringDownloader.LoadUrl(_packUrls[urlIdx], (IUdonEventReceiver)this);\n // Rate limit: next slot after 5.5 s\n SendCustomEventDelayedSeconds(nameof(LoadNextSlot), 5.5f);\n }\n\n // ── Pack parsing ─────────────────────────────────────────────────────────\n\n private void ParseAndCachePack(string raw, int urlIdx)\n {\n // Validate version header\n int firstNl = raw.IndexOf('\\n');\n if (firstNl \u003c 0) return;\n if (raw.Substring(0, firstNl) != PackVersion) return;\n\n // Read JSON length\n int secondNl = raw.IndexOf('\\n', firstNl + 1);\n if (secondNl \u003c 0) return;\n string lenStr = raw.Substring(firstNl + 1, secondNl - firstNl - 1);\n if (!int.TryParse(lenStr, out int jsonLen) || jsonLen \u003c= 0) return;\n\n int jsonStart = secondNl + 1;\n if (jsonStart + jsonLen > raw.Length) return;\n string jsonText = raw.Substring(jsonStart, jsonLen);\n\n if (!VRCJson.TryDeserializeFromJson(jsonText, out DataToken metaToken)) return;\n DataDictionary meta = metaToken.DataDictionary;\n\n if (!meta.TryGetValue(\"entries\", out DataToken entriesToken)) return;\n DataList entries = entriesToken.DataList;\n int entryCount = entries.Count;\n\n string[] blocks = new string[entryCount];\n int blockRegionStart = jsonStart + jsonLen;\n\n for (int i = 0; i \u003c entryCount; i++)\n {\n DataDictionary entry = entries[i].DataDictionary;\n int dataStart = (int)entry[\"dataStart\"].Double;\n int dataLength = (int)entry[\"dataLength\"].Double;\n if (dataStart \u003c 0 || blockRegionStart + dataStart + dataLength > raw.Length) return;\n blocks[i] = raw.Substring(blockRegionStart + dataStart, dataLength);\n }\n\n // Store into LRU cache\n if (_cacheCount >= CacheCapacity)\n {\n // Evict oldest\n for (int i = 0; i \u003c CacheCapacity - 1; i++)\n {\n _cacheKeys[i] = _cacheKeys[i + 1];\n _cacheData[i] = _cacheData[i + 1];\n }\n _cacheKeys[CacheCapacity - 1] = -1;\n _cacheData[CacheCapacity - 1] = null;\n _cacheCount = CacheCapacity - 1;\n }\n\n _cacheKeys[_cacheCount] = urlIdx;\n _cacheData[_cacheCount] = blocks;\n _cacheCount++;\n }\n\n // ── Texture application ───────────────────────────────────────────────────\n\n private void ApplySlotTexture(int slotIdx)\n {\n string resId = _slotResourceIds[slotIdx];\n int urlIdx = -1, innerIdx = -1;\n for (int i = 0; i \u003c _resCount; i++)\n {\n if (_resIds[i] == resId)\n {\n urlIdx = _resUrlIdx[i];\n innerIdx = _resInnerIdx[i];\n break;\n }\n }\n if (urlIdx \u003c 0) return;\n\n for (int i = 0; i \u003c _cacheCount; i++)\n {\n if (_cacheKeys[i] == urlIdx)\n {\n ApplyTextureFromCache(slotIdx, _cacheData[i], innerIdx);\n return;\n }\n }\n }\n\n private void ApplyTextureFromCache(int slotIdx, string[] blocks, int innerIdx)\n {\n if (innerIdx \u003c 0 || innerIdx >= blocks.Length) return;\n if (_uiSlots[slotIdx] == null) return;\n\n // Validate Base64 string before decoding.\n // UdonSharp does not support try/catch, so FormatException from Convert.FromBase64String\n // cannot be caught. A minimal guard: valid Base64 length must be a multiple of 4.\n string base64String = blocks[innerIdx];\n if (base64String.Length == 0 || base64String.Length % 4 != 0) return;\n\n // Decode Base64 to raw bytes\n byte[] rawBytes = Convert.FromBase64String(base64String);\n if (rawBytes == null || rawBytes.Length == 0) return;\n\n // Select platform-appropriate raw GPU format for LoadRawTextureData\n#if UNITY_ANDROID\n TextureFormat fmt = TextureFormat.ETC2_RGB;\n#else\n TextureFormat fmt = TextureFormat.DXT1;\n#endif\n\n // Decode at fixed 128×128 for this example (real code reads width/height from JSON)\n Texture2D tex = new Texture2D(128, 128, fmt, false);\n tex.LoadRawTextureData(rawBytes);\n tex.Apply();\n\n // Destroy old texture only if it was created at runtime by this script.\n // Destroying Inspector-assigned or shared textures would break other references.\n if (_hasRuntimeSprite.Length > slotIdx && _hasRuntimeSprite[slotIdx])\n {\n Sprite oldSprite = _uiSlots[slotIdx].sprite;\n if (oldSprite != null)\n {\n Destroy(oldSprite.texture);\n }\n }\n\n _uiSlots[slotIdx].sprite = Sprite.Create(\n tex,\n new Rect(0f, 0f, tex.width, tex.height),\n new Vector2(0.5f, 0.5f),\n 100f\n );\n\n // Mark this slot as owning a runtime sprite so future updates can safely destroy it.\n if (_hasRuntimeSprite.Length > slotIdx)\n {\n _hasRuntimeSprite[slotIdx] = true;\n }\n }\n}\n```\n\n---\n\n## Anti-Patterns\n\n| Anti-Pattern | Problem | Correct Approach |\n|---|---|---|\n| Not caching decoded packs | Each request re-downloads and re-decodes the same file; 5-second rate-limit delay on every access | Store decoded Base64 blocks in an LRU buffer keyed by `urlIndex` |\n| Using `TextureFormat.DXT1` on Quest / Android | DXT is a DirectX format; GLES/Vulkan hardware cannot decode it — result is garbled pixels or a Unity error | Use `#if UNITY_ANDROID` to select `ETC2_RGB` / `ETC2_RGBA8` |\n| Forgetting `Destroy()` on decoded textures | Every `new Texture2D` + `LoadRawTextureData` allocates VRAM; without `Destroy()` this accumulates until the world crashes | Before replacing a texture slot, call `Destroy(oldTexture)` (see [image-loading-vram.md](image-loading-vram.md)) |\n| Scanning the full string with repeated `Substring()` calls | `O(n²)` string allocation; on a 200 KB pack string with 32 entries this creates hundreds of MB of garbage | Compute `dataStart` + `dataLength` from JSON metadata and call `Substring` once per block with pre-computed absolute indices |\n| Hardcoding texture dimensions in the decoder | Breaks silently when the server changes texture sizes; width/height mismatch produces corrupted images | Store `width` and `height` per entry in the JSON metadata; read them at decode time |\n| Not handling `OnStringLoadError` | A failed download leaves `_pendingUrlIndex` set; the next download response is misattributed to the wrong pack slot | Always implement `OnStringLoadError`; reset `_pendingUrlIndex` and `_pendingSlotIndex` to `-1` |\n| Downloading all pack files at startup regardless of which resources are needed | Wastes bandwidth and saturates the rate-limit queue; especially costly on Quest with slow mobile connections | Use double-key addressing — download a pack file only when a resource from that `urlIndex` is actually requested |\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely Cause | Solution |\n|---|---|---|\n| `Convert.FromBase64String` throws at runtime | `System.Convert` unavailable in older SDK | Requires SDK 3.7.1+; check SDK version in `ProjectSettings` |\n| Decoded texture is entirely black or garbled | Texture format mismatch (DXT on Quest, or ETC2 on PC) | Verify `#if UNITY_ANDROID` selects the correct `TextureFormat`; confirm server served the right platform file |\n| `LoadRawTextureData` produces corrupt image | Wrong byte count — `dataLength` in JSON does not match actual encoded data | Re-validate the server pack builder; log `rawBytes.Length` vs the expected `width * height * bpp` |\n| VRAM grows after repeated resource loads | `Destroy()` not called on old textures before creating new ones | In `ApplyTextureFromCache`, call `Destroy(oldSprite.texture)` before assigning the new sprite |\n| Second request for same pack re-downloads | LRU cache lookup logic has an off-by-one or key mismatch | Add a `Debug.Log` at cache hit/miss; confirm `_cacheKeys[i] == urlIndex` comparison is correct |\n| Index file parses but `TryGetAddress` always returns false | Resource ID string case mismatch between JSON and `_slotResourceIds` inspector values | Ensure exact string match; JSON keys are case-sensitive |\n| Pack parses but inner texture is blank | `innerIndex` out of range for the decoded `blocks` array | Log `blocks.Length` and `innerIdx`; confirm JSON `entries` array length matches the pack |\n| All downloads stall after one error | `_pendingUrlIndex` is not reset in `OnStringLoadError`; next callback is misrouted | Reset `_pendingUrlIndex = -1` and `_pendingSlotIndex = -1` in `OnStringLoadError` |\n| UI slots load slowly even with cache hits | `LoadNextSlot` delays 5.5 s between all slots, including cache hits | Skip the 5.5 s delay when dispatching `LoadNextSlot` for a cache hit; only delay when a network download is initiated |\n\n---\n\n## See Also\n\n- [web-loading.md](web-loading.md) — Base `VRCStringDownloader` / `VRCImageDownloader` API, rate limits, trusted URL list\n- [image-loading-vram.md](image-loading-vram.md) — VRAM management: `Destroy` vs `Dispose`, double-buffer fade, VRAM cost table\n- [patterns-performance.md](patterns-performance.md) — Frame-budget `Stopwatch` pattern for heavy decode operations in `Update`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":34689,"content_sha256":"3ddeb2447a954274fc76f73a33a21ac0c86494f1a68e5a8b7089d9c3e2692c92"},{"filename":"references/web-loading.md","content":"# Web Loading (String / Image Download)\n\n**Supported SDK Versions**: 3.7.1 - 3.10.3\n\nSince `System.Net` is unavailable in UdonSharp, VRChat-specific APIs must be used to retrieve data from the web.\n\n## Overview\n\n| API | Purpose | Namespace |\n|-----|------|----------|\n| `VRCStringDownloader` | Text/JSON download | `VRC.SDK3.StringLoading` |\n| `VRCImageDownloader` | Image download (Texture2D) | `VRC.SDK3.ImageLoading` |\n| `VRCJson` | JSON parsing (string -> DataDictionary) | `VRC.SDK3.Data` |\n\n## Common Constraints\n\n### Rate Limiting\n\n| Type | Limit | Behavior when exceeded |\n|------|------|-------------|\n| String Loading | **Once per 5 seconds** | Queued and processed in random order |\n| Image Loading | **Once per 5 seconds** (shared across the entire scene) | Queued and processed in random order |\n\n### Trusted URLs (Allowed Domains)\n\nVRChat restricts access to external URLs through a domain allow list for security purposes.\nURLs outside the allow list are blocked unless the user enables **\"Allow Untrusted URLs\"** in their settings.\n\n**Trusted domains for String Loading:**\n\n| Domain | Service |\n|---------|---------|\n| `*.github.io` | GitHub Pages |\n| `gist.githubusercontent.com` | GitHub Gist |\n| `pastebin.com` | Pastebin |\n| `*.vrcdn.cloud` | VRCDN |\n\n**Trusted domains for Image Loading:**\n\n| Domain | Service |\n|---------|---------|\n| `i.imgur.com` | Imgur |\n| `cdn.discordapp.com` | Discord CDN |\n| `*.github.io` | GitHub Pages |\n| `dl.dropboxusercontent.com` | Dropbox |\n| `i.postimg.cc` | Postimages |\n| `i.ibb.co` | ImgBB |\n| `images2.imgbox.com` | imgbox |\n| `i.redd.it` | Reddit |\n| `pbs.twimg.com` | Twitter/X |\n| `api.vrchat.cloud` | VRChat API |\n\n> For the latest list, refer to [VRChat Wiki: Trusted URLs](https://wiki.vrchat.com/wiki/Trusted_URLs).\n> Domains may change, so verify via WebSearch.\n\n### VRCUrl Dynamic Generation Constraints (Important)\n\nVRCUrl **cannot be dynamically generated at runtime**.\nThe Udon VM intentionally blocks this for security reasons (preventing data leaks and malicious URL generation).\n\n#### What you can and cannot do\n\n| Operation | Possible | Description |\n|------|:----:|------|\n| Set VRCUrl field in Inspector | Yes | Serialized at build time |\n| `new VRCUrl(\"https://literal-string\")` | Yes | The literal is embedded as a constant in Udon Assembly and passes the VM's security filter |\n| `new VRCUrl(stringVariable)` | No | **Blocked by Udon VM** - cannot generate from runtime strings |\n| Build URL via string concatenation then convert to VRCUrl | No | Same as above. `\"base\" + param` -> VRCUrl is not possible |\n| `VRCUrlInputField.GetUrl()` | Yes | Retrieves a URL **manually entered/pasted by the user** as VRCUrl |\n| `VRCUrlInputField.SetUrl(vrcUrl)` | Yes | Displays an existing VRCUrl in the InputField (does not generate a new URL) |\n\n> **As of March 2026**: A Feature Request with 158+ votes on Canny remains open.\n> VRChat is considering a partial solution limited to trusted domains, but full dynamic generation is not yet implemented.\n\n#### Why VRCUrlInputField is widely used\n\nVRCUrlInputField is the only way to obtain a URL at runtime through \"user manual input\",\nand is effectively a required component for worlds that need \"dynamic URLs\" such as video players or custom API calls.\n\n> **Note**: URLs obtained via VRCUrlInputField are also **subject to Trusted URL checks**.\n> If using URLs outside trusted domains, the user must have \"Allow Untrusted URLs\" enabled.\n\n```csharp\n// VRCUrlInputField pattern: User enters URL -> retrieve in Udon\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.Components;\nusing VRC.SDK3.StringLoading;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class UserUrlLoader : UdonSharpBehaviour\n{\n [SerializeField] private VRCUrlInputField urlInputField;\n\n // Called from UI button's OnClick\n public void OnLoadButtonClicked()\n {\n VRCUrl url = urlInputField.GetUrl();\n VRCStringDownloader.LoadUrl(url, (IUdonEventReceiver)this);\n }\n\n public override void OnStringLoadSuccess(IVRCStringDownload result)\n {\n Debug.Log($\"Loaded: {result.Result.Length} chars\");\n }\n\n public override void OnStringLoadError(IVRCStringDownload result)\n {\n Debug.LogError($\"Error: {result.Error}\");\n }\n}\n```\n\n#### Design Patterns When Dynamic URLs Are Needed\n\n| Pattern | Description | Example use |\n|---------|------|--------|\n| **VRCUrlInputField** | Have the user enter the URL | Video players, custom APIs |\n| **VRCUrl array + index** | Pre-set all candidates in Inspector, select by index | BGM lists, image galleries |\n| **Server-side routing** | Server processes parameters against a fixed URL | Scoreboards (return via a single `/api/scores`) |\n\n```csharp\n// VRCUrl array pattern: Pre-defined + index selection\n[SerializeField] private VRCUrl[] imageUrls; // Set multiple URLs in Inspector\n[SerializeField] private Material targetMaterial;\nprivate VRCImageDownloader _downloader; // Initialize in Start()\nprivate int _currentIndex = 0;\n\npublic void LoadNext()\n{\n if (_currentIndex >= imageUrls.Length) _currentIndex = 0;\n _downloader.DownloadImage(\n imageUrls[_currentIndex],\n targetMaterial,\n (IUdonEventReceiver)this,\n new TextureInfo()\n );\n _currentIndex++;\n}\n```\n\n```csharp\n// Server-side routing pattern: Fixed single URL, server returns the data\n[SerializeField] private VRCUrl scoreBoardUrl; // \"https://example.github.io/scores.json\"\n\npublic void FetchScores()\n{\n VRCStringDownloader.LoadUrl(scoreBoardUrl, (IUdonEventReceiver)this);\n}\n// -> The server returns the latest data. No need to change the URL itself.\n```\n\n#### Anti-Patterns\n\n```csharp\n// NG: Generating VRCUrl from a runtime string - blocked by Udon VM\nstring dynamicUrl = \"https://api.example.com/data?id=\" + playerId;\nVRCUrl url = new VRCUrl(dynamicUrl); // Compiles but fails at runtime\n\n// NG: Attempting to generate a new dynamic URL via SetUrl -> GetUrl\n// SetUrl only displays an existing VRCUrl in the field.\n// GetUrl() returns the same VRCUrl that was passed to SetUrl,\n// and does not generate a new VRCUrl from a dynamic string.\n// urlInputField.SetUrl(existingVRCUrl); // Sets an existing VRCUrl\n// VRCUrl result = urlInputField.GetUrl(); // Returns the same object\n```\n\n### URL Redirect Limitations\n\n| API | Redirects | Notes |\n|-----|:----------:|------|\n| String Loading | Supported but subject to Trusted URL checks | Redirect destination must also be a trusted domain |\n| Image Loading | **Not supported** | Direct URLs required. Short URLs and redirect URLs cannot be used |\n\n---\n\n## VRCStringDownloader\n\n### API\n\n```csharp\nusing VRC.SDK3.StringLoading;\n\n// Static method: Download text from URL\nVRCStringDownloader.LoadUrl(VRCUrl url, IUdonEventReceiver udonBehaviour);\n```\n\n### IVRCStringDownload Properties\n\n| Property | Type | Description |\n|-----------|-----|------|\n| `Result` | `string` | Downloaded string (UTF-8 decoded) |\n| `ResultBytes` | `byte[]` | Downloaded raw byte data |\n| `Error` | `string` | Error message (on failure) |\n| `ErrorCode` | `int` | HTTP error code (on failure) |\n| `IsComplete` | `bool` | Download completion flag |\n| `Url` | `VRCUrl` | Requested URL |\n| `UdonBehaviour` | `UdonBehaviour` | Event receiver target |\n\n### Events\n\n| Event | Timing |\n|---------|-----------|\n| `OnStringLoadSuccess(IVRCStringDownload result)` | On successful download |\n| `OnStringLoadError(IVRCStringDownload result)` | On download failure |\n\n### Basic Pattern\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.StringLoading;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class StringDownloadExample : UdonSharpBehaviour\n{\n public VRCUrl dataUrl;\n\n public void StartDownload()\n {\n VRCStringDownloader.LoadUrl(dataUrl, (IUdonEventReceiver)this);\n }\n\n public override void OnStringLoadSuccess(IVRCStringDownload result)\n {\n string data = result.Result;\n Debug.Log($\"[StringDownload] Success: {data.Length} chars\");\n }\n\n public override void OnStringLoadError(IVRCStringDownload result)\n {\n Debug.LogError($\"[StringDownload] Error {result.ErrorCode}: {result.Error}\");\n }\n}\n```\n\n---\n\n## VRCImageDownloader\n\n### API\n\n```csharp\nusing VRC.SDK3.ImageLoading;\n\n// Constructor: Create an instance (reusable)\nVRCImageDownloader imageDownloader = new VRCImageDownloader();\n\n// Execute download (multiple overloads)\nIVRCImageDownload imageDownloader.DownloadImage(\n VRCUrl url,\n Material material, // Material to apply texture to\n IUdonEventReceiver udonBehaviour, // Event receiver target\n TextureInfo textureInfo // (Optional) Texture settings\n);\n```\n\n**Note (UdonSharp)**: If the `udonBehaviour` parameter is omitted, **events will not be received**.\nIn UdonGraph, the current UdonBehaviour is used when omitted, but in UdonSharp it must be explicitly specified.\n\n### IVRCImageDownload Properties\n\n| Property | Type | Description |\n|-----------|-----|------|\n| `Result` | `Texture2D` | Downloaded texture |\n| `SizeInMemoryBytes` | `int` | Texture memory size (bytes) |\n| `Error` | `string` | Error message (on failure) |\n| `ErrorCode` | `int` | HTTP error code (on failure) |\n| `TextureInfo` | `TextureInfo` | Specified texture settings |\n| `Material` | `Material` | Specified material |\n\n### TextureInfo Properties\n\n| Property | Type | Default | Description |\n|-----------|-----|-----------|------|\n| `GenerateMipmaps` | `bool` | `false` | Mipmap generation |\n| `FilterMode` | `FilterMode` | `Trilinear` | Texture filtering |\n| `WrapModeU` | `TextureWrapMode` | `Repeat` | U-axis wrap mode |\n| `WrapModeV` | `TextureWrapMode` | `Repeat` | V-axis wrap mode |\n| `WrapModeW` | `TextureWrapMode` | `Repeat` | W-axis wrap mode |\n| `AnisoLevel` | `int` | `9` | Anisotropic filtering (0=disabled, 9-16=enabled) |\n| `MaterialProperty` | `string` | `null` | Override for texture target property name |\n\n### Image Constraints\n\n| Constraint | Value |\n|------|-----|\n| Maximum resolution | **2048 x 2048** (error if exceeded) |\n| Redirects | **Not supported** (direct URL required) |\n| Texture format | Auto-selected: with alpha -> RGBA32/RGB64, without -> RGB24/RGB48 |\n\n### Events\n\n| Event | Timing |\n|---------|-----------|\n| `OnImageLoadSuccess(IVRCImageDownload result)` | On successful download |\n| `OnImageLoadError(IVRCImageDownload result)` | On download failure |\n\n### Memory Management (Important)\n\nMemory is consumed each time an image is downloaded.\nWhen replacing old images, **always release with `Dispose()`** to free VRC internal state.\n\n> **Important**: `Dispose()` only frees the VRC download wrapper — it does NOT release the GPU memory\n> (VRAM) of textures already applied to materials. To free VRAM, call `Destroy(texture)` on the old\n> texture before applying a new one. For detailed guidance on VRAM management, double-buffer fading,\n> and other advanced patterns, see [image-loading-vram.md](image-loading-vram.md).\n\n```csharp\n// Dispose individual download results\nIVRCImageDownload oldDownload;\noldDownload.Dispose();\n\n// Dispose the downloader itself (releases all textures)\nimageDownloader.Dispose();\n```\n\n### Basic Pattern\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing UnityEngine.UI;\nusing VRC.SDKBase;\nusing VRC.SDK3.ImageLoading;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class ImageDownloadExample : UdonSharpBehaviour\n{\n public VRCUrl imageUrl;\n public Material targetMaterial;\n public RawImage uiImage; // For displaying in UI\n\n private VRCImageDownloader _downloader;\n private IVRCImageDownload _currentDownload;\n\n void Start()\n {\n _downloader = new VRCImageDownloader();\n }\n\n public void StartDownload()\n {\n // Dispose previous download result\n if (_currentDownload != null)\n {\n _currentDownload.Dispose();\n }\n\n TextureInfo info = new TextureInfo();\n info.GenerateMipmaps = false;\n info.FilterMode = FilterMode.Bilinear;\n\n _currentDownload = _downloader.DownloadImage(\n imageUrl,\n targetMaterial,\n (IUdonEventReceiver)this,\n info\n );\n }\n\n public override void OnImageLoadSuccess(IVRCImageDownload result)\n {\n Debug.Log($\"[ImageDownload] Success: {result.SizeInMemoryBytes} bytes\");\n\n // Apply to UI\n if (uiImage != null)\n {\n uiImage.texture = result.Result;\n }\n }\n\n public override void OnImageLoadError(IVRCImageDownload result)\n {\n Debug.LogError($\"[ImageDownload] Error {result.ErrorCode}: {result.Error}\");\n }\n\n private void OnDestroy()\n {\n // Cleanup: release all textures from memory\n if (_downloader != null)\n {\n _downloader.Dispose();\n }\n }\n}\n```\n\n---\n\n## VRCJson (JSON Parsing of Downloaded Strings)\n\nUsed in combination with String Loading to parse downloaded JSON strings.\n\n### API\n\n```csharp\nusing VRC.SDK3.Data;\n\n// JSON -> DataToken (returns true on success)\nbool VRCJson.TryDeserializeFromJson(string json, out DataToken result);\n\n// DataToken -> JSON string (returns true on success)\nbool VRCJson.TrySerializeToJson(DataToken token, JsonExportType exportType, out DataToken result);\n```\n\n### Important Notes\n\n| Note | Details |\n|--------|------|\n| **Lazy parsing** | Only the top level is parsed immediately. Invalid nested JSON returns `DataError.UnableToParse` on `TryGetValue` |\n| **Numeric type conversion** | During deserialization, all numbers are converted to `Double` (`int` -> `Double`) |\n| **Object references not supported** | DataTokens containing object references cannot be serialized (`DataError.TypeUnsupported`) |\n\n### String Loading + JSON Parsing Pattern\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.SDK3.StringLoading;\nusing VRC.SDK3.Data;\nusing VRC.Udon.Common.Interfaces;\n\n[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]\npublic class JsonDownloadExample : UdonSharpBehaviour\n{\n [Header(\"Data Source\")]\n public VRCUrl jsonUrl;\n\n [Header(\"Display\")]\n public UnityEngine.UI.Text statusText;\n\n public void FetchData()\n {\n VRCStringDownloader.LoadUrl(jsonUrl, (IUdonEventReceiver)this);\n }\n\n public override void OnStringLoadSuccess(IVRCStringDownload result)\n {\n // Parse JSON\n if (!VRCJson.TryDeserializeFromJson(result.Result, out DataToken jsonData))\n {\n Debug.LogError(\"[JsonDownload] JSON parse failed\");\n return;\n }\n\n // Use as DataDictionary\n DataDictionary dict = jsonData.DataDictionary;\n\n // Get values (numbers are stored as Double)\n if (dict.TryGetValue(\"name\", out DataToken nameToken))\n {\n string name = nameToken.String;\n Debug.Log($\"[JsonDownload] name = {name}\");\n }\n\n if (dict.TryGetValue(\"score\", out DataToken scoreToken))\n {\n // Note: JSON numbers are stored as Double\n int score = (int)scoreToken.Double;\n Debug.Log($\"[JsonDownload] score = {score}\");\n }\n\n // Get DataList (arrays)\n if (dict.TryGetValue(\"items\", out DataToken itemsToken))\n {\n DataList items = itemsToken.DataList;\n for (int i = 0; i \u003c items.Count; i++)\n {\n Debug.Log($\"[JsonDownload] item[{i}] = {items[i].String}\");\n }\n }\n\n if (statusText != null)\n {\n statusText.text = \"Data loaded successfully\";\n }\n }\n\n public override void OnStringLoadError(IVRCStringDownload result)\n {\n Debug.LogError($\"[JsonDownload] Error {result.ErrorCode}: {result.Error}\");\n if (statusText != null)\n {\n statusText.text = $\"Error: {result.Error}\";\n }\n }\n}\n```\n\n---\n\n## Design Patterns\n\n### Retry Pattern (Rate Limit Handling)\n\n```csharp\npublic VRCUrl dataUrl;\nprivate int _retryCount = 0;\nprivate const int MAX_RETRIES = 3;\nprivate const float RETRY_DELAY = 6.0f; // 5-second limit + margin\n\npublic void StartDownload()\n{\n _retryCount = 0;\n VRCStringDownloader.LoadUrl(dataUrl, (IUdonEventReceiver)this);\n}\n\npublic override void OnStringLoadError(IVRCStringDownload result)\n{\n if (_retryCount \u003c MAX_RETRIES)\n {\n _retryCount++;\n Debug.LogWarning($\"[Download] Retry {_retryCount}/{MAX_RETRIES}\");\n SendCustomEventDelayedSeconds(nameof(RetryDownload), RETRY_DELAY);\n }\n else\n {\n Debug.LogError($\"[Download] Failed after {MAX_RETRIES} retries: {result.Error}\");\n }\n}\n\npublic void RetryDownload()\n{\n VRCStringDownloader.LoadUrl(dataUrl, (IUdonEventReceiver)this);\n}\n```\n\n### Sequential Download of Multiple URLs\n\n```csharp\npublic VRCUrl[] urls;\nprivate int _currentIndex = 0;\nprivate string[] _results;\n\nvoid Start()\n{\n _results = new string[urls.Length];\n}\n\npublic void StartBatchDownload()\n{\n _currentIndex = 0;\n _results = new string[urls.Length];\n DownloadNext();\n}\n\nprivate void DownloadNext()\n{\n if (_currentIndex >= urls.Length)\n {\n OnAllDownloadsComplete();\n return;\n }\n VRCStringDownloader.LoadUrl(urls[_currentIndex], (IUdonEventReceiver)this);\n}\n\npublic override void OnStringLoadSuccess(IVRCStringDownload result)\n{\n _results[_currentIndex] = result.Result;\n _currentIndex++;\n // Delay to respect rate limiting\n SendCustomEventDelayedSeconds(nameof(DownloadNext), 5.5f);\n}\n\npublic override void OnStringLoadError(IVRCStringDownload result)\n{\n Debug.LogError($\"[Batch] Error at index {_currentIndex}: {result.Error}\");\n _results[_currentIndex] = null;\n _currentIndex++;\n SendCustomEventDelayedSeconds(nameof(DownloadNext), 5.5f);\n}\n\nprivate void OnAllDownloadsComplete()\n{\n Debug.Log(\"[Batch] All downloads complete\");\n}\n```\n\n---\n\n## Troubleshooting\n\n| Symptom | Cause | Solution |\n|------|------|--------|\n| `new VRCUrl(variable)` fails at runtime | Runtime dynamic generation of VRCUrl is not possible | Use VRCUrlInputField (user input) or VRCUrl[] array (pre-defined) |\n| Download does not work at all | Domain not in Trusted URL list | Use a trusted domain, or instruct users to enable \"Allow Untrusted URLs\" |\n| Image download error | Image exceeds 2048x2048 | Pre-resize the image |\n| Image download error | URL redirects | Use a direct URL (short URLs not supported) |\n| Want to download faster than every 5 seconds | Rate limiting | Not possible. Requests are only queued. Processing order is random |\n| Image events not received in UdonSharp | `udonBehaviour` parameter not specified | Explicitly pass `(IUdonEventReceiver)this` |\n| JSON numbers are not `int` | VRCJson specification | Cast with `(int)token.Double` |\n| Memory usage keeps growing | Old textures not Disposed | Release with `IVRCImageDownload.Dispose()` |\n| Error inside JSON after successful parse | Lazy parsing specification | Check for false on `TryGetValue` for nested values |\n\n---\n\n## Reference Links\n\n| Resource | URL |\n|---------|-----|\n| String Loading Official | creators.vrchat.com/worlds/udon/string-loading/ |\n| Image Loading Official | creators.vrchat.com/worlds/udon/image-loading/ |\n| External URLs Official | creators.vrchat.com/worlds/udon/external-urls/ |\n| VRCJson Official | creators.vrchat.com/worlds/udon/data-containers/vrcjson/ |\n| Trusted URLs Wiki | wiki.vrchat.com/wiki/Trusted_URLs |\n| Image Loading Sample | github.com/vrchat-community/examples-image-loading |\n\n## See Also\n\n- [api.md](api.md) - `VRCUrl`, `VRCStringDownloader`, and `VRCImageDownloader` API quick reference\n- [troubleshooting.md](troubleshooting.md) - Web loading error table and debugging tips\n- [image-loading-vram.md](image-loading-vram.md) - Advanced VRAM management: Destroy vs Dispose, double-buffer fade, stock mode, mipmap bias\n- [web-loading-advanced.md](web-loading-advanced.md) - Advanced data loading: Base64 texture embedding, cross-platform compression, LRU cache\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":20139,"content_sha256":"37fb76a70a14592197973d49d8c249fd07aceb35e81cabb69197d50ed366fa22"},{"filename":"rules/udonsharp-constraints.md","content":"# UdonSharp Compile Constraints (Always Loaded)\n\nUdonSharp compiles C# to Udon Assembly. Always adhere to these constraints, which differ from standard C#.\n\n**SDK Coverage**: 3.7.1 - 3.10.3\n\n> For detailed examples, SDK version availability, and compiler behavior explanations,\n> see `references/constraints.md`.\n\n## Blocked Features\n\n| Feature | Alternative |\n|---------|------------|\n| `List\u003cT>`, `Dictionary\u003cT,K>` | `T[]` arrays or `DataList`/`DataDictionary` (VRC.SDK3.Data) |\n| `HashSet\u003cT>`, `Queue\u003cT>`, `Stack\u003cT>` | Implement with arrays |\n| Generic type parameters | Use concrete types |\n| `interface` | Base class inheritance or `SendCustomEvent` |\n| Method overloading | Unique method names (`DoInt`, `DoString`) |\n| Operator overloading | Explicit methods |\n| `try`/`catch`/`finally`/`throw` | Defensive null checks + early return |\n| `async`/`await` | `SendCustomEventDelayedSeconds()` |\n| `yield return` (coroutines) | `SendCustomEventDelayedSeconds()` |\n| `StartCoroutine()` | `SendCustomEventDelayedSeconds()` |\n| Delegates / C# events | `SendCustomEvent` |\n| `Button.onClick.AddListener()` | Configure SendCustomEvent via Inspector |\n| LINQ (`.Where`, `.Select`, etc.) | Manual for loops |\n| Lambda expressions | Named methods |\n| Local functions | private methods |\n| Pattern matching | Traditional `if`/`switch` |\n| Anonymous types | Explicit type definitions |\n| `System.IO`, `System.Net` | `VRCStringDownloader`, `VRCImageDownloader` |\n| `System.Reflection` | Not available |\n| `System.Threading` | Not available |\n| `unsafe`, pointers | Not available |\n\n## Available Features (SDK 3.7.1+)\n\n| Feature | Notes |\n|---------|-------|\n| `System.Text.StringBuilder` | Efficient string concatenation |\n| `System.Text.RegularExpressions` | Regex pattern matching |\n| `System.Random` | Seeded deterministic random numbers |\n| `System.Type` | Runtime type information |\n| `GetComponent\u003cT>()` (inheritance) | Works with UdonSharpBehaviour subclasses (SDK 3.8+) |\n\n## Code Generation Rules\n\n### 1. Class Declaration\n\nMust inherit from `UdonSharpBehaviour`. `MonoBehaviour` is forbidden.\n\n```csharp\nusing UdonSharp;\nusing UnityEngine;\nusing VRC.SDKBase;\nusing VRC.Udon;\n\npublic class MyScript : UdonSharpBehaviour { }\n```\n\n### 2. Field Initialization\n\nField initializers are evaluated at compile time. Scene-dependent references must be obtained in `Start()` or via Lazy Init. See `references/constraints.md` for the lazy-init pattern.\n\n```csharp\n// OK: Compile-time constant\nprivate int maxPlayers = 10;\n\n// NG: Runtime value in field initializer (same value for all instances!)\n// private int rng = Random.Range(0, 100);\n\n// OK: Initialize in Start()\nprivate int rng;\nvoid Start() { rng = Random.Range(0, 100); }\n```\n\n### 3. Struct Mutation\n\nStruct mutation methods do not modify the original value. Use the return value.\n\n```csharp\n// NG: v is not modified\nv.Normalize();\n\n// OK: Assign return value\nv = v.normalized;\n```\n\n### 4. GetComponent Restrictions\n\n`GetComponent\u003cUdonBehaviour>()` is not exposed. Use cast syntax.\n\n```csharp\n// NG\nUdonBehaviour ub = GetComponent\u003cUdonBehaviour>();\n\n// OK\nUdonBehaviour ub = (UdonBehaviour)GetComponent(typeof(UdonBehaviour));\n\n// OK (SDK 3.8+): Generic works for UdonSharpBehaviour subclasses\nMyScript s = GetComponent\u003cMyScript>();\n```\n\n### 5. Access Modifiers\n\nPrefer `private` methods. Public methods slow down Udon's method lookup.\n\nSee [`Event Dispatch & Cross-Behaviour Call Cost Tiers`](../references/patterns-performance.md#event-dispatch--cross-behaviour-call-cost-tiers) for the full method-visibility tier table.\n\n### 6. Recursive Methods\n\nThe `[RecursiveMethod]` attribute is required for recursive calls.\n\n```csharp\n[RecursiveMethod]\nprivate int Factorial(int n) { ... }\n```\n\n### 7. uGUI Button Events and Unity Callbacks\n\n- `Button.onClick.AddListener()` is not available -- configure OnClick via Inspector to call `SendCustomEvent`\n- Unity callbacks (`OnTriggerEnter`, etc.) do **not** require `override` -- `override` is only for VRChat events\n\n```csharp\n// NG: override -> CS0115 error\npublic override void OnTriggerEnter(Collider other) { }\n// OK: No override\npublic void OnTriggerEnter(Collider other) { }\n// OK: VRChat events require override\npublic override void OnPlayerJoined(VRCPlayerApi player) { }\n```\n\n### 8. UdonSharpProgramAsset Requirement\n\nEvery `.cs` UdonSharpBehaviour needs a corresponding `.asset` (UdonSharpProgramAsset). Without it, the script won't compile to Udon.\n\n**When creating a new `.cs` file, the agent MUST follow this procedure:**\n\n1. **Check**: Verify that `Assets/Editor/UdonSharpProgramAssetAutoGenerator.cs` exists in the user's Unity project\n2. **Install if missing**: If the file does not exist, create the `Assets/Editor/` directory (if needed) and write the auto-generator using the implementation from `references/editor-scripting.md` (UdonSharpProgramAsset Auto-Generation section)\n3. **Notify**: Inform the user that the auto-generator was installed and that new `.cs` files will automatically receive `.asset` files on domain reload\n\nDo NOT assume the auto-generator is already installed. The agent cannot verify installation status without explicitly checking, so skipping this procedure based on assumption is prohibited. See `references/editor-scripting.md` for the full implementation.\n\n### 9. UdonBehaviour Component Wiring\n\nAfter the `.asset` file is generated (Rule 8), the GameObject's `UdonBehaviour` component must reference that `.asset` in its **Program Source** field. Without this assignment, the UdonBehaviour exists on the GameObject but executes nothing — no error, no warning, no compile failure. The same silent-failure family as Rule 8, but at the **component layer** instead of the file layer.\n\n| State | `.asset` exists? | `programSource` set? | Symptom |\n|-------|:-:|:-:|---------|\n| Healthy | Yes | Yes | Code runs |\n| Rule 8 violation | No | (n/a) | \"The associated script cannot be loaded\" |\n| Rule 9 violation | Yes | No | Component present, **no events fire**, no log |\n\n**When the agent creates UdonBehaviour components programmatically (Unity automation, editor scripts, prefab manipulation), it MUST verify after creation:**\n\n1. The GameObject has a `UdonBehaviour` component\n2. That component's `programSource` field references the matching `UdonSharpProgramAsset`\n3. The referenced `.asset` is the one paired with the intended `.cs` (same base name, same folder)\n\n**Preferred API (handles wiring automatically):**\n\n```csharp\n#if UNITY_EDITOR && !COMPILER_UDONSHARP\nusing UdonSharpEditor;\n // Creates UdonBehaviour AND sets programSource in one call\n MyScript script = gameObject.AddUdonSharpComponent\u003cMyScript>();\n#endif\n```\n\nWhen manipulating `UdonBehaviour` directly without `AddUdonSharpComponent`, the agent is responsible for assigning `programSource` itself. See `references/editor-scripting.md` for proxy-system specifics and `references/troubleshooting.md` for diagnostic steps.\n\n## Attribute Quick Reference\n\n### Class Level\n\n| Attribute | Purpose |\n|-----------|---------|\n| `[UdonBehaviourSyncMode(mode)]` | Specify sync mode |\n| `[DefaultExecutionOrder(n)]` | Control execution order |\n\n### Field Level\n\n| Attribute | Purpose |\n|-----------|---------|\n| `[UdonSynced]` | Sync field |\n| `[UdonSynced(UdonSyncMode.Linear)]` | Linear interpolation (position/rotation) |\n| `[UdonSynced(UdonSyncMode.Smooth)]` | Smooth interpolation |\n| `[FieldChangeCallback(nameof(Prop))]` | Invoke property setter on change |\n\n### Method Level\n\n| Attribute | Purpose |\n|-----------|---------|\n| `[RecursiveMethod]` | Allow recursive calls |\n| `[NetworkCallable]` | Network event (SDK 3.8.1+) |\n\n## Syncable Types\n\nTypes that can be used with `[UdonSynced]`:\n\n`bool`, `byte`, `sbyte`, `char`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`,\n`float`, `double`, `string` (2 bytes/char; bounded by sync mode budget — keep short in Continuous), `Vector2`, `Vector3`, `Vector4`,\n`Quaternion`, `Color`, `Color32`, `T[]` (arrays of the above types)\n\n## Validation Checklist\n\n- [ ] Not using `List\u003cT>` / `Dictionary\u003cT,K>`\n- [ ] No `interface` declarations\n- [ ] No method overloading (all method names are unique)\n- [ ] No `try`/`catch`\n- [ ] No `async`/`await` / `yield return`\n- [ ] No LINQ / Lambda\n- [ ] No `System.IO` / `System.Net`\n- [ ] Recursive methods have `[RecursiveMethod]`\n- [ ] Using return values for struct methods\n- [ ] Not using `AddListener()`\n- [ ] Unity callbacks (OnTriggerEnter, etc.) do not have override\n- [ ] Auto-generator (`UdonSharpProgramAssetAutoGenerator.cs`) confirmed present in `Assets/Editor/` (installed if it was missing)\n- [ ] Every UdonBehaviour created programmatically has its `programSource` populated with the matching `.asset` (Rule 9)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8723,"content_sha256":"ff44a1e56f890cb99d538faf91d729e72fe63ac7ee833f0f51729453a6b8aed9"},{"filename":"rules/udonsharp-networking.md","content":"# UdonSharp Networking Rules (Always Loaded)\n\nCore networking rules and constraints. See `../references/networking.md` for detailed patterns.\n\n**SDK Coverage**: 3.7.1 - 3.10.3\n\n## Ownership Model\n\n- Each GameObject has exactly one network owner\n- **Only the owner can modify synced variables**\n- Transfer ownership: `Networking.SetOwner(Networking.LocalPlayer, gameObject)`\n- Check ownership: `Networking.IsOwner(gameObject)`\n\n```csharp\n// Standard pattern: Check -> Acquire -> Modify -> Send\nif (!Networking.IsOwner(gameObject))\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\nsyncedValue = newValue;\nRequestSerialization();\n```\n\n## Sync Modes\n\n| Mode | Attribute Value | Characteristics | Data Limit |\n|------|----------------|-----------------|------------|\n| **NoVariableSync** | `BehaviourSyncMode.NoVariableSync` | No variable sync, events only | - |\n| **Continuous** | `BehaviourSyncMode.Continuous` | Automatic sync ~10Hz | ~200 bytes |\n| **Manual** | `BehaviourSyncMode.Manual` | Explicit sync via `RequestSerialization()` | ~280KB (280,496 bytes) |\n\n### Continuous\n\n- `RequestSerialization()` not needed (sent automatically)\n- Suitable for continuously changing values like position/rotation\n- Be mindful of data size limit (~200 bytes)\n\n### Manual\n\n- `RequestSerialization()` required\n- Suitable for infrequent updates like game state/score\n- Supports large data payloads\n\n```csharp\n[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]\npublic class GameState : UdonSharpBehaviour\n{\n [UdonSynced] private int score;\n\n public void AddScore(int points)\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n score += points;\n RequestSerialization();\n }\n}\n```\n\n## RequestSerialization Pattern\n\nManual sync procedure: Acquire ownership -> Update synced variables -> `RequestSerialization()` -> Receivers react in `OnDeserialization()`\n\n## String Sync Limitations\n\nSynced `string` fields are encoded at 2 bytes/char. There is no separate per-string character limit; the practical limit depends on the sync mode's serialization budget:\n\n- **Continuous**: strings share the ~200-byte budget with all other synced fields on the behaviour. Keep synced strings short (a single short word or short code), as even a 20-character string consumes 40 bytes.\n- **Manual**: strings can be much larger within the ~280KB (280,496 byte) per-serialization limit.\n\nFor longer data in Continuous mode, consider splitting across multiple fields or switching to Manual sync.\n\n## NetworkCallable (SDK 3.8.1+)\n\nParameterized network events. Supports sending up to 8 parameters.\n\n```csharp\n[NetworkCallable]\npublic void TakeDamage(int damage, int attackerId)\n{\n health -= damage;\n Debug.Log($\"Player {attackerId} dealt {damage} damage\");\n}\n\n// Invocation\nSendCustomNetworkEvent(NetworkEventTarget.All, nameof(TakeDamage), damage, attackerId);\n```\n\n### NetworkCallable Constraints\n\n| Constraint | Description |\n|------------|-------------|\n| Access modifier | `public` required |\n| Attribute | `[NetworkCallable]` required |\n| `static` / `virtual` / `override` | Not allowed |\n| Overloading | Not allowed (UdonSharp-wide constraint) |\n| Rate limit | Default 5 calls/sec/event (configurable up to 100 calls/sec) |\n| Parameter count | Maximum 8 |\n\n## FieldChangeCallback Pattern\n\nPattern for detecting synced variable changes via property setter:\n\n```csharp\n[UdonSynced, FieldChangeCallback(nameof(Health))]\nprivate float _health = 100f;\n\npublic float Health\n{\n get => _health;\n set\n {\n _health = value;\n // Called for both local and remote changes\n OnHealthChanged();\n }\n}\n\nprivate void OnHealthChanged()\n{\n healthBar.value = _health;\n}\n```\n\n## Key Principles\n\n1. **\"The trick to syncing is not to sync\"**: Sync only the minimum data and leverage local computation\n2. **No dynamic instantiation**: Use object pooling\n3. **Late joiner support**: Synced variables are automatically sent to late joiners\n4. **Testing**: Early testing with multiple players is critical\n5. **VRCPlayerApi validity**: Always check `player != null && player.IsValid()`\n\n## Common Anti-Patterns (Important)\n\n### Anti-Pattern 1: Owner Check in uGUI Callback -> Non-Owner Buttons Become Unresponsive\n\nuGUI OnClick fires **locally on all clients**. Blocking with an owner check makes buttons non-functional for non-owners.\n\n```csharp\n// NG: Buttons do nothing for non-owners\npublic void OnButtonClicked()\n{\n if (!Networking.IsOwner(gameObject)) return; // Nothing happens for non-owners!\n score += 10;\n RequestSerialization();\n}\n\n// OK Pattern A: Delegate to owner (for infrequent operations)\npublic void OnButtonClicked()\n{\n SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(OwnerAddScore));\n}\npublic void OwnerAddScore()\n{\n score += 10;\n RequestSerialization();\n}\n\n// OK Pattern B: Acquire ownership then execute (for immediate response)\npublic void OnButtonClicked()\n{\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n score += 10;\n RequestSerialization();\n}\n```\n\n### Anti-Pattern 2: All Clients Running Game Logic in Update() -> Owner Conflict\n\nWhen a condition evaluates to true simultaneously on all clients, everyone calls SetOwner + modifies the value, causing conflicts.\n\n```csharp\n// NG: All clients monitor and modify state -> Owner conflict\nvoid Update()\n{\n if (detectSomeCondition) // True on all clients\n {\n Networking.SetOwner(Networking.LocalPlayer, gameObject);\n syncedState = newState; // Everyone modifies simultaneously\n RequestSerialization();\n }\n}\n\n// OK: Only owner runs logic, others only update display\nvoid Update()\n{\n if (!Networking.IsOwner(gameObject)) return;\n\n if (detectSomeCondition)\n {\n syncedState = newState;\n RequestSerialization();\n }\n}\n\npublic override void OnDeserialization()\n{\n UpdateDisplay(); // All clients: Reflect received state in display\n}\n```\n\n## Networking Checklist\n\n- [ ] Ownership verified/acquired before modifying synced variables\n- [ ] `RequestSerialization()` called for Manual sync\n- [ ] Synced strings in Continuous sync are kept short (respect the ~200-byte shared budget; 2 bytes/char)\n- [ ] VRCPlayerApi validity checked\n- [ ] Works correctly for late joiners\n- [ ] NetworkCallable rate limits considered\n- [ ] OnDeserialization side effects guarded with `_isInitialized` flag for late-joiner safety\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6408,"content_sha256":"511a7019b332201cea4df6274b26a7ea66f44679a5eb5847ac8bfd254c2061d6"},{"filename":"rules/udonsharp-sync-selection.md","content":"# Sync Pattern Selection (Always Loaded)\n\nAlways consult before generating code. A decision framework for WHAT to sync and WHEN to sync.\nSee `../references/sync-examples.md` for practical patterns and code examples.\n\n## Decision Tree\n\n```text\nQ1: Does it need to be visible to other players?\n No -> No sync (no [UdonSynced], NoVariableSync)\n Yes -> Q2\n\nQ2: Do late joiners need to know the current state?\n No -> SendCustomNetworkEvent only (no synced variables needed)\n Yes -> Q3\n\nQ3: Does the value change continuously? (position/rotation)\n Yes -> Continuous sync\n No -> Manual sync + minimal [UdonSynced]\n```\n\n| Use Case | Pattern | Synced Variables | Example |\n|----------|---------|-----------------|---------|\n| Personal effects | No sync | 0 | Gun muzzle flash particles |\n| Temporary action for all players | Events only | 0 | Sound effects, animation playback |\n| Persistent shared state | Manual sync | Minimal | Score, game progression |\n| Continuous tracking (position/rotation) | Continuous | Position-related only | Object movement |\n\n## Data Budget\n\nEstimate synced data volume before generating code.\n\n| Type | Bytes | Usage |\n|------|-------|-------|\n| `bool` | 1 | Flags |\n| `byte` | 1 | Small values 0-255 |\n| `short` | 2 | 0-65535 |\n| `int` | 4 | General purpose integer |\n| `float` | 4 | Decimal values |\n| `Vector3` | 12 | Position |\n| `Quaternion` | 16 | Rotation |\n| `string` | 2 bytes/char | Text (keep short; Continuous budget is ~200B shared) |\n\n**Target**: \u003c 50 bytes per behaviour\n\n**Reference values** (typical world gimmicks):\n- Voting system: `int + int + bool` = **9 bytes**\n- Shooting manager: `bool + bool + string + int` = **~38 bytes**\n- Global counter: **0** synced variables (SendCustomNetworkEvent only)\n- Small to medium worlds total: typically **under 100 bytes**\n\n**Bandwidth**: 11KB/sec -> ~0.1 sec latency for a 1KB payload\n\n## Sync Minimization (6 Principles)\n\n1. **Do not sync derivable values** (elapsed time = current time - syncedStartTime)\n2. **Use the smallest type possible** (0-255 -> `byte`, 0-65535 -> `ushort`)\n3. **Bit-pack boolean groups** (8 flags = `int` 4B vs `bool` x8 = 8B)\n4. **Use SendCustomNetworkEvent for one-time effects** (no synced variable needed)\n5. **Sync state, not actions** (sync gamePhase, use event for startGame)\n6. **Single source of truth** (only owner modifies -> all clients update display in OnDeserialization)\n\n## Anti-Pattern: Sync Bloat\n\n| Bad Pattern | Improvement |\n|-------------|-------------|\n| Syncing display-derived values | Sync source data only, compute display locally |\n| Using `int` when `byte` suffices | Choose the smallest type that fits |\n| Syncing per-player data on a shared object | Consider PlayerData API (SDK 3.7.4+) |\n| Marking all variables as `[UdonSynced]` | Only sync values that late joiners actually need |\n| Syncing both state and actions | Sync state only, use events for actions |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2911,"content_sha256":"d226fd8e126a32c0b3f635d4fcb4e2622014f422c693a21cfa4582bb7cbe43ec"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"UdonSharp Skill","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Why This Skill Matters","type":"text"}]},{"type":"paragraph","content":[{"text":"UdonSharp looks like regular Unity C# scripting — until you hit its hidden walls. Many standard C# features (","type":"text"},{"text":"List\u003cT>","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"async/await","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"try/catch","type":"text","marks":[{"type":"code_inline"}]},{"text":", LINQ, generics) ","type":"text"},{"text":"silently fail or refuse to compile","type":"text","marks":[{"type":"strong"}]},{"text":" in Udon. Networking is even more treacherous: modifying a synced variable without ownership produces no error — it just does nothing. Forgetting ","type":"text"},{"text":"RequestSerialization","type":"text","marks":[{"type":"code_inline"}]},{"text":" means your state changes never leave your machine. Standard single-player local testing gives zero signal about these networking bugs because there is only one player.","type":"text"}]},{"type":"paragraph","content":[{"text":"Every rule in this skill exists because UdonSharp's default behavior is to ","type":"text"},{"text":"fail silently","type":"text","marks":[{"type":"strong"}]},{"text":". Read the Rules before generating any code.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Before Writing Network Code","type":"text"}]},{"type":"paragraph","content":[{"text":"Four architectural decisions that must be made before choosing sync modes or writing any synced variable. Changing them mid-implementation typically requires a full rewrite:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Who owns this state?","type":"text","marks":[{"type":"strong"}]},{"text":" One owner writes; all others read. If two players can both write (e.g., a shared toggle), you need an ownership transfer protocol — writes without ownership are silently discarded.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When does ownership transfer?","type":"text","marks":[{"type":"strong"}]},{"text":" On grab? Interact? Game event? ","type":"text"},{"text":"OnPlayerLeft","type":"text","marks":[{"type":"code_inline"}]},{"text":"? ","type":"text"},{"text":"Networking.SetOwner","type":"text","marks":[{"type":"code_inline"}]},{"text":" is ","type":"text"},{"text":"locally immediate","type":"text","marks":[{"type":"strong"}]},{"text":" on the calling client — ","type":"text"},{"text":"Networking.IsOwner(gameObject)","type":"text","marks":[{"type":"code_inline"}]},{"text":" is ","type":"text"},{"text":"true","type":"text","marks":[{"type":"code_inline"}]},{"text":" synchronously after the call, and writing ","type":"text"},{"text":"[UdonSynced]","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields plus ","type":"text"},{"text":"RequestSerialization()","type":"text","marks":[{"type":"code_inline"}]},{"text":" immediately afterwards is safe under an ","type":"text"},{"text":"IsOwner","type":"text","marks":[{"type":"code_inline"}]},{"text":" guard. Concurrent ","type":"text"},{"text":"SetOwner","type":"text","marks":[{"type":"code_inline"}]},{"text":" calls from multiple clients are resolved by network arrival order — there is no client-side arbitration, so accept that the loser's write is overwritten.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"What do late joiners see?","type":"text","marks":[{"type":"strong"}]},{"text":" State set only by one-time events (","type":"text"},{"text":"SendCustomNetworkEvent","type":"text","marks":[{"type":"code_inline"}]},{"text":") is invisible to late joiners. Late-joiner-visible state must live in ","type":"text"},{"text":"[UdonSynced]","type":"text","marks":[{"type":"code_inline"}]},{"text":" variables, which are delivered automatically via ","type":"text"},{"text":"OnDeserialization","type":"text","marks":[{"type":"code_inline"}]},{"text":"; no manual ","type":"text"},{"text":"RequestSerialization()","type":"text","marks":[{"type":"code_inline"}]},{"text":" on join is needed.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"What if the owner leaves mid-session?","type":"text","marks":[{"type":"strong"}]},{"text":" VRChat automatically transfers ownership to a remaining player (selection rule is not publicly documented), and ","type":"text"},{"text":"OnOwnershipTransferred","type":"text","marks":[{"type":"code_inline"}]},{"text":" fires on all clients. Synced variables are preserved, so state is not frozen; decide upfront whether to keep the current value, reset to a known default, or re-apply/re-broadcast derived state in ","type":"text"},{"text":"OnOwnershipTransferred","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Context Preservation","type":"text"}]},{"type":"paragraph","content":[{"text":"For complex synced systems, ownership-sensitive refactors, or work resumed after compaction/handoff, consider loading ","type":"text"},{"text":"references/context-preservation.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". It provides a lightweight task-context note for source of truth, transport, sync mode, storage, ownership, late-joiner behavior, and validation rationale. This is optional guidance for complex work, not a step for small mechanical edits. Keep private data and raw transcripts out of any note.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Core Principles","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Constraints First","type":"text","marks":[{"type":"strong"}]},{"text":" — Assume standard C# features are blocked until verified. Check ","type":"text"},{"text":"udonsharp-constraints.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before using any API.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ownership Before Mutation","type":"text","marks":[{"type":"strong"}]},{"text":" — Only the owner of an object can modify its synced variables. Always ","type":"text"},{"text":"SetOwner","type":"text","marks":[{"type":"code_inline"}]},{"text":" → modify → ","type":"text"},{"text":"RequestSerialization","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Late Joiner Correctness","type":"text","marks":[{"type":"strong"}]},{"text":" — State must be correct for players who join after events have occurred. Design for re-serialization, not just live updates.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sync Minimization","type":"text","marks":[{"type":"strong"}]},{"text":" — Every synced variable costs bandwidth (see data budget in ","type":"text"},{"text":"udonsharp-sync-selection.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Derive what you can locally; sync only the source of truth.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Event-Driven, Not Polling","type":"text","marks":[{"type":"strong"}]},{"text":" — Use ","type":"text"},{"text":"OnDeserialization","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"[FieldChangeCallback]","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"SendCustomEvent","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead of checking state in ","type":"text"},{"text":"Update()","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"for state-change reactions; for hot-path or periodic work, see ","type":"text","marks":[{"type":"strong"}]},{"text":"Event Dispatch & Cross-Behaviour Call Cost Tiers","type":"text","marks":[{"type":"link","attrs":{"href":"references/patterns-performance.md#event-dispatch--cross-behaviour-call-cost-tiers","title":null}},{"type":"strong"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Common Mistakes (NEVER List)","type":"text"}]},{"type":"paragraph","content":[{"text":"These constraints cause either ","type":"text"},{"text":"compile-time failures","type":"text","marks":[{"type":"strong"}]},{"text":" or ","type":"text"},{"text":"silent runtime failures","type":"text","marks":[{"type":"strong"}]},{"text":". Check this list before writing any UdonSharp code.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"#","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NEVER do this","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Why it fails silently","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use instead","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"List\u003cT>","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Dictionary\u003cT,K>","type":"text","marks":[{"type":"code_inline"}]},{"text":", or any generic collection","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Compile error — blocked by Udon compiler","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"T[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" arrays, ","type":"text"},{"text":"DataList","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"DataDictionary","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"async","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"await","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"System.Threading","type":"text","marks":[{"type":"code_inline"}]},{"text":", or coroutines","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Udon is single-threaded; these features do not exist","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SendCustomEventDelayedSeconds()","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Modify ","type":"text"},{"text":"[UdonSynced]","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields without owning the object","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Change appears local but is ","type":"text"},{"text":"silently reverted","type":"text","marks":[{"type":"strong"}]},{"text":" on next deserialization","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Networking.SetOwner()","type":"text","marks":[{"type":"code_inline"}]},{"text":" before modify, then ","type":"text"},{"text":"RequestSerialization()","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Forget ","type":"text"},{"text":"RequestSerialization()","type":"text","marks":[{"type":"code_inline"}]},{"text":" after modifying synced fields (Manual sync)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"State changes never leave the local client — no error, no warning","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Always call ","type":"text"},{"text":"RequestSerialization()","type":"text","marks":[{"type":"code_inline"}]},{"text":" after modifying ","type":"text"},{"text":"[UdonSynced]","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"try","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"catch","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"finally","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"throw","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Compile error — exception handling is blocked","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Defensive null checks + early return","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Access ","type":"text"},{"text":"Networking.LocalPlayer","type":"text","marks":[{"type":"code_inline"}]},{"text":" in field initializers","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Field initializers run at compile time — ","type":"text"},{"text":"LocalPlayer","type":"text","marks":[{"type":"code_inline"}]},{"text":" is null","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Initialize in ","type":"text"},{"text":"Start()","type":"text","marks":[{"type":"code_inline"}]},{"text":" or use lazy-init guard","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"7","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"static","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields for per-instance state","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Static fields are shared across all instances on the same client and are not synced","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Instance fields with ","type":"text"},{"text":"[UdonSynced]","type":"text","marks":[{"type":"code_inline"}]},{"text":" if sync is needed","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"8","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Call ","type":"text"},{"text":"RequestSerialization()","type":"text","marks":[{"type":"code_inline"}]},{"text":" every frame in Manual sync","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Floods the ~11 KB/s network budget, causing congestion for the entire world","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Throttle to 1-10 Hz with change detection; check ","type":"text"},{"text":"Networking.IsClogged","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"9","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use LINQ (","type":"text"},{"text":".Where","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":".Select","type":"text","marks":[{"type":"code_inline"}]},{"text":", etc.) or lambda expressions","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Compile error — not supported by Udon compiler","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manual ","type":"text"},{"text":"for","type":"text","marks":[{"type":"code_inline"}]},{"text":" loops with named methods","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"10","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"Button.onClick.AddListener()","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Not available in Udon — no runtime delegate support","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Configure ","type":"text"},{"text":"SendCustomEvent","type":"text","marks":[{"type":"code_inline"}]},{"text":" via Inspector OnClick","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"11","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mix Continuous and Manual sync concerns on one behaviour","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Wastes bandwidth (discrete values in Continuous) or loses control (redundant ","type":"text"},{"text":"RequestSerialization","type":"text","marks":[{"type":"code_inline"}]},{"text":" in Continuous)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Separate behaviours: Continuous for position/rotation, Manual for discrete state","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"12","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Write to ","type":"text"},{"text":"[UdonSynced]","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields without an ","type":"text"},{"text":"IsOwner","type":"text","marks":[{"type":"code_inline"}]},{"text":" guard","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Non-owner writes are purely local and silently reverted on the next deserialization from the actual owner","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Networking.SetOwner","type":"text","marks":[{"type":"code_inline"}]},{"text":" first if needed (locally immediate), then write under ","type":"text"},{"text":"IsOwner","type":"text","marks":[{"type":"code_inline"}]},{"text":" and call ","type":"text"},{"text":"RequestSerialization()","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"13","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"[NetworkCallable]","type":"text","marks":[{"type":"code_inline"}]},{"text":" on SDK \u003c 3.8.1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Compiles but silently ignored at runtime — the attribute has no effect and methods never receive network calls","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Verify SDK >= 3.8.1; on older SDKs use synced variables + ","type":"text"},{"text":"SendCustomNetworkEvent","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"14","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use PhysBones/Contacts API (","type":"text"},{"text":"OnPhysBoneGrab","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"OnContactEnter","type":"text","marks":[{"type":"code_inline"}]},{"text":", etc.) on SDK \u003c 3.10.0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Compiles but silently ignored at runtime — world-side Dynamics did not exist pre-3.10.0, so callbacks never fire","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Verify SDK >= 3.10.0; Dynamics for Worlds was added in 3.10.0","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"15","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"PlayerData","type":"text","marks":[{"type":"code_inline"}]},{"text":" persistence API on SDK \u003c 3.7.4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Compile error — missing symbol; ","type":"text"},{"text":"PlayerData","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"PlayerObject","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"OnPlayerRestored","type":"text","marks":[{"type":"code_inline"}]},{"text":" were added in 3.7.4 and are not in the Udon whitelist before then","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Verify SDK >= 3.7.4; persistence was added in 3.7.4","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"16","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Create a ","type":"text"},{"text":".cs","type":"text","marks":[{"type":"code_inline"}]},{"text":" script without a corresponding ","type":"text"},{"text":".asset","type":"text","marks":[{"type":"code_inline"}]},{"text":" file","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Script is not recognized as UdonBehaviour — \"The associated script cannot be loaded\", no Udon compilation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Every time","type":"text","marks":[{"type":"strong"}]},{"text":" a ","type":"text"},{"text":".cs","type":"text","marks":[{"type":"code_inline"}]},{"text":" is created: verify ","type":"text"},{"text":"Assets/Editor/UdonSharpProgramAssetAutoGenerator.cs","type":"text","marks":[{"type":"code_inline"}]},{"text":" exists, install from ","type":"text"},{"text":"references/editor-scripting.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" if missing, notify the user (see Rule 8 in ","type":"text"},{"text":"rules/udonsharp-constraints.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"17","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Call ","type":"text"},{"text":"Debug.Log()","type":"text","marks":[{"type":"code_inline"}]},{"text":" inside ","type":"text"},{"text":"Update()","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"PostLateUpdate()","type":"text","marks":[{"type":"code_inline"}]},{"text":", or any per-frame event","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRChat's client-side log rate limiter silently drops excess entries; the implicit string allocation every frame causes sustained GC pressure that tanks framerate. ClientSim and Unity Editor hide both symptoms","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Guard with ","type":"text"},{"text":"if (debugMode && Time.frameCount % 60 == 0)","type":"text","marks":[{"type":"code_inline"}]},{"text":", or move all logging to event-driven callbacks","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"18","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"[UdonSynced]","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a ","type":"text"},{"text":"GameObject","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Transform","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"UdonBehaviour","type":"text","marks":[{"type":"code_inline"}]},{"text":", or any component reference","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Only primitives, value types (Vector3, Quaternion, Color, etc.), string, VRCUrl, and their simple arrays are syncable. Component references either fail at compile time or are silently never serialized depending on SDK version","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sync a player ID (","type":"text"},{"text":"int","type":"text","marks":[{"type":"code_inline"}]},{"text":") or scene object index (","type":"text"},{"text":"int","type":"text","marks":[{"type":"code_inline"}]},{"text":") and resolve the actual reference locally on each client","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Sync Mode Quick Decision","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"Changing every frame (position, rotation)? -> Continuous sync\nChanging on user action (toggle, score)? -> Manual sync + RequestSerialization()\nNo sync needed (local UI, effects)? -> NoVariableSync\nNeed reliable one-shot calls with params? -> [NetworkCallable] (SDK 3.8.1+)\nTemporary effect for all players, no state? -> SendCustomNetworkEvent (no synced vars)","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"For detailed decision trees, data budget, and minimization principles, see ","type":"text"},{"text":"rules/udonsharp-sync-selection.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Sync Debugging Quick Decision","type":"text"}]},{"type":"paragraph","content":[{"text":"When sync \"looks correct locally but doesn't work for others\":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"Remote players don't see my state change?\n ├── Did I call RequestSerialization() after writing? (Manual sync) → Add it\n ├── Does the local player own the object? → Networking.SetOwner() first\n └── Using Continuous sync for button/toggle state? → Switch to Manual + RequestSerialization()\n\nRequestSerialization() called but still not syncing?\n ├── Is Networking.IsClogged == true? → Throttle; retry after delay\n └── Writing in OnPreSerialization scope? → Move write before OnPreSerialization fires\n\nLate joiners don't see current state?\n ├── State set only on event (e.g., player trigger)? → Also set in Start() + RequestSerialization() on owner\n └── Using SendCustomNetworkEvent for persistent state? → Use [UdonSynced] variables instead\n\nOnOwnershipTransferred not firing on a remote client?\n └── On the caller, the callback fires synchronously inside SetOwner — confirm the calling client called Networking.SetOwner(LocalPlayer, gameObject), and that remote clients resolve the same gameObject reference (scene path or prefab GUID)","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Reference Loading Guide","type":"text"}]},{"type":"paragraph","content":[{"text":"Load only what you need. Over-loading wastes tokens; under-loading causes critical mistakes.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Task","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MANDATORY READ","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Optional","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Do NOT Load","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Writing networking/sync code","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"networking-antipatterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking-bandwidth.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sync-examples.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"image-loading-vram.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Building UI/menus","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-ui.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"events.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-core.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"api.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking-bandwidth.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Implementing persistence (save/load)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"persistence.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-networking.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"events.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"image-loading-vram.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Downloading strings/images from web","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"web-loading-advanced.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"image-loading-vram.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"persistence.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"networking-bandwidth.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Using PhysBones/Contacts/Constraints","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"events.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-networking.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"api.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"image-loading-vram.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"persistence.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Optimizing performance (Update loops)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-performance.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-utilities.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"api.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"persistence.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Building a video player","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-video.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"events.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"persistence.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"image-loading-vram.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Debugging/troubleshooting","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"troubleshooting.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"constraints.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"networking.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"testing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-*.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Debugging ownership / sync conflicts","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"troubleshooting.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking-antipatterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resuming complex work after compaction / handoff / ownership-sensitive multi-file refactor","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Current task's primary references","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"context-preservation.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Unrelated domain references","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Writing new UdonSharp scripts (not sure if sync needed)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"constraints.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"image-loading-vram.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Creating new UdonSharp scripts","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"editor-scripting.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"troubleshooting.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Pattern Selection Guide","type":"text"}]},{"type":"paragraph","content":[{"text":"Six pattern files cover different domains. Use this quick routing to pick the right one:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"Building a UI, menu, or HUD? -> patterns-ui.md\nVR finger/touch interaction on Canvas? -> patterns-ui.md\nModular app with multiple screens? -> patterns-ui.md\nSyncing state across players? -> patterns-networking.md\nMultiple identical rooms from one model? -> patterns-networking.md (distant-room)\nOptimizing Update() or heavy loops? -> patterns-performance.md\nHeavy rebuild, replay, or reset/cancel? -> patterns-performance.md\nPlaying or streaming video? -> patterns-video.md\nNeed array helpers, event bus, or -> patterns-utilities.md\n pseudo-delegates?\nBasic interactions, timers, audio, -> patterns-core.md\n pickups, or teleportation?\nStation + trigger zone detection? -> troubleshooting.md","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Multiple concerns? Load the primary pattern file plus its dependencies. For example, a synced video player needs both ","type":"text"},{"text":"patterns-video.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"patterns-networking.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Template Selection Guide","type":"text"}]},{"type":"paragraph","content":[{"text":"17 templates cover common starting points. Pick the closest match and adapt:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Starting Point","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Template","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Key Feature","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Interaction & Objects","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Interactive object (click/use)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BasicInteraction.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Cooldown, toggle, audio feedback","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Synced toggle / shared object","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SyncedObject.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ownership guard, FieldChangeCallback, late-joiner init","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Per-player movement settings","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PlayerSettings.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Walk/run/jump speed via trigger zone","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Contact-based collision detection","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ContactReceiver.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"OnContactEnter/Exit, avatar vs world, debounce (SDK 3.10.0+)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"State & Game Logic","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"State machine / game flow","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"StateMachine.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Timed transitions, synced state, late-joiner safety","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Game with undo/history","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UndoableGameManager.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"byte[] history, NetworkCallable OwnerProcessMove/Undo/Reset","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Object pool (player slots)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MasterManagedPlayerPool.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"FIFO ring buffer, master-managed, OnPlayerJoined/Left","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Persistence & Data","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Save/load player data","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DataPersistence.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PlayerData API, OnPlayerRestored, auto-save (SDK 3.7.4+)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Networking Patterns","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rate-limited sync (slider drag)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"RateLimitedSync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0.15s cooldown, last-write-wins","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Batched sync (rapid events)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BatchedSync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Idempotent schedule, 0.2s delay, single packet","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Congestion-aware retry","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CloggedRetrySync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"IsClogged check, linear back-off, MaxRetries","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Dual local+synced copy","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DualCopySync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Local working copy + synced transport, dirty flag","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pack multiple values into one field","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PackedStateSync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3 ints in one Vector3, reduced sync overhead","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Utilities","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Array helpers (List\u003cT> alternative)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ArrayUtils.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Add, Remove, Contains, FindIndex, Shuffle for arrays","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Event bus (pub/sub)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EventBus.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Subscriber list (max 32), RegisterListener/RaiseEvent","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Custom editor inspector","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CustomInspector.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UdonSharpGUI, Undo, proxy sync","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Auto-generate .asset for new scripts","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UdonSharpProgramAssetAutoGenerator.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AssetPostprocessor, domain-reload-only, auto-compile","type":"text"}]}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Multiple needs?","type":"text","marks":[{"type":"strong"}]},{"text":" Start with the template closest to your primary concern, then pull patterns from others. For example, a synced game with undo needs ","type":"text"},{"text":"UndoableGameManager.cs","type":"text","marks":[{"type":"code_inline"}]},{"text":" as the base plus patterns from ","type":"text"},{"text":"RateLimitedSync.cs","type":"text","marks":[{"type":"code_inline"}]},{"text":" for throttling.","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Rules (Constraints & Networking)","type":"text"}]},{"type":"paragraph","content":[{"text":"Compile constraints and networking rules are defined in ","type":"text"},{"text":"always-loaded Rules","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rule File","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Contents","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"rules/udonsharp-constraints.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Blocked features, code generation rules, attributes, syncable types","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"rules/udonsharp-networking.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ownership, sync modes, RequestSerialization, NetworkCallable","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"rules/udonsharp-sync-selection.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sync pattern selection, data budget, minimization principles","type":"text"}]}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"After installation, place these in the agent's rules directory for automatic loading.","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"SDK Versions","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SDK Version","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Key Features","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.7.1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Added ","type":"text"},{"text":"StringBuilder","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"RegularExpressions","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"System.Random","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.7.4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Added ","type":"text"},{"text":"Persistence API","type":"text","marks":[{"type":"strong"}]},{"text":" (PlayerData/PlayerObject)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.7.6","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Multi-platform Build & Publish (PC + Android simultaneously)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.8.0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PhysBone dependency sorting, Drone API (VRCDroneInteractable)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.8.1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"[NetworkCallable]","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" attribute, parameterized network events, ","type":"text"},{"text":"NetworkEventTarget.Others","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":".Self","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.9.0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Camera Dolly API, Auto Hold pickup simplification","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.10.0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRChat Dynamics for Worlds","type":"text","marks":[{"type":"strong"}]},{"text":" (PhysBones, Contacts, VRC Constraints)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.10.1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Bug fixes and stability improvements","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.10.2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EventTiming extensions, PhysBones fixes, shader time globals","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.10.3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRCPlayerApi.isVRCPlus","type":"text","marks":[{"type":"code_inline"}]},{"text":", VRCRaycast (avatar), Mirror render-order fix","type":"text"}]}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Note","type":"text","marks":[{"type":"strong"}]},{"text":": SDK versions below 3.9.0 are ","type":"text"},{"text":"deprecated as of December 2, 2025","type":"text","marks":[{"type":"strong"}]},{"text":". New world uploads are no longer possible.","type":"text"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Official Resources","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resource","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"URL","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Contents","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRChat Creators","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"creators.vrchat.com/worlds/udon/","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Official Udon / SDK documentation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UdonSharp Docs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"udonsharp.docs.vrchat.com","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UdonSharp API reference","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRChat Forums","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ask.vrchat.com","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Q&A, solutions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRChat Canny","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"feedback.vrchat.com","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Bug reports, known issues","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GitHub","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"github.com/vrchat-community","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Samples and libraries","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"References","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"File","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Contents","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Search Hints","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"constraints.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"C# feature availability in UdonSharp; blocked features; syncable types; attributes; DataList vs array decision guidance; advanced workarounds (object array pseudo-struct, VRCUrl array sync)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"List, async, try/catch, LINQ, generics, DataList, DataDictionary, DataList vs array, when to use DataList, VRCUrl array, VRCUrl sync, pseudo-struct, object array cast, multi-field state container","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ownership model, sync modes, RequestSerialization, NetworkCallable, network events, data limits","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UdonSynced, SetOwner, BehaviourSyncMode, FieldChangeCallback, OnDeserialization, master leave, ownership cascade","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking-bandwidth.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Bandwidth throttling, bit packing, synced data size examples, debugging, owner-centric architecture","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"IsClogged, bandwidth, throttle, bit packing, data budget, IsMaster","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"networking-antipatterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6 anti-patterns to avoid; 5 advanced sync patterns with template links","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"anti-pattern, race condition, ownership fight, late-joiner, PackedStateSync, BatchedSync","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"persistence.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Storage layer decision tree (local/synced/PlayerData/PlayerObject); PlayerData/PlayerObject API (SDK 3.7.4+); per-player save data; storage usage query API (SDK 3.10.0+)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"storage layer, decision tree, local variable, PlayerData, PlayerObject, OnPlayerRestored, SetInt, TryGetInt, GetPlayerDataStorageUsage, GetPlayerDataStorageLimit, RequestStorageUsageUpdate, OnPersistenceUsageUpdated, storage quota, storage usage, which storage, when to use PlayerData","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dynamics.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PhysBones, Contacts, VRC Constraints (SDK 3.10.0+)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PhysBone, ContactReceiver, ContactSender, VRCConstraint, OnContactEnter","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-core.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Initialization, interaction, player detection, timer, audio, pickup, animation, UI, teleportation, lazy init guard, remote players","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Interact, OnEnable, Initialize, AudioSource, VRCPickup, Animator, UI, TeleportTo, remote players, GetRemotePlayers, exclude local player, FindAll alternative","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-networking.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Object pooling, NetworkCallable patterns, persistence integration, dynamics integration, synced game state, distant-room pseudo-multi-room (state/presentation split, self-owned vs master-approved tiers), delayed event debounce, string join for array sync","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pool, MasterManagedPlayerPool, NetworkCallable, DamageReceiver, game state, distant room, pseudo multi-room, room assignment, roomIndex, LocalRoomPresenter, RoomAssignment, NoVariableSync, TeleportTo per-client, debounce, state machine, string join, array sync, paragraph separator, U+2029","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-performance.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Partial class pattern, update handler, PostLateUpdate, spatial query, platform optimization, frame budget Stopwatch, heavy processing architecture (rebuild, replay, reset/cancel), rate limit resolver, GameObject lookup cost tiers","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Update, PostLateUpdate, Bounds, AnimatorHash, performance, mobile, PC, Stopwatch, frame budget, SendCustomEventDelayedFrames, heavy processing, rebuild, replay, reset, cancel, operation log, authoritative data, derived state, cursor rebuild, rate limit, URL scheduler, video load queue, GameObject.Find, Find cost, lookup cost tier, SerializeField vs Find, silent failure on rename, SendCustomEvent cost, cross-behaviour call, EventBus hot path, delayed loop spike, public method lookup, event dispatch tier","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-utilities.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Array helpers (List alternatives), event bus, GameObject relay communication, pseudo-struct double-cast, abstract class callback, cancellable delayed event, re-entrance guard, UdonEvent pseudo-delegate","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ArrayUtils, EventBus, relay, subscriber, FindIndex, ShuffleArray, object array, pseudo struct, double cast, abstract class, callback, interface alternative, cancellable timer, re-entrance, emitting guard, UdonEvent, pseudo delegate","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-ui.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UI/Canvas patterns: immobilize guard, avatar-scale-aware UI, FOV-responsive positioning, platform-adaptive layout, dynamic player list, scroll input abstraction, lookup-table localization, toggle-animator bridge, settings persistence via PlayerObject, listener-based menu events, finger touch interaction, modular app architecture","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Canvas, UI, menu, Immobilize, avatar scale, FOV, platform, Quest, VR, desktop, player list, scroll, localization, language, Toggle, Animator, PlayerObject, settings, persistence, listener, broadcast, finger touch, fingertip, haptic, FingerPointer, FingerTouchCanvas, touch canvas, app architecture, AppModule, AppManager, plugin lifecycle, CanvasGroup transition","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"patterns-video.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Video player state machine, server-time playback sync, late joiner sync, AVPro Blit buffering, error retry with fallback, synced playlist/queue, platform URL selection","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"video player, AVPro, VRCUnityVideoPlayer, BaseVRCVideoPlayer, playback sync, server time, GetServerTimeInMilliseconds, late joiner, VRCGraphics.Blit, OnVideoReady, OnVideoError, retry, fallback, playlist, queue, shuffle, repeat, Quest URL","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"web-loading.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"String/Image downloading, VRCJson, Trusted URLs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRCStringDownloader, VRCImageDownloader, VRCJson, DataDictionary, VRCUrl","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"image-loading-vram.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Advanced VRAM management for image loading: Destroy vs Dispose, double-buffer fade, stock mode, mipmap bias","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRAM, texture memory, memory leak, Destroy, Dispose, double buffer, fade, mipmap, TextureInfo","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"web-loading-advanced.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Advanced data loading: Base64 texture embedding via StringDownloader, cross-platform compression, URL double-key indexing, LRU decode cache","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Base64, LoadRawTextureData, StringDownloader texture, DXT1, ETC_RGB4, UNITY_ANDROID, LRU cache, packed resources, binary format","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"api.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"VRCPlayerApi, Networking, enums reference, VRCObjectPool methods + Interact-driven ownership patterns","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GetPlayers, playerId, isMaster, isLocal, GetPosition, SetVelocity, Drone, VRCDroneApi, VRCObjectPool, TryToSpawn, Return, Shuffle, pool owner, Interact pool, pool forwarded spawn, pool ownership transfer","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"events.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"All Udon events (including OnPlayerRestored, OnContactEnter)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"OnPlayerJoined, OnPlayerLeft, OnPlayerTriggerEnter, OnOwnershipTransferred, OnControllerColliderHitPlayer, CharacterController, OnMasterTransferred, OnAvatarChanged, OnSpawn, VRC Economy, OnPurchaseConfirmed, OnAsyncGpuReadbackComplete","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"editor-scripting.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Editor scripting, proxy system, and UdonSharpProgramAsset auto-generation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UdonSharpEditor, UdonSharpBehaviourProxy, SerializedObject, UdonSharpProgramAsset, auto-generate, AssetPostprocessor, .asset missing","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sync-examples.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sync pattern examples (Local/Events/SyncedVars)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Continuous, Manual, NoVariableSync, sync example","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"troubleshooting.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Common errors and solutions","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NullReference, compile error, sync not working, FieldChangeCallback, VRCStation, seated player, trigger zone, OnPlayerTriggerEnter not firing, station collider, position polling, OnStationEntered","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sdk-migration.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SDK migration guide (3.7 to 3.10), version-by-version changes and checklists","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"migration, deprecated, upgrade, 3.7, 3.8, 3.9, 3.10","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"testing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing and debugging guide: ClientSim editor testing, Build and Test (single and multi-client), Debug.Log patterns, pre-release cleanup, testing checklist","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ClientSim, Build and Test, multi-client, late joiner test, debug, Debug.Log, ownership test, sync test, testing checklist","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"context-preservation.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Context-preservation guide: recording task-specific design intent (source of truth, sync strategy, ownership, late-joiner behavior, validation) across context compaction/handoff; minimal task-context note; privacy guidance; resume checklist","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"context preservation, design intent, compaction, handoff, resume, task context note, why this design, ownership rationale, design-context loss","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Templates (","type":"text"},{"text":"assets/templates/","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Template","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Purpose","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BasicInteraction.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Interactive object with ","type":"text"},{"text":"Interact()","type":"text","marks":[{"type":"code_inline"}]},{"text":" handler","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SyncedObject.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Network-synced object (Manual sync, ownership guard, late-joiner init flag)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PlayerSettings.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Per-player movement settings (walk/run/jump speed)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"StateMachine.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"State machine with synced state and transitions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DataPersistence.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PlayerData save/load with OnPlayerRestored (SDK 3.7.4+)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ContactReceiver.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Contact receiver for world-side collision detection (SDK 3.10.0+)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CustomInspector.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Custom editor inspector with UdonSharpEditor","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MasterManagedPlayerPool.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Master-managed player object pool; FIFO ring buffer; OnPlayerJoined/Left; VerifyAssignments after master handoff","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EventBus.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Subscriber list event bus (max 32 listeners); RegisterListener/UnregisterListener/RaiseEvent; in-place compaction","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ArrayUtils.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"List\u003cT> alternatives: Add, Contains, AddUnique, Remove, RemoveAt, Insert for GameObject[]; FindIndex/ShuffleArray for int[]","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UndoableGameManager.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"History/undo sync with byte[] state history; NetworkCallable OwnerProcessMove/OwnerUndo/OwnerReset","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PackedStateSync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pack 3 ints into one Vector3 UdonSynced field; OnPreSerialization/OnDeserialization","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"RateLimitedSync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0.15s sync cooldown with _syncLocked/_changeCounter; _OnSyncUnlock callback","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DualCopySync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Local + synced copy with _dirty flag; strict OnPreSerialization/OnDeserialization separation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BatchedSync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Idempotent ScheduleBatchedSync with 0.2s BatchDelay; _FlushBatch delayed callback","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CloggedRetrySync.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Networking.IsClogged check; linear back-off (RetryDelay * retryCount); MaxRetries=5","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UdonSharpProgramAssetAutoGenerator.cs","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AssetPostprocessor that auto-creates UdonSharpProgramAsset for new scripts","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Hooks","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Hook","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Platform","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Purpose","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"validate-udonsharp.ps1","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Windows (PowerShell)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PostToolUse constraint validation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"validate-udonsharp.sh","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Linux/macOS (Bash)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PostToolUse constraint validation","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Reference","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"CHEATSHEET.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" - One-page quick reference","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"unity-vrc-udon-sharp","author":"@skillopedia","source":{"stars":173,"repo_name":"agent-skills-vrc-udon","origin_url":"https://github.com/niaka3dayo/agent-skills-vrc-udon/blob/HEAD/skills/unity-vrc-udon-sharp/SKILL.md","repo_owner":"niaka3dayo","body_sha256":"c9d7ad489129268d6d745855d98fa7cad90b4860f010e199345ea111a94d159f","cluster_key":"bc0cdf4822918da2514e94f45c14f2a62dffe3a1d52a8584202d74f4fdcd54cb","clean_bundle":{"format":"clean-skill-bundle-v1","source":"niaka3dayo/agent-skills-vrc-udon/skills/unity-vrc-udon-sharp/SKILL.md","attachments":[{"id":"cba1b01e-8a9b-529e-b171-55610e966517","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cba1b01e-8a9b-529e-b171-55610e966517/attachment.md","path":"CHEATSHEET.md","size":10546,"sha256":"c0b9596aa51b21de04c9173a5e86da88979f19b93480d27268c53e357016c737","contentType":"text/markdown; charset=utf-8"},{"id":"80568483-3391-57ec-a81e-fa414d824c54","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/80568483-3391-57ec-a81e-fa414d824c54/attachment.cs","path":"assets/templates/ArrayUtils.cs","size":4678,"sha256":"a35cacf6f1cd04b3c9758ac1211a3ea7e35af349b6d65cca5c9c469a0923e912","contentType":"text/plain; charset=utf-8"},{"id":"e0fd280b-36be-5061-a29e-5ea613cf7fa5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e0fd280b-36be-5061-a29e-5ea613cf7fa5/attachment.cs","path":"assets/templates/BasicInteraction.cs","size":2208,"sha256":"9c183bd62c7f61a4dcc2df5ff1ef0a5c514ec24c696cf0694d0520879e337536","contentType":"text/plain; charset=utf-8"},{"id":"73471e33-87f7-52b2-95dd-bad283cdf3f1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/73471e33-87f7-52b2-95dd-bad283cdf3f1/attachment.cs","path":"assets/templates/BatchedSync.cs","size":3345,"sha256":"20858be2a81c58ea1c9673a96e98e352c3149cc1df7c9797cdbf68873748b355","contentType":"text/plain; charset=utf-8"},{"id":"9f0ffcb3-f1f5-5a0b-8a69-c69938bd6aa5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9f0ffcb3-f1f5-5a0b-8a69-c69938bd6aa5/attachment.cs","path":"assets/templates/CloggedRetrySync.cs","size":4061,"sha256":"04643c409ffbd82bcb92cbbbd847f24a5a2483313199a922df873b82ca053c75","contentType":"text/plain; charset=utf-8"},{"id":"6ff40c6e-9dcf-5855-bfd6-42f5c53fed24","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6ff40c6e-9dcf-5855-bfd6-42f5c53fed24/attachment.cs","path":"assets/templates/ContactReceiver.cs","size":8511,"sha256":"4779f98cd5ac23b13e777c20060cc84d517f5ac7d743a595df39b7ccb039cb54","contentType":"text/plain; charset=utf-8"},{"id":"af465e1b-adea-5564-b89f-4f868fdb23ec","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/af465e1b-adea-5564-b89f-4f868fdb23ec/attachment.cs","path":"assets/templates/CustomInspector.cs","size":9469,"sha256":"71cb62e3ba51361680edf5ce1544c0fee13ddec108199f5934bd3d7883741a55","contentType":"text/plain; charset=utf-8"},{"id":"0e21d801-f3ca-5c7c-a496-b35b4a0aef8b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0e21d801-f3ca-5c7c-a496-b35b4a0aef8b/attachment.cs","path":"assets/templates/DataPersistence.cs","size":12355,"sha256":"ca24dc6ed972d68196ea106067a70a5237a07d89473bfe2ed1671e2a68e243ad","contentType":"text/plain; charset=utf-8"},{"id":"f3a27a9b-e6b4-5872-9489-1b42b50654c2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f3a27a9b-e6b4-5872-9489-1b42b50654c2/attachment.cs","path":"assets/templates/DualCopySync.cs","size":3179,"sha256":"cf26165daddf1ef2b245af5397769bd2a1d3de2137301e2afde1c6f7263814c8","contentType":"text/plain; charset=utf-8"},{"id":"8d06e07a-ea87-511d-9be0-52f57b38b217","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8d06e07a-ea87-511d-9be0-52f57b38b217/attachment.cs","path":"assets/templates/EventBus.cs","size":3121,"sha256":"be9586ac0607582565b89f554dc7197ebf6e0d4320239f5f5e3e03870fee7a05","contentType":"text/plain; charset=utf-8"},{"id":"bf9e8ec9-d712-56e6-b3bc-a213e9a5970c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bf9e8ec9-d712-56e6-b3bc-a213e9a5970c/attachment.cs","path":"assets/templates/MasterManagedPlayerPool.cs","size":10808,"sha256":"3dd01534036113de2700b35f6e39e1acfb45a4177d86e452865dbdbc9dd74e13","contentType":"text/plain; charset=utf-8"},{"id":"9590a1d4-70c1-5c78-b82c-c08cd570ea9d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9590a1d4-70c1-5c78-b82c-c08cd570ea9d/attachment.cs","path":"assets/templates/PackedStateSync.cs","size":2893,"sha256":"311b9e31a9b396786b3a53d555bb49eb408ad1d392b186c3b447f99aa7f7f736","contentType":"text/plain; charset=utf-8"},{"id":"2a9bd728-8b83-5a58-ad8c-aa5d79defbce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2a9bd728-8b83-5a58-ad8c-aa5d79defbce/attachment.cs","path":"assets/templates/PlayerSettings.cs","size":3560,"sha256":"edad269c214ebdd5b74a32fb77d22fca9175ef175a606b0caa31a53364118d7b","contentType":"text/plain; charset=utf-8"},{"id":"731c15e4-f8bb-5aab-86a1-55aae796bb5e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/731c15e4-f8bb-5aab-86a1-55aae796bb5e/attachment.cs","path":"assets/templates/RateLimitedSync.cs","size":3720,"sha256":"0afeb384f95cba19e212d519323ceea4906c452971c64e70172d35b7d3f9170b","contentType":"text/plain; charset=utf-8"},{"id":"16757cf6-99ec-5cbf-a51c-04065a2b0c6e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/16757cf6-99ec-5cbf-a51c-04065a2b0c6e/attachment.cs","path":"assets/templates/StateMachine.cs","size":13080,"sha256":"9ebb1c391fd67e4b935ea3dcf12e1503614890087332eb70ff35f3835152c0e5","contentType":"text/plain; charset=utf-8"},{"id":"9e7d2cc7-54f8-58a6-9a74-016c5a92b276","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9e7d2cc7-54f8-58a6-9a74-016c5a92b276/attachment.cs","path":"assets/templates/SyncedObject.cs","size":5842,"sha256":"243899c30a3943656d88e74055c0211a8b5e6e6eda0165defbb8eb09849c653d","contentType":"text/plain; charset=utf-8"},{"id":"fc48b7a8-edc0-5c1a-b7ed-4f1f55e11edd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fc48b7a8-edc0-5c1a-b7ed-4f1f55e11edd/attachment.cs","path":"assets/templates/UdonSharpProgramAssetAutoGenerator.cs","size":5814,"sha256":"eb42ed3798410f606ac87ad8ac17d9a0dd475b9566e6b4fe78072ea1984e9f93","contentType":"text/plain; charset=utf-8"},{"id":"240d2e38-3b66-55e7-a958-7b12b5d030b9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/240d2e38-3b66-55e7-a958-7b12b5d030b9/attachment.cs","path":"assets/templates/UndoableGameManager.cs","size":4392,"sha256":"a436709f69b259a1d0367c309375e33b36fd1c3225e7cb28d9760286f00a4626","contentType":"text/plain; charset=utf-8"},{"id":"27b8b305-f894-59e4-9d37-c47404f2af38","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/27b8b305-f894-59e4-9d37-c47404f2af38/attachment.ps1","path":"hooks/validate-udonsharp.ps1","size":7674,"sha256":"04b140c1f4a5f60538d9eaba1f2c6ae8e3a67016da2d74da0638cc47550b391e","contentType":"text/plain; charset=utf-8"},{"id":"fbace20e-e50c-5279-bd22-0248fdd4d514","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fbace20e-e50c-5279-bd22-0248fdd4d514/attachment.sh","path":"hooks/validate-udonsharp.sh","size":7970,"sha256":"5cc2006eb6ce958ae9a2796fde60ceb31eac0795b8857d0582bbf6d26f5dab04","contentType":"application/x-sh; charset=utf-8"},{"id":"ca690896-5b5e-5a12-826c-e1001fb5543f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ca690896-5b5e-5a12-826c-e1001fb5543f/attachment.md","path":"references/api.md","size":42037,"sha256":"54a423ae38597da6966152fce977568f6a84a82d1c9eb777ff56af01b4e2d386","contentType":"text/markdown; charset=utf-8"},{"id":"25e32073-3d27-500c-853d-39ab4c96a699","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/25e32073-3d27-500c-853d-39ab4c96a699/attachment.md","path":"references/constraints.md","size":37315,"sha256":"209cc00b456058bc9c35a4350acf719e5e25760c915f1b332b5afe3b7a39dad6","contentType":"text/markdown; charset=utf-8"},{"id":"9f5e6e7c-d6a1-58b0-b5de-a896f9fab194","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9f5e6e7c-d6a1-58b0-b5de-a896f9fab194/attachment.md","path":"references/context-preservation.md","size":10238,"sha256":"f8785197aab513ffd1971b73bbfeff839670afc91a3e9bd596ffac67f70b73c5","contentType":"text/markdown; charset=utf-8"},{"id":"ebb27037-f519-557a-943a-205958bdcd35","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ebb27037-f519-557a-943a-205958bdcd35/attachment.md","path":"references/dynamics.md","size":30355,"sha256":"dc9c366795c995c92e429a45f436bd38a2e389e87468bcb3a4dc32a36de43da1","contentType":"text/markdown; charset=utf-8"},{"id":"c6ae106a-97cc-572a-abec-29802a9d1132","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c6ae106a-97cc-572a-abec-29802a9d1132/attachment.md","path":"references/editor-scripting.md","size":20090,"sha256":"8d25c299538f9e75db390c56c4b09ae2a82165a084483cf050001ee531c99420","contentType":"text/markdown; charset=utf-8"},{"id":"08be44e7-26bc-54ef-b7ed-cbbde5f04a33","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/08be44e7-26bc-54ef-b7ed-cbbde5f04a33/attachment.md","path":"references/events.md","size":32813,"sha256":"39656dcb5ef5edd2dbe581dfa2a0c9b8c3643fe61765caeb4e0bedfdceea66f4","contentType":"text/markdown; charset=utf-8"},{"id":"0a152085-40e3-5409-8e48-4bd12388e70c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0a152085-40e3-5409-8e48-4bd12388e70c/attachment.md","path":"references/image-loading-vram.md","size":34393,"sha256":"b2fd96b70faf7e3b7f4f995554c05e9ceff5eb4f2efd3d50aeb229440d5a1f92","contentType":"text/markdown; charset=utf-8"},{"id":"397011fd-2066-5be8-8321-d7fbc8dbfb7c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/397011fd-2066-5be8-8321-d7fbc8dbfb7c/attachment.md","path":"references/networking-antipatterns.md","size":23974,"sha256":"ddf617cb0b5d03067744bf7c16ef5b15bb91ab58bf0011872b8c53cb37b74102","contentType":"text/markdown; charset=utf-8"},{"id":"e14e9f1d-8767-59a8-8d34-252919670f52","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e14e9f1d-8767-59a8-8d34-252919670f52/attachment.md","path":"references/networking-bandwidth.md","size":12073,"sha256":"c58b8cc539fe8803d4b63ec43fdf5c4a3cc5d0080f6a296fcb97e949a9cf8b81","contentType":"text/markdown; charset=utf-8"},{"id":"ccc08834-34cd-50af-9bb6-88c622d0339e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ccc08834-34cd-50af-9bb6-88c622d0339e/attachment.md","path":"references/networking.md","size":37675,"sha256":"789c600478c788133fc8fabe754e7d1b3acb25e0676deb7d41db5faf7cb35aee","contentType":"text/markdown; charset=utf-8"},{"id":"eb225008-581c-5091-acf1-36ceed9cb3d2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/eb225008-581c-5091-acf1-36ceed9cb3d2/attachment.md","path":"references/patterns-core.md","size":22268,"sha256":"47e75380f05b26dd9db73467aac8d4fa6ab7d6ebfd0d7b5b83d7e9bb6340a319","contentType":"text/markdown; charset=utf-8"},{"id":"9ebd4ea8-23af-539f-8922-a519393649ad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9ebd4ea8-23af-539f-8922-a519393649ad/attachment.md","path":"references/patterns-networking.md","size":38949,"sha256":"37eb1cbc07b1259a8d77db2b7a2b22ca73bb7729796bad32a060d04d707c2de0","contentType":"text/markdown; charset=utf-8"},{"id":"a2935ee2-94b6-5474-a81a-3db7616ff508","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a2935ee2-94b6-5474-a81a-3db7616ff508/attachment.md","path":"references/patterns-performance.md","size":46113,"sha256":"cb39628c7b3f1c33c46c32c2c23d2deaf0e1056b7e58559fb0b97b09ae9239b0","contentType":"text/markdown; charset=utf-8"},{"id":"c164eb9f-8eac-5036-a3a2-635884d3bc97","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c164eb9f-8eac-5036-a3a2-635884d3bc97/attachment.md","path":"references/patterns-ui.md","size":61197,"sha256":"5c2d0d03287cb89ebdd9dc1a4877c2219d8952a6f8634b68e1df9be1c65991e7","contentType":"text/markdown; charset=utf-8"},{"id":"401a30c3-d78e-5bc4-a7cb-59a04ede18a7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/401a30c3-d78e-5bc4-a7cb-59a04ede18a7/attachment.md","path":"references/patterns-utilities.md","size":29872,"sha256":"e41f281e0732227ba43c321ecb6ae81c09b10d599e0cfeb6ef324a38d09ce9a2","contentType":"text/markdown; charset=utf-8"},{"id":"c56988bb-595f-5260-93e2-65f5b77177bb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c56988bb-595f-5260-93e2-65f5b77177bb/attachment.md","path":"references/patterns-video.md","size":26209,"sha256":"bcd1b01db2716b76f2a1257e221c087ce47c0696e9a7f772fda9051d63e4e4c8","contentType":"text/markdown; charset=utf-8"},{"id":"d44a0a5e-6aa0-51ff-ac4a-2d8c6adf983b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d44a0a5e-6aa0-51ff-ac4a-2d8c6adf983b/attachment.md","path":"references/persistence.md","size":30451,"sha256":"9a784188e1d1f97730da5a34347d7b7a64bf4ec75e872acc20f8622859a5129a","contentType":"text/markdown; charset=utf-8"},{"id":"ec973ad6-4537-5f22-9681-0cda34d16864","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ec973ad6-4537-5f22-9681-0cda34d16864/attachment.md","path":"references/sdk-migration.md","size":16802,"sha256":"85e542e0c5bb7cd82790d54812da3600f1e31faa31175a39d411bcfde40878e4","contentType":"text/markdown; charset=utf-8"},{"id":"529fbb10-195b-585f-924e-7cf63fb01f00","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/529fbb10-195b-585f-924e-7cf63fb01f00/attachment.md","path":"references/sync-examples.md","size":10743,"sha256":"631bf1c5b3137a9c6e7e3559a752c5da65c9d1b71be795827809ff3217561e9c","contentType":"text/markdown; charset=utf-8"},{"id":"396ec823-b570-5ec1-accd-17b624f3371f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/396ec823-b570-5ec1-accd-17b624f3371f/attachment.md","path":"references/testing.md","size":10013,"sha256":"6a9945e72813e32322c4c3bb6597051dd634a96fbfe68cde4d29ac805eb00596","contentType":"text/markdown; charset=utf-8"},{"id":"3737b858-4430-5f79-822f-eba395644489","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3737b858-4430-5f79-822f-eba395644489/attachment.md","path":"references/troubleshooting.md","size":43175,"sha256":"8dd9600a2cc1302c350003b593d40aeed36154033597d0ed154b4ccd7b21d6b5","contentType":"text/markdown; charset=utf-8"},{"id":"f7b65291-c11c-568e-9ce0-9bb43a9c3669","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f7b65291-c11c-568e-9ce0-9bb43a9c3669/attachment.md","path":"references/web-loading-advanced.md","size":34689,"sha256":"3ddeb2447a954274fc76f73a33a21ac0c86494f1a68e5a8b7089d9c3e2692c92","contentType":"text/markdown; charset=utf-8"},{"id":"91823149-9498-589a-ab00-3a7efeb524f1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/91823149-9498-589a-ab00-3a7efeb524f1/attachment.md","path":"references/web-loading.md","size":20139,"sha256":"37fb76a70a14592197973d49d8c249fd07aceb35e81cabb69197d50ed366fa22","contentType":"text/markdown; charset=utf-8"},{"id":"11fd0ff1-19bf-520f-b9d9-04976b4874ad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/11fd0ff1-19bf-520f-b9d9-04976b4874ad/attachment.md","path":"rules/udonsharp-constraints.md","size":8723,"sha256":"ff44a1e56f890cb99d538faf91d729e72fe63ac7ee833f0f51729453a6b8aed9","contentType":"text/markdown; charset=utf-8"},{"id":"fa345d0e-d5f1-5669-ae50-6789634d0f2c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fa345d0e-d5f1-5669-ae50-6789634d0f2c/attachment.md","path":"rules/udonsharp-networking.md","size":6408,"sha256":"511a7019b332201cea4df6274b26a7ea66f44679a5eb5847ac8bfd254c2061d6","contentType":"text/markdown; charset=utf-8"},{"id":"bf25ac06-5178-5669-8809-269e31e074a0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bf25ac06-5178-5669-8809-269e31e074a0/attachment.md","path":"rules/udonsharp-sync-selection.md","size":2911,"sha256":"d226fd8e126a32c0b3f635d4fcb4e2622014f422c693a21cfa4582bb7cbe43ec","contentType":"text/markdown; charset=utf-8"}],"bundle_sha256":"90c170417bd8aeb8673224bf26e19091fee5bf3348fb0afcfc6b61ed1ff6acd6","attachment_count":46,"text_attachments":45,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":1,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/unity-vrc-udon-sharp/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"games-interactive","category_label":"Games"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"games-interactive","metadata":{"tags":"vrchat, udonsharp, udon, networking, sync, persistence, dynamics","author":"niaka3dayo","version":"2.3.0"},"import_tag":"clean-skills-v1","description":"UdonSharp (C# to Udon Assembly) scripting skill for VRChat world development. Use this skill when writing, reviewing, or debugging UdonSharp C# code. Covers compile constraints (List\u003cT>/async/await/try/catch/LINQ blocked), network sync (UdonSynced, RequestSerialization, FieldChangeCallback, NetworkCallable), persistence (PlayerData/PlayerObject), Dynamics (PhysBones, Contacts), Web Loading, VRAM management (texture lifecycle, Dispose vs Destroy), and event handling. SDK 3.7.1 - 3.10.3 coverage. Triggers on: UdonSharp, Udon, VRC SDK, UdonBehaviour, UdonSynced, NetworkCallable, VRCPlayerApi, SendCustomEvent, PlayerData, PhysBones, synced variables, VRChat world scripting, C# to Udon.\n"}},"renderedAt":1782987308751}

UdonSharp Skill Why This Skill Matters UdonSharp looks like regular Unity C# scripting — until you hit its hidden walls. Many standard C# features ( , , , LINQ, generics) silently fail or refuse to compile in Udon. Networking is even more treacherous: modifying a synced variable without ownership produces no error — it just does nothing. Forgetting means your state changes never leave your machine. Standard single-player local testing gives zero signal about these networking bugs because there is only one player. Every rule in this skill exists because UdonSharp's default behavior is to fail…