Skip to content

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.

  • A world Canvas with a 3×3 grid of buttons. Each button’s onClick calls a dedicated OnCellN() method (0..8) on the TurnBasedGame component.
  • Each cell has a TextMeshProUGUI child; populate the cellLabels[] array in order 0..8.
  • A statusLabel (TMP) for turn / winner messages.
  • A Reset button wired to OnResetClicked.
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).

non-owner clicks cell -> MSG_MOVE sent to owner
owner receives MSG_MOVE -> validate (correct turn? cell empty? winner not yet decided?)
-> apply -> MSG_STATE broadcast to all
owner clicks cell -> apply directly + broadcast MSG_STATE

Players 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 an IsInstanceOwner() branch or inside an owner-received message handler.
  • Verify sender identity on every inbound message. A non-owner client shouldn’t spoof a MSG_STATE to 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 (OnPlayerJoined from 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’t let any client mutate board[] from OnCellClicked unless they are the instance owner. That path exists in the code only for the owner’s own clicks.
  • Don’t broadcast every frame. MSG_STATE fires 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.Unreliable for 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() on OnPlayerJoined / OnPlayerLeft / OnInstanceOwnerChange.

Paste TurnBasedGame.cs into the validator. Expect:

  • err 0, warn 0 under World.
  • info: Unity events detected: Start and Game events detected: OnInstanceOwnerChange, OnPlayerJoined, OnPlayerLeft.
  • Switch context to Avatar — the validator flags LocalPlayer.PlayerObject as world-only (several hits).
  • 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; MyMarker returns 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.
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);
}