Skip to content

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.

  • An avatar prefab with an AccessoryToggle component at the avatar root.
  • Populate accessories[] with child GameObjects — hats, glasses, tails, anything that should toggle.
  • Leave everything inactive at upload time; the script enables whichever index is active.
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.

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 OnPlayerJoined from the wearer’s side so late-arriving viewers immediately see the correct accessory.
  • Use Reliable sends — 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] on activeIndex so the inspector-selected starting state plays back consistently.
  • Don’t call LocalPlayer.PlayerObject from an avatar script. Guaranteed WasmAccessDeniedException.
  • 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 every Cycle() and on every OnPlayerJoined of 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.Log in cosmetic-critical hot paths. Each log is a host call. Fine for debugging; remove before shipping.

Paste AccessoryToggle.cs into the validator and select Avatar context. Expect:

  • err 0, warn 0.
  • info reporting Unity events detected: Start, Update, OnDestroy and Game events detected: OnPlayerJoined.
  • Switch the context to World — still clean; nothing here is avatar-only.
  • Pose-driven toggle — sample CVRInput gesture values, detect a specific pose, call Cycle().
  • Named outfits — replace int activeIndex with a string outfit key that maps to a Dictionary of GameObject groups.
  • Per-outfit audio — play a one-shot AudioSource child on Apply().
  • 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.
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;
}
}