Skip to content

Prop: Dice Roller

TL;DR: A 6-sided die prop (sides configurable). Only the spawner can roll; results broadcast over Reliable, and every viewer accepts only messages whose sender matches the prop’s current spawner. Demonstrates prop-context ownership via WasmUtils.GetOwnerContext(), spawner-authoritative networking, and accepting inbound sync with sender verification.

Context: Prop. LocalPlayer.* is unavailable; ownership is context-based.

  • A prop root GameObject with a DiceRoller component.
  • A TextMeshProUGUI child for faceLabel (renders the rolled number).
  • An optional statusLabel TMP for feedback.
  • A Button (or CCK interactable) whose click invokes Roll(). The [ExternallyVisible] attribute marks the method as a valid UnityEvent target for forward-compat — current CCK accepts any public method, but future versions may require the attribute (see Unity Events Rewiring).
[ExternallyVisible]
public void Roll()
{
if (!IsSpawner()) { SetStatus("only the spawner can roll"); return; }
int face = UnityEngine.Random.Range(1, Mathf.Max(2, sides + 1));
ApplyResult(face);
Broadcast(face);
}
private void HandleMessage(Player sender, Span<byte> message)
{
// ... read tag ...
Prop current = Prop.GetCurrentProp();
Player spawner = current?.GetSpawner();
if (spawner == null || sender != spawner) return; // reject non-spawner senders
// ... read face and apply ...
}
private bool IsSpawner()
{
return WasmUtils.GetOwnerContext() == CVRScriptOwnerContext.Self;
}

Source: examples/prop/DiceRoller.cs.

A prop lives in every viewer’s scene, but only the spawner should be able to change its state. If every client could roll independently, everyone would see different numbers. Restricting rolls to the spawner + broadcasting gives everyone a consistent view, and verifying the sender on receive stops any player from faking a roll on another’s prop.

When a new player joins, they see the prop spawn via their own OnPropSpawned. If the spawner’s side has already rolled, that message might have landed before the new viewer joined — they’d see the default. To fix, the spawner re-broadcasts on OnPropSpawned:

public void OnPropSpawned(Prop prop)
{
if (IsSpawner() && lastRoll > 0) Broadcast(lastRoll);
}

OnPropSpawned fires on all viewers whenever any prop spawns — the spawner filters to their own prop via IsSpawner() and rebroadcasts the last-known state. Cheap because it’s once per join event.

  • Use WasmUtils.GetOwnerContext() for prop ownership checks. Works from prop context without any world-only calls.
  • Verify sender against Prop.GetCurrentProp().GetSpawner() on inbound MSG_RESULT. Prevents cross-prop spoofing.
  • Use Reliable for discrete events like dice rolls; a lost roll is noticeable.
  • Cache Prop.GetCurrentProp() in Start if you call it often — it’s a host round-trip per call. The example re-reads it because the rate is low.
  • Mark UI-invocable methods with [ExternallyVisible] to stay forward-compatible with the planned attribute enforcement.
  • Don’t call LocalPlayer.PlayerObject from a prop script. World-only; throws.
  • Don’t call prop.Destroy() from inside the prop’s own script. That binding is world-only; a prop wanting to self-destruct should network a “destroy me” request to a world script that then invokes destruction.
  • Don’t broadcast on every Update. Dice-roll events are user-driven; broadcast once per roll.
  • Don’t skip sender verification for state-mutating messages. Without it, any player can fake a roll on your prop.
  • Don’t forget Random.Range’s inclusive/exclusive behaviourRandom.Range(1, 7) returns 1–6 inclusive for int overloads (min inclusive, max exclusive). The example uses sides + 1 accordingly.

Paste DiceRoller.cs into the validator and select Prop context. Expect:

  • err 0, warn 0.
  • info reporting Unity events detected: Start, OnDestroy and Game events detected: OnPropSpawned.
  • Switch the context to World — still clean; the script avoids any prop-only assumptions.
  • Multi-die roller — N dice in one roll; broadcast a fixed-size byte[] of results.
  • Physics die — replace the on-click roll with a physics-driven die + face detection on OnCollisionExit.
  • Visual flip animation — interpolate the face label over 200 ms before settling on the result to sell the roll.
  • Roll history — keep the last 10 results in a ring buffer; display as a secondary text.
using System;
using TMPro;
using UnityEngine;
using WasmScripting;
using CVR;
public partial class DiceRoller : WasmBehaviour
{
public TextMeshProUGUI faceLabel;
public TextMeshProUGUI statusLabel;
public int sides = 6;
[WasmSerialized] private int lastRoll = 0;
private const byte MSG_RESULT = 1;
void Start()
{
Networking.OnReceiveMessage += HandleMessage;
Refresh();
}
void OnDestroy() { Networking.OnReceiveMessage -= HandleMessage; }
// Called from a Unity Button.onClick on the prop.
[ExternallyVisible]
public void Roll()
{
if (!IsSpawner()) { SetStatus("only the spawner can roll"); return; }
int face = UnityEngine.Random.Range(1, Mathf.Max(2, sides + 1));
ApplyResult(face);
Broadcast(face);
}
public void OnPropSpawned(Prop prop)
{
// When a prop spawns on someone else's client, they already have the default state.
// The spawner's side re-broadcasts so late viewers sync.
if (IsSpawner() && lastRoll > 0) Broadcast(lastRoll);
}
private void ApplyResult(int face)
{
lastRoll = face;
Refresh();
SetStatus($"rolled {face}");
}
private void Broadcast(int face)
{
var w = new BufferReaderWriter(8);
w.Write(MSG_RESULT);
w.Write(face);
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_RESULT) return;
// Accept results only from the prop's spawner.
Prop current = Prop.GetCurrentProp();
if (current == null) return;
Player spawner = current.GetSpawner();
if (spawner == null || sender != spawner) return;
r.Read(out int face);
if (face < 1 || face > sides) return;
ApplyResult(face);
}
private bool IsSpawner()
{
// Ownership context is "Self" on the spawner's client for prop VMs.
return WasmUtils.GetOwnerContext() == CVRScriptOwnerContext.Self;
}
private void Refresh()
{
if (faceLabel != null) faceLabel.text = lastRoll > 0 ? $"{lastRoll}" : "-";
}
private void SetStatus(string s) { if (statusLabel != null) statusLabel.text = s; }
}