Skip to content

06 — Networked Counter

TL;DR: Extends the Click Counter to share state across all players in the instance. The instance owner holds authority: their client is the only one that mutates count and broadcasts updates. Non-owners receive Reliable messages and update their local display. Demonstrates Networking.SendMessage, Networking.OnReceiveMessage, BufferReaderWriter packing, and OnInstanceOwnerChange handoff.

Exactly the same as 02 — Click Counter: Canvas + Button(s) + TextMeshProUGUI for the display. Wire the buttons to OnClicked and OnResetClicked. Add the NetworkedCounter component instead of ClickCounter.

using System;
using UnityEngine;
using TMPro;
using WasmScripting;
using CVR;
public partial class NetworkedCounter : WasmBehaviour
{
public TextMeshProUGUI display;
[WasmSerialized] private int count;
private const byte MSG_COUNT = 1;
void Start()
{
Networking.OnReceiveMessage += HandleMessage;
Refresh();
}
void OnDestroy()
{
Networking.OnReceiveMessage -= HandleMessage;
}
[ExternallyVisible]
public void OnClicked()
{
if (!IsInstanceOwner()) return;
count++;
Broadcast();
Refresh();
}
[ExternallyVisible]
public void OnResetClicked()
{
if (!IsInstanceOwner()) return;
count = 0;
Broadcast();
Refresh();
}
public void OnInstanceOwnerChange(Player newOwner)
{
if (!IsInstanceOwner()) return;
Broadcast();
}
private static bool IsInstanceOwner()
{
Player owner = Networking.GetInstanceOwner();
Player me = LocalPlayer.PlayerObject;
return owner != null && me != null && owner == me;
}
private void Broadcast()
{
var w = new BufferReaderWriter(8);
w.Write(MSG_COUNT);
w.Write(count);
Networking.SendMessage(w.Buffer.Slice(0, w.Length), playerIds: null, SendType.Reliable);
}
private void HandleMessage(Player sender, Span<byte> message)
{
if (message.Length < 1) return;
var r = new BufferReaderWriter(message);
r.Read(out byte tag);
if (tag != MSG_COUNT) return;
r.Read(out int newCount);
// Only accept from the current instance owner.
Player owner = Networking.GetInstanceOwner();
if (owner == null || sender == null || sender != owner) return;
count = newCount;
Refresh();
}
private void Refresh()
{
if (display != null) display.text = $"Shared clicks: {count}";
}
}

Source file: examples/06_NetworkedCounter.cs.

The two hardest bugs in networked scripts are:

  • Everyone mutates state → conflicts, divergence, or last-writer-wins races.
  • Non-authoritative clients broadcast noise → someone trolls by spamming messages that other clients happily accept.

This script avoids both by making the instance owner (a property of the CVR instance, not the script) the sole writer:

  1. Local writes are gatedOnClicked / OnResetClicked early-return unless IsInstanceOwner(). Clicking on a non-owner’s client does nothing.
  2. Incoming messages are verifiedHandleMessage drops anything whose sender is not the current Networking.GetInstanceOwner().
  3. Handoff is resilient — when OnInstanceOwnerChange fires (the owner leaves and authority transfers), the new owner re-broadcasts the current state so late-joiners and any client that missed an update synchronize.

Non-owners never touch count except in response to an accepted HandleMessage. They can click all they want — nothing changes locally until the owner’s click round-trips.

Instead of JSON strings, the wire format is:

[1 byte tag][4 bytes int count]

Tiny, deterministic, and costs exactly one guest-side allocation per send (the buffer itself). See BufferReaderWriter.

The tag byte lets you grow the protocol without breaking parsers — future messages can define MSG_RESET = 2, MSG_SNAPSHOT = 3, etc., and the handler can ignore unknown tags gracefully.

  • Button clicks are discrete events — SendType.Reliable ensures every non-owner sees every increment in order. Latency on the reliable channel is modestly higher than unreliable, but still under typical interactive thresholds.
  • For continuous streams (player position every frame), you’d use SendType.Unreliable or UnreliableSequenced with a timestamp.
  • Networking.SendMessage, GetInstanceOwner, OnReceiveMessage — no Object/Owner/Scope restrictions. Available from any content context.
  • LocalPlayer.PlayerObject — world-only. The script has to be a world script to use this pattern (or a prop script that borrows a world-permissioned helper; not shown).
  • Per-player counters — switch from one shared counter to a Dictionary<short, int> keyed by Player.GetNetworkId(). Each player broadcasts their own updates; no owner-authority required.
  • Late join snapshot — on OnPlayerJoined(Player player), the owner sends a fresh MSG_COUNT to new short[] { player.GetNetworkId() } so the joining player sees the current state immediately.
  • Throttled broadcasts — add a Time.time-based debounce so rapid clicks coalesce into fewer messages.
  • Optimistic UI — let non-owners increment locally and send an “I clicked” request; the owner adjudicates and broadcasts the authoritative value. More complex; only worth it if round-trip latency hurts UX.
  • Networking — send types, MTU probes, addressing.
  • BufferReaderWriter — packing format.
  • EventsOnInstanceOwnerChange and the Networking.OnReceiveMessage delegate.