Avatar: Accessory Toggle
TL;DR: The avatar carries an array of cosmetic GameObjects. The wearer cycles through them (on a sprint button press in this example — swap to any CVRButton you like). The active index is broadcast over Reliable so remote viewers render the same state. Demonstrates avatar-context ownership via WasmUtils.GetOwnerContext(), network sync without LocalPlayer (which is world-only), and late-joiner re-sync via OnPlayerJoined.
Context: Avatar. LocalPlayer.* is unavailable; ownership is determined by the script’s own context.
Scene setup (avatar hierarchy)
Section titled “Scene setup (avatar hierarchy)”- An avatar prefab with an
AccessoryTogglecomponent at the avatar root. - Populate
accessories[]with childGameObjects — hats, glasses, tails, anything that should toggle. - Leave everything inactive at upload time; the script enables whichever index is active.
Code highlights
Section titled “Code highlights”private bool IsWearer(){ return WasmUtils.GetOwnerContext() == CVRScriptOwnerContext.Self;}
void Update(){ if (!IsWearer()) return; if (CVRInput.WasPressedThisFrame(CVRButton.Sprint)) Cycle();}
private void HandleMessage(Player sender, Span<byte> message){ var r = new BufferReaderWriter(message); r.Read(out byte tag); if (tag != MSG_SET_INDEX) return; Avatar avatar = Avatar.GetCurrentAvatar(); Player wearer = avatar?.GetWearer(); if (wearer == null || sender != wearer) return; // ignore spoofed updates r.Read(out int idx); Apply(idx);}Source: examples/avatar/AccessoryToggle.cs.
Why WasmUtils.GetOwnerContext() instead of LocalPlayer
Section titled “Why WasmUtils.GetOwnerContext() instead of LocalPlayer”On the wearer’s client, the VM’s owner context is Self; everywhere else it’s Other. That’s the right gate for “am I the wearer” from an avatar script. Using LocalPlayer.PlayerObject would throw at runtime because it’s world-only.
Why verify the sender against GetWearer()
Section titled “Why verify the sender against GetWearer()”When a remote client receives a MSG_SET_INDEX, the message could in principle come from anyone. We accept it only if the sender matches the avatar’s current wearer — Avatar.GetCurrentAvatar().GetWearer(). That stops spoofed toggles from other players.
- Use
WasmUtils.GetOwnerContext()for avatar ownership checks. It doesn’t require any world-level binding. - Broadcast on
OnPlayerJoinedfrom the wearer’s side so late-arriving viewers immediately see the correct accessory. - Use
Reliablesends — a dropped cosmetic update is noticeable and cheap to re-send. - Verify sender == wearer on every inbound
MSG_SET_INDEX. A typical anti-spoof check. - Use
[WasmSerialized]onactiveIndexso the inspector-selected starting state plays back consistently.
Don’ts
Section titled “Don’ts”- Don’t call
LocalPlayer.PlayerObjectfrom an avatar script. GuaranteedWasmAccessDeniedException. - Don’t toggle children outside the avatar root. Scope checks will fail; you can only mutate your own content.
- Don’t broadcast on every
Update. Sync only on real state changes (the index changes) — this example broadcasts on everyCycle()and on everyOnPlayerJoinedof a new viewer. - Don’t assume input writes will work.
CVRInput.SetMovement(...)and friends only materialize in world VMs or avatar-on-self contexts. Avatar reads are fine; writes are narrowly gated. - Don’t use
Debug.Login cosmetic-critical hot paths. Each log is a host call. Fine for debugging; remove before shipping.
Validator
Section titled “Validator”Paste AccessoryToggle.cs into the validator and select Avatar context. Expect:
err 0,warn 0.inforeportingUnity events detected: Start, Update, OnDestroyandGame events detected: OnPlayerJoined.- Switch the context to World — still clean; nothing here is avatar-only.
Extensions
Section titled “Extensions”- Pose-driven toggle — sample
CVRInputgesture values, detect a specific pose, callCycle(). - Named outfits — replace
int activeIndexwith astring outfitkey that maps to a Dictionary of GameObject groups. - Per-outfit audio — play a one-shot
AudioSourcechild onApply(). - Persistence across sessions — avatars don’t have
FileStorage; you’d sync the chosen outfit via a companion world script that persists on behalf of the wearer.
Full source
Section titled “Full source”using System;using UnityEngine;using WasmScripting;using CVR;
public partial class AccessoryToggle : WasmBehaviour{ public GameObject[] accessories; public KeyCode toggleKey = KeyCode.T;
[WasmSerialized] private int activeIndex = -1;
private const byte MSG_SET_INDEX = 1;
void Start() { Networking.OnReceiveMessage += HandleMessage; Apply(activeIndex); } void OnDestroy() { Networking.OnReceiveMessage -= HandleMessage; }
public void OnPlayerJoined(Player player) { // When a new player arrives, re-broadcast so they match immediately. if (IsWearer()) Broadcast(activeIndex); }
void Update() { if (!IsWearer()) return; if (CVRInput.WasPressedThisFrame(CVRButton.Sprint)) { // Example hotkey via CVRInput; keep your own key choice. Cycle(); } }
public void Cycle() { if (accessories == null || accessories.Length == 0) return; int next = activeIndex + 1; if (next >= accessories.Length) next = -1; // -1 means "nothing active" Apply(next); Broadcast(next); }
private void Apply(int idx) { activeIndex = idx; if (accessories == null) return; for (int i = 0; i < accessories.Length; i++) { if (accessories[i] == null) continue; accessories[i].SetActive(i == idx); } }
private void Broadcast(int idx) { var w = new BufferReaderWriter(8); w.Write(MSG_SET_INDEX); w.Write(idx); Networking.SendMessage(w.Buffer.Slice(0, w.Length), null, SendType.Reliable); }
private void HandleMessage(Player sender, Span<byte> message) { if (message.Length < 1 || sender == null) return; var r = new BufferReaderWriter(message); r.Read(out byte tag); if (tag != MSG_SET_INDEX) return; // Only accept index updates from the wearer. Avatar avatar = Avatar.GetCurrentAvatar(); if (avatar == null) return; Player wearer = avatar.GetWearer(); if (wearer == null || sender != wearer) return; r.Read(out int idx); Apply(idx); }
private bool IsWearer() { // Compare against the avatar's wearer rather than LocalPlayer, since LocalPlayer.* is world-only. return WasmUtils.GetOwnerContext() == CVRScriptOwnerContext.Self; }}Related
Section titled “Related”- Avatar context — capability overview.
- CVR Input — the read/write gate.
- Permissions — why
LocalPlayerfails.