Skip to content

Networked RPG

TL;DR: A shared party of up to 64 players. The instance owner holds the authoritative state (List<PartyMember>) and processes every action. Non-owners send MSG_ATTACK / MSG_REST / MSG_JOIN_REQUEST requests; the owner validates, applies, and broadcasts a full snapshot. State is in memory only — when the owner leaves, the new owner starts fresh (extend with FileStorage persistence if you want durable state).

Context: World. Uses LocalPlayer, Networking, and authoritative ownership.

  • Two TMP labels: partyLabel (lists every member’s stats) and statusLabel (action feedback).
  • Two buttons: Attack / Rest wired to OnAttackClicked / OnRestClicked.
  • A NetworkedRpg component on the world root.
local action from non-owner -> send MSG_ATTACK/REST to owner
owner receives request -> DoAttackLocally(sender) / DoRestLocally(sender)
BroadcastState()
owner acts locally -> DoAttackLocally(me) + BroadcastState()
non-owner receives MSG_STATE -> verify sender == Networking.GetInstanceOwner()
replace party[] with payload
refresh UI
TagNameFrom → ToPayload
1MSG_JOIN_REQUESTany → owner(none)
2MSG_STATEowner → everyoneparty count + 18 bytes per member
3MSG_ATTACKany → owner(none)
4MSG_RESTany → owner(none)

Per-member payload (18 bytes):

[2 bytes] netId (short)
[4 bytes] hp (int)
[4 bytes] maxHp (int)
[4 bytes] level (int)
[4 bytes] xp (int)

A 4-player snapshot is ~76 bytes including the tag and count. Well under any MTU.

  • Store party state as a compact struct (PartyMember) with only the fields you actually replicate. Avoid holding Unity handles (Player, Transform) in the replicated state — send netId and resolve via Player.GetAllPlayers() locally when needed.
  • Rebuild party on owner change. In OnInstanceOwnerChange, if you became the owner, rebuild from the players currently in the instance.
  • Rate-limit join requests. If a late joiner’s MSG_JOIN_REQUEST crosses a MSG_STATE in flight, they’ll receive two states in rapid succession — that’s fine; the second one overwrites the first.
  • Validate array bounds on receive. A malicious sender could claim count = 2147483647. Cap at something reasonable (64 here) before allocating.
  • Don’t accept MSG_STATE from anyone but the current owner. Verify sender == Networking.GetInstanceOwner() before mutating.
  • Don’t broadcast state every frame. Broadcast only when state actually changes (an attack lands, a player joins/leaves, an owner hands off).
  • Don’t send per-stat deltas separately. One full snapshot is simpler and small enough for this scale. Delta protocols become worthwhile at hundreds of members or fine-grained positional state.
  • Don’t reach for Username for identity. Usernames change; netId (per-session) and UserId (persistent, gated) are the right keys.
  • Don’t persist party to FileStorage unless the game design calls for it. Persistence tangles with “what happens when the owner leaves and the state has to migrate” — explicitly design around that before saving.

Paste NetworkedRpg.cs into the validator. Expect:

  • err 0, warn 0 under World.
  • info: Unity events (Start, OnDestroy) and Game events (OnPlayerJoined, OnPlayerLeft, OnInstanceOwnerChange).
  • Switch context to Avatar or Prop — the validator flags LocalPlayer.PlayerObject, Player.GetAllPlayers(), and the world-only APIs used here.
  • Persistent party across sessions — the owner writes party to FileStorage on every broadcast; future owners Load() it when they become owner. Decide whether state belongs to the world or to specific users.
  • Enemy NPCs — owner-side simulation; NPCs are part of party with a negative netId flag bit, no client actions can target them.
  • Inventory + loot drops — extend PartyMember with an int[] items; send a delta when loot lands.
  • Party chat — a MSG_CHAT tag carrying a sender netId + string. Owner re-broadcasts to filter out spam.
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using WasmScripting;
using CVR;
public partial class NetworkedRpg : WasmBehaviour
{
[Serializable]
public struct PartyMember
{
public short netId;
public int hp;
public int maxHp;
public int level;
public int xp;
}
public TextMeshProUGUI partyLabel;
public TextMeshProUGUI statusLabel;
[WasmSerialized] private List<PartyMember> party = new();
private const byte MSG_JOIN_REQUEST = 1;
private const byte MSG_STATE = 2;
private const byte MSG_ATTACK = 3;
private const byte MSG_REST = 4;
void Start()
{
Networking.OnReceiveMessage += HandleMessage;
if (IsInstanceOwner()) EnsureSelfInParty();
else SendJoinRequest();
Refresh();
}
void OnDestroy() { Networking.OnReceiveMessage -= HandleMessage; }
public void OnPlayerJoined(Player player)
{
if (!IsInstanceOwner()) return;
BroadcastState();
}
public void OnPlayerLeft(Player player)
{
if (!IsInstanceOwner() || player == null) return;
RemoveMember(player.GetNetworkId());
BroadcastState();
}
public void OnInstanceOwnerChange(Player newOwner)
{
if (IsInstanceOwner()) { EnsureSelfInParty(); BroadcastState(); }
}
public void OnAttackClicked()
{
if (IsInstanceOwner())
{
DoAttackLocally(LocalPlayer.PlayerObject);
BroadcastState();
}
else
{
SendTag(MSG_ATTACK);
}
}
public void OnRestClicked()
{
if (IsInstanceOwner())
{
DoRestLocally(LocalPlayer.PlayerObject);
BroadcastState();
}
else
{
SendTag(MSG_REST);
}
}
// --- owner-side logic ---
private void DoAttackLocally(Player actor)
{
if (actor == null) return;
int idx = FindIndex(actor.GetNetworkId());
if (idx < 0) return;
PartyMember pm = party[idx];
int xpGain = UnityEngine.Random.Range(10, 26);
pm.xp += xpGain;
while (pm.xp >= 100 + (pm.level - 1) * 50)
{
pm.xp -= 100 + (pm.level - 1) * 50;
pm.level++;
pm.maxHp += 10;
pm.hp = pm.maxHp;
}
party[idx] = pm;
SetStatus($"{actor.GetUsername()} +{xpGain} xp");
}
private void DoRestLocally(Player actor)
{
if (actor == null) return;
int idx = FindIndex(actor.GetNetworkId());
if (idx < 0) return;
PartyMember pm = party[idx];
pm.hp = pm.maxHp;
party[idx] = pm;
SetStatus($"{actor.GetUsername()} rested");
}
private void EnsureSelfInParty()
{
Player me = LocalPlayer.PlayerObject;
if (me == null) return;
EnsureMember(me.GetNetworkId());
}
private void EnsureMember(short netId)
{
if (FindIndex(netId) >= 0) return;
party.Add(new PartyMember { netId = netId, hp = 100, maxHp = 100, level = 1, xp = 0 });
}
private void RemoveMember(short netId)
{
int idx = FindIndex(netId);
if (idx >= 0) party.RemoveAt(idx);
}
private int FindIndex(short netId)
{
for (int i = 0; i < party.Count; i++) if (party[i].netId == netId) return i;
return -1;
}
// --- networking ---
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);
switch (tag)
{
case MSG_JOIN_REQUEST:
if (!IsInstanceOwner()) return;
EnsureMember(sender.GetNetworkId());
BroadcastState();
break;
case MSG_ATTACK:
if (!IsInstanceOwner()) return;
DoAttackLocally(sender);
BroadcastState();
break;
case MSG_REST:
if (!IsInstanceOwner()) return;
DoRestLocally(sender);
BroadcastState();
break;
case MSG_STATE:
if (IsInstanceOwner()) return;
Player owner = Networking.GetInstanceOwner();
if (owner == null || sender != owner) return;
r.Read(out int count);
if (count < 0 || count > 64) return;
party.Clear();
for (int i = 0; i < count; i++)
{
r.Read(out short nid);
r.Read(out int hp);
r.Read(out int maxHp);
r.Read(out int lvl);
r.Read(out int xp);
party.Add(new PartyMember { netId = nid, hp = hp, maxHp = maxHp, level = lvl, xp = xp });
}
Refresh();
break;
}
}
private void SendTag(byte tag)
{
Player owner = Networking.GetInstanceOwner();
if (owner == null) return;
var w = new BufferReaderWriter(4);
w.Write(tag);
Networking.SendMessage(w.Buffer.Slice(0, w.Length),
new short[] { owner.GetNetworkId() }, SendType.Reliable);
}
private void SendJoinRequest() => SendTag(MSG_JOIN_REQUEST);
private void BroadcastState()
{
var w = new BufferReaderWriter(8 + party.Count * 18);
w.Write(MSG_STATE);
w.Write(party.Count);
for (int i = 0; i < party.Count; i++)
{
PartyMember pm = party[i];
w.Write(pm.netId);
w.Write(pm.hp);
w.Write(pm.maxHp);
w.Write(pm.level);
w.Write(pm.xp);
}
Networking.SendMessage(w.Buffer.Slice(0, w.Length), null, SendType.Reliable);
Refresh();
}
private bool IsInstanceOwner()
{
Player owner = Networking.GetInstanceOwner();
Player me = LocalPlayer.PlayerObject;
return owner != null && me != null && owner == me;
}
// --- UI ---
private void Refresh()
{
if (partyLabel == null) return;
var sb = new System.Text.StringBuilder(party.Count * 48);
for (int i = 0; i < party.Count; i++)
{
PartyMember pm = party[i];
sb.Append('#').Append(pm.netId)
.Append(" Lvl ").Append(pm.level)
.Append(" HP ").Append(pm.hp).Append('/').Append(pm.maxHp)
.Append(" XP ").Append(pm.xp)
.Append('\n');
}
partyLabel.text = sb.ToString();
}
private void SetStatus(string s) { if (statusLabel != null) statusLabel.text = s; }
}