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.
Scene setup
Section titled “Scene setup”- Two TMP labels:
partyLabel(lists every member’s stats) andstatusLabel(action feedback). - Two buttons: Attack / Rest wired to
OnAttackClicked/OnRestClicked. - A
NetworkedRpgcomponent on the world root.
Authority model
Section titled “Authority model”local action from non-owner -> send MSG_ATTACK/REST to ownerowner 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 UIMessage protocol
Section titled “Message protocol”| Tag | Name | From → To | Payload |
|---|---|---|---|
| 1 | MSG_JOIN_REQUEST | any → owner | (none) |
| 2 | MSG_STATE | owner → everyone | party count + 18 bytes per member |
| 3 | MSG_ATTACK | any → owner | (none) |
| 4 | MSG_REST | any → 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 — sendnetIdand resolve viaPlayer.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_REQUESTcrosses aMSG_STATEin 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’ts
Section titled “Don’ts”- Don’t accept
MSG_STATEfrom anyone but the current owner. Verifysender == 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
Usernamefor identity. Usernames change;netId(per-session) andUserId(persistent, gated) are the right keys. - Don’t persist
partytoFileStorageunless 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.
Validator
Section titled “Validator”Paste NetworkedRpg.cs into the validator. Expect:
err 0,warn 0under 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.
Extensions
Section titled “Extensions”- Persistent party across sessions — the owner writes
partytoFileStorageon every broadcast; future ownersLoad()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
partywith a negativenetIdflag bit, no client actions can target them. - Inventory + loot drops — extend
PartyMemberwith anint[] items; send a delta when loot lands. - Party chat — a
MSG_CHATtag carrying a sender netId + string. Owner re-broadcasts to filter out spam.
Full source
Section titled “Full source”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; }}Related
Section titled “Related”- Networking — SendType, addressing, MTU.
- Events → Networking events —
OnInstanceOwnerChangehandoff. - RPG — single-player version.
- Turn-Based — simpler owner-authoritative state machine.