Turn-Based Game
TL;DR: A 3×3 tic-tac-toe board where player order is determined by join order (first two players become X and O). The instance owner validates every move — clients who click a cell send a MSG_MOVE to the owner; the owner applies it if legal and broadcasts the new state to everyone. Demonstrates owner-authoritative networking, turn alternation, and deferring state changes to a single authority.
Context: World. Uses LocalPlayer.PlayerObject and Player.GetAllPlayers() which are world-only.
Scene setup
Section titled “Scene setup”- A world Canvas with a 3×3 grid of buttons. Each button’s
onClickcalls a dedicatedOnCellN()method (0..8) on theTurnBasedGamecomponent. - Each cell has a
TextMeshProUGUIchild; populate thecellLabels[]array in order 0..8. - A
statusLabel(TMP) for turn / winner messages. - A Reset button wired to
OnResetClicked.
Message protocol
Section titled “Message protocol”MSG_MOVE = 1 [1 byte tag][4 bytes cellIdx]MSG_STATE = 2 [1 byte tag][9 bytes board][1 byte currentTurn][1 byte winner]MSG_RESET = 3 [1 byte tag]All messages flow over SendType.Reliable. Moves and resets from non-owners target only the owner (via playerIds: new[] { owner.GetNetworkId() }). Snapshot broadcasts from the owner target everyone (playerIds: null).
The authority loop
Section titled “The authority loop”non-owner clicks cell -> MSG_MOVE sent to ownerowner receives MSG_MOVE -> validate (correct turn? cell empty? winner not yet decided?) -> apply -> MSG_STATE broadcast to allowner clicks cell -> apply directly + broadcast MSG_STATEPlayers never write to their local board from a click. They either mutate through the owner, or via an incoming MSG_STATE from the owner. This keeps the game consistent even if a non-owner’s view is behind by a frame.
- Make the instance owner the sole writer of shared state. Every authoritative mutation path (
ApplyMove,ResetLocal) is only called from anIsInstanceOwner()branch or inside an owner-received message handler. - Verify sender identity on every inbound message. A non-owner client shouldn’t spoof a
MSG_STATEto rewrite the board; reject anything whose sender isn’t the current owner. - Include sender in auth check — for
MSG_MOVE, the marker (X vs O) is derived from the sender’s Player identity, not from a client-supplied claim. - Broadcast on join (
OnPlayerJoinedfrom the owner’s side) so late joiners immediately get the current state. - Use
[WasmSerialized]on the board array so inspector-filled test state carries through.
Don’ts
Section titled “Don’ts”- Don’t let any client mutate
board[]fromOnCellClickedunless they are the instance owner. That path exists in the code only for the owner’s own clicks. - Don’t broadcast every frame.
MSG_STATEfires once per legal move. At two players clicking at typical rates, that’s a handful of messages per minute. - Don’t rely on timestamps to order concurrent moves. The owner’s local order is authoritative; late arrivals are validated against the current board at receive time.
- Don’t use
SendType.Unreliablefor moves. A dropped move = a lost turn = a frustrated player. Reliable is the right trade-off for turn-based. - Don’t assume
Player.GetAllPlayers()returns a stable order. Re-BindPlayers()onOnPlayerJoined/OnPlayerLeft/OnInstanceOwnerChange.
Validator
Section titled “Validator”Paste TurnBasedGame.cs into the validator. Expect:
err 0,warn 0under World.info:Unity events detected: StartandGame events detected: OnInstanceOwnerChange, OnPlayerJoined, OnPlayerLeft.- Switch context to Avatar — the validator flags
LocalPlayer.PlayerObjectas world-only (several hits).
Extensions
Section titled “Extensions”- More than 2 players — lobby queue with
Queue<Player>, rotate spectators into X/O as slots free up. - Rematch flow — after a win, show a “play again?” button that sends
MSG_RESET. - Spectator view — non-participants see the board read-only;
MyMarkerreturns 0, clicks are no-ops. - Disconnect handling — if X leaves mid-game, auto-promote the next player from the queue and broadcast.
- Move timer — owner enforces a 30-second turn timer; auto-forfeit if the current player doesn’t move in time.
Full source
Section titled “Full source”using System;using TMPro;using UnityEngine;using WasmScripting;using CVR;
public partial class TurnBasedGame : WasmBehaviour{ public TextMeshProUGUI[] cellLabels = new TextMeshProUGUI[9]; public TextMeshProUGUI statusLabel;
[WasmSerialized] private byte[] board = new byte[9]; [WasmSerialized] private byte currentTurn = 1; [WasmSerialized] private byte winner;
private Player _playerX; private Player _playerO;
private const byte MSG_MOVE = 1; private const byte MSG_STATE = 2; private const byte MSG_RESET = 3;
void Start() { Networking.OnReceiveMessage += HandleMessage; BindPlayers(); RefreshCells(); RefreshStatus(); } void OnDestroy() { Networking.OnReceiveMessage -= HandleMessage; }
public void OnInstanceOwnerChange(Player newOwner) { BindPlayers(); RefreshStatus(); } public void OnPlayerJoined(Player player) { BindPlayers(); if (IsInstanceOwner()) BroadcastState(); } public void OnPlayerLeft(Player player) { BindPlayers(); RefreshStatus(); }
public void OnCellClicked(int idx) { if (winner != 0) return; if (idx < 0 || idx >= 9 || board[idx] != 0) return;
byte me = MyMarker(); if (me == 0 || me != currentTurn) return;
if (IsInstanceOwner()) { ApplyMove(idx, me); BroadcastState(); } else { SendMove(idx); } }
public void OnResetClicked() { if (!IsInstanceOwner()) { SendReset(); return; } ResetLocal(); BroadcastState(); }
// --- logic ---
private void ApplyMove(int idx, byte marker) { if (idx < 0 || idx >= 9 || board[idx] != 0 || marker == 0) return; board[idx] = marker; byte w = CheckWinner(); if (w != 0) { winner = w; } else { currentTurn = (byte)(3 - marker); } RefreshCells(); RefreshStatus(); }
private void ResetLocal() { for (int i = 0; i < 9; i++) board[i] = 0; currentTurn = 1; winner = 0; RefreshCells(); RefreshStatus(); }
private byte CheckWinner() { int[,] lines = new int[,] { {0,1,2},{3,4,5},{6,7,8},{0,3,6},{1,4,7},{2,5,8},{0,4,8},{2,4,6} }; for (int i = 0; i < lines.GetLength(0); i++) { byte a = board[lines[i, 0]], b = board[lines[i, 1]], c = board[lines[i, 2]]; if (a != 0 && a == b && b == c) return a; } return 0; }
// --- networking ---
private void HandleMessage(Player sender, Span<byte> message) { if (message.Length < 1) return; var r = new BufferReaderWriter(message); r.Read(out byte tag); switch (tag) { case MSG_MOVE: r.Read(out int moveIdx); if (!IsInstanceOwner()) return; if (sender == null) return; byte marker = (sender == _playerX) ? (byte)1 : (sender == _playerO ? (byte)2 : (byte)0); if (marker != currentTurn || winner != 0) return; ApplyMove(moveIdx, marker); BroadcastState(); break;
case MSG_STATE: if (IsInstanceOwner()) return; Player owner = Networking.GetInstanceOwner(); if (owner == null || sender == null || sender != owner) return; for (int i = 0; i < 9; i++) { r.Read(out byte v); board[i] = v; } r.Read(out byte turnByte); r.Read(out byte winByte); currentTurn = turnByte; winner = winByte; RefreshCells(); RefreshStatus(); break;
case MSG_RESET: if (!IsInstanceOwner()) return; if (sender != _playerX && sender != _playerO) return; ResetLocal(); BroadcastState(); break; } }
private void SendMove(int idx) { Player owner = Networking.GetInstanceOwner(); if (owner == null) return; var w = new BufferReaderWriter(8); w.Write(MSG_MOVE); w.Write(idx); Networking.SendMessage(w.Buffer.Slice(0, w.Length), new short[] { owner.GetNetworkId() }, SendType.Reliable); }
private void SendReset() { Player owner = Networking.GetInstanceOwner(); if (owner == null) return; var w = new BufferReaderWriter(4); w.Write(MSG_RESET); Networking.SendMessage(w.Buffer.Slice(0, w.Length), new short[] { owner.GetNetworkId() }, SendType.Reliable); }
private void BroadcastState() { var w = new BufferReaderWriter(20); w.Write(MSG_STATE); for (int i = 0; i < 9; i++) w.Write(board[i]); w.Write(currentTurn); w.Write(winner); Networking.SendMessage(w.Buffer.Slice(0, w.Length), null, SendType.Reliable); }
// --- players ---
private void BindPlayers() { Player[] all = Player.GetAllPlayers(); _playerX = null; _playerO = null; if (all == null) return; if (all.Length > 0) _playerX = all[0]; if (all.Length > 1) _playerO = all[1]; }
private byte MyMarker() { Player me = LocalPlayer.PlayerObject; if (me == null) return 0; if (_playerX == me) return 1; if (_playerO == me) return 2; return 0; }
private bool IsInstanceOwner() { Player owner = Networking.GetInstanceOwner(); Player me = LocalPlayer.PlayerObject; return owner != null && me != null && owner == me; }
// --- UI ---
private void RefreshCells() { if (cellLabels == null) return; for (int i = 0; i < 9 && i < cellLabels.Length; i++) { if (cellLabels[i] == null) continue; cellLabels[i].text = board[i] == 1 ? "X" : board[i] == 2 ? "O" : ""; } }
private void RefreshStatus() { if (statusLabel == null) return; if (winner == 1) { statusLabel.text = "X wins!"; return; } if (winner == 2) { statusLabel.text = "O wins!"; return; } bool full = true; for (int i = 0; i < 9; i++) if (board[i] == 0) { full = false; break; } if (full) { statusLabel.text = "Draw."; return; } statusLabel.text = currentTurn == 1 ? "X's turn" : "O's turn"; }
// Called by each cell's Button.onClick with `int` arg 0..8. public void OnCell0() => OnCellClicked(0); public void OnCell1() => OnCellClicked(1); public void OnCell2() => OnCellClicked(2); public void OnCell3() => OnCellClicked(3); public void OnCell4() => OnCellClicked(4); public void OnCell5() => OnCellClicked(5); public void OnCell6() => OnCellClicked(6); public void OnCell7() => OnCellClicked(7); public void OnCell8() => OnCellClicked(8);}Related
Section titled “Related”- Networking — owner-authoritative patterns.
- Events → Networking events —
OnInstanceOwnerChangeusage. - Example 06 — Networked Counter — simpler one-state networked example.