NetCrafter Relay Tutorial: Turn-Based (Tile Claim)
The clearest example of the host-authoritative request → validate → broadcast pattern in the whole package — a 2-4 player turn-based board game with no third-party assets, no per-frame sync, and no interpolation.
Time: 75–100 minutes
Prerequisites: NetCrafter Relay installed; your own self-hosted coturn + rendezvous instance up and running (see the Server Deployment Guide); Allow downloads over HTTP set to Always allowed in Player Settings (Edit → Project Settings → Player → Other Settings → Configuration — a fresh project defaults to blocking this, which fails the very first connection attempt with InvalidOperationException: Insecure connection not allowed; see Getting Started); Unity's Input System package active (Edit → Project Settings → Player → Active Input Handling set to Input System Package or Both); TextMeshPro essentials imported (Window → TextMeshPro → Import TMP Essentials). Recommended (not required): read the Chat Quick Start first — this tutorial reuses its Login/Lobby flow without re-explaining it from scratch.
What you'll build: A 6×6 grid board for 2-4 players. Each player starts on a corner tile; on your turn you claim one empty tile orthogonally adjacent to a tile you already own; a player with no legal move is skipped; the game ends when nobody can move, and whoever owns the most tiles wins (ties are shown as a tie). Built entirely from Unity UI primitives and TextMeshPro — no external assets at all.
This tutorial exists to teach the host-authoritative pattern in its purest form, stripped of everything that isn't that pattern. There's no continuous position sync (Co-op/Racing/Character all need it; this doesn't), no interpolation, no physics, no third-party asset dependency (unlike VR). A joiner's tile click sends a request; the host validates it against the rules and decides; the host broadcasts the result, and that broadcast — never the click itself — is the only thing that ever changes what's on screen. If you only read one tutorial in this package to understand why NetCrafter Relay is host-relay-only and host-authoritative, make it this one.
00 — Architecture: request → validate → broadcast#
Same transport model as every other tutorial in this package: host-relay-only, star topology. Every peer — host and joiners alike — opens one TURN allocation on your coturn relay and talks through it. Joiners cannot reach each other directly; every message routes through whichever player is hosting, and the host fans results out to everyone via BroadcastToAll. This schema targets 2-4 players, the same scope every other tutorial in this package assumes.
Joiners cannot message each other directly. There is no peer-to-peer path in this architecture — everything routes through whichever player is hosting. A disconnect from the host (~15 seconds of silence, see Part 7) ends every joiner's session with no path back to the Lobby — see Part 7 for exactly what that looks like on screen.
Why the client never trusts itself. When you click a tile, nothing about the board changes on your screen at that moment — not even optimistically. Clicking a tile does exactly one thing: it sends a request (ClaimTile, Part 2) to whoever is hosting. The host checks four things against its own, single authoritative copy of the board:
- Is a round actually running right now?
- Is it this player's turn?
- Is the requested tile actually empty?
- Is the requested tile orthogonally adjacent to a tile this player already owns?
Only if all four pass does the host apply the claim to its own board and broadcast TileClaimed to everyone, including back to whichever client asked. Every client — including the host's own UI for the host's own clicks, see Part 6 — only ever repaints a tile in response to that broadcast arriving, never in response to the click itself. If the request fails any check, the host does nothing at all: no error message, no broadcast, nothing changes anywhere. This is deliberate — see Part 3's note on why a silent ignore is the right call here, not a rejection message.
Why no sync rules in this schema at all. Co-op/Racing/Character all need NetCrafterSyncBinding/NetCrafterSyncManager because something (an avatar, a car) needs to visibly move on every other client's screen continuously, many times a second. A turn-based board has no such thing — it changes in discrete, infrequent steps (a tile claim, a turn change), each of which is naturally a single event. Every schema element here is a message or a Host Action; there is nothing to continuously replicate, so there is nothing to build a sync rule for.
Part 1 — Create the schema#
1.1 Open the NetCrafter Relay window
Tools → Pivec Labs → NetCrafter Relay.
1.2 Create a new schema
Click + Create Definition, enter TurnBasedDemo, click Create. This creates Assets/PivecLabs/NetCrafterRelay/Definitions/TurnBasedDemo/ with all the standard subfolders and a fresh RelaySettings.asset with no working defaults, same as any new schema.
1.3 Set the namespace
Settings → Project Settings: set Namespace to PivecLabs.Generated.TurnBased, and Example Namespace to PivecLabs.NetCrafterRelay.Examples.TurnBasedDemo (this is what lets the generated Host Action code call this tutorial's own scripts — TurnBasedGameState, TurnBasedDemoController — unqualified; see Part 3). Both are schema-scoped, so this cannot collide with Chat/Co-op/Racing/Character/VR's own namespaces even if all six are built in the same project.
No guard-define pattern is needed here, and there's no class-name collision to worry about with any other shipped example — TurnBasedRules, TurnBasedGameState, TurnBasedTile, and TurnBasedDemoController are all distinctly named from every other tutorial's own scripts (CoopGameState, RacingGameState, and so on all compile into the same Assembly-CSharp already).
Part 2 — Define the messages#
Open the Gameplay Messages tab. Create eight messages — kept deliberately small and few, per the architecture in Part 0.
2.1 Login — client tells the host its display name
| Field | Value |
|---|---|
| Message Name | Login |
| Direction | ClientToHost |
| Transport | Reliable |
| Host Handler Name | HandleLogin |
Add one field: displayName (type string).
2.2 PlayerJoined — host announces a player (new or already-known) to someone
| Field | Value |
|---|---|
| Message Name | PlayerJoined |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add two fields: playerId (type int), displayName (type string).
2.3 PlayerLeft — host announces a player disconnected
| Field | Value |
|---|---|
| Message Name | PlayerLeft |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add one field: playerId (type int).
2.4 ClaimTile — client asks the host to claim a tile
| Field | Value |
|---|---|
| Message Name | ClaimTile |
| Direction | ClientToHost |
| Transport | Reliable |
| Host Handler Name | HandleClaimTile |
Add two fields: x (type int), y (type int).
Named ClaimTile, not ClaimTileRequest — the generator always appends Request to a ClientToHost message's own class name (Login → LoginRequest), so naming the message itself ClaimTileRequest would generate a class called ClaimTileRequestRequest. Keep the message name itself free of a trailing "Request" and the generated name reads cleanly.
2.5 TileClaimed — host announces a tile was claimed
| Field | Value |
|---|---|
| Message Name | TileClaimed |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add three fields: x (type int), y (type int), playerId (type int). This one message type covers three different real moments: a live claim during play, a starting-corner claim right after Start, and one re-send per already-claimed tile when a late joiner needs a snapshot (Part 7) — the receiving client never needs to know which case it is; it just applies the tile.
2.6 TurnChanged — host announces whose turn it is now
| Field | Value |
|---|---|
| Message Name | TurnChanged |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add one field: currentTurnPlayerId (type int).
2.7 GameStarted — host announces a round began (or snapshots one already in progress)
| Field | Value |
|---|---|
| Message Name | GameStarted |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add two fields: activePlayersMask (type int), currentTurnPlayerId (type int).
Why a bitmask instead of a list. This schema only ever needs array-like data in one place: "which of the (at most 4) player ids are actually part of this round." With ids fixed at 0-3 (see Part 3), a 4-bit mask — bit N set means player N is active — fits in the one int field every other message here already uses, and this package's own array-field support (isArray on a message field) isn't exercised by any shipped schema, this one included — keeping this tutorial to plain scalar fields only is deliberate, not an oversight.
2.8 GameOver — host announces the round ended
| Field | Value |
|---|---|
| Message Name | GameOver |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add two fields: winnersMask (type int — same bit-per-player-id convention as activePlayersMask, more than one bit set means a tie), endedEarly (type bool — true if the round ended because too few players remain connected, not because anyone actually ran out of legal moves; see Part 7).
Notice there's no "rejected move" message. See Part 3's HandleClaimTile note for why an invalid request is silently ignored instead.
Part 3 — Define the Host Actions & shared scripts#
Open the Host Actions tab. Create two. Both reference two small shared scripts you'll add first — create them now, under Assets/PivecLabs/Examples/TurnBasedDemo/Scripts/.
3.1 TurnBasedRules.cs — pure, connection-free board rules
Every "is this move legal" question in this tutorial goes through one shared, static class with no dependency on networking at all. It's used twice: by the host's own validation (the only copy that ever actually decides anything) and, separately, by every client's purely advisory tile-highlighting (Part 6) — so the two can never quietly disagree about what counts as legal.
Show TurnBasedRules.cs
namespace PivecLabs.NetCrafterRelay.Examples.TurnBasedDemo
{
public static class TurnBasedRules
{
public const int GridSize = 6;
public const int TileCount = GridSize * GridSize;
public static int Index(int x, int y) => y * GridSize + x;
public static bool InBounds(int x, int y) => x >= 0 && x < GridSize && y >= 0 && y < GridSize;
public static int OwnerAt(int[] tileOwners, int x, int y) =>
InBounds(x, y) ? tileOwners[Index(x, y)] : -1;
// playerId doubles as this player's fixed turn-order position, starting corner, AND
// color index -- RelayConnectionManager already hands out LocalUserId 0 (host) through
// 3 (third joiner) in strict join order, so there's nothing left to separately assign.
private static readonly (int x, int y)[] Corners =
{
(0, 0), (GridSize - 1, GridSize - 1), (GridSize - 1, 0), (0, GridSize - 1)
};
public static (int x, int y) CornerFor(int playerId) =>
playerId >= 0 && playerId < Corners.Length ? Corners[playerId] : (0, 0);
public static bool HasAdjacentOwnedTile(int[] tileOwners, int x, int y, int playerId) =>
OwnerAt(tileOwners, x - 1, y) == playerId || OwnerAt(tileOwners, x + 1, y) == playerId ||
OwnerAt(tileOwners, x, y - 1) == playerId || OwnerAt(tileOwners, x, y + 1) == playerId;
// Every active player starts the game already owning their corner tile, so there is no
// separate "first move" special case -- this one check covers every turn, including the
// first.
public static bool IsLegalMove(int[] tileOwners, int x, int y, int playerId) =>
InBounds(x, y) && OwnerAt(tileOwners, x, y) == -1 &&
HasAdjacentOwnedTile(tileOwners, x, y, playerId);
public static bool HasAnyLegalMove(int[] tileOwners, int playerId)
{
for (int y = 0; y < GridSize; y++)
for (int x = 0; x < GridSize; x++)
if (IsLegalMove(tileOwners, x, y, playerId))
return true;
return false;
}
}
}3.2 TurnBasedGameState.cs — the host's authoritative board, shared by discipline not enforcement
A plain static class, not a MonoBehaviour — same pattern as CoopGameState in the Co-op tutorial. Every peer — host and joiners alike — loads the same compiled class, and every peer uses its plain fields (PlayerNames, TileOwners, CurrentTurnPlayerId, and so on) as its own "what do I currently believe the match looks like" store, updated directly from incoming broadcasts. The discipline, not a technical barrier, is that only host-only code paths — this file's own mutating methods, called only from HandleClaimTile's Custom Host Code and from TurnBasedDemoController's own if (relay.IsHost) branches — ever call TryClaimTileAndAdvance, StartGame, SkipIdlePlayer, or HandlePlayerDisconnected. A joiner's own process never calls these; it only ever writes the plain fields directly, mirroring what the host already decided and already broadcast.
Show TurnBasedGameState.cs
using System.Collections.Generic;
namespace PivecLabs.NetCrafterRelay.Examples.TurnBasedDemo
{
// Returned by every TurnBasedGameState method that can change whose turn it is or end the
// game, so both call sites that need this (HandleClaimTile's Custom Host Code, for a joiner's
// move, and TurnBasedDemoController's own host-branch, for the host's own move) broadcast
// and locally-apply the exact same way without duplicating any decision logic.
public struct TileClaimResult
{
public bool claimed;
public bool turnChanged;
public int nextTurnPlayerId;
public bool gameOver;
public bool endedEarly;
public int winnersMask;
}
public static class TurnBasedGameState
{
public static readonly Dictionary<int, string> PlayerNames = new();
public static readonly HashSet<int> ConnectedPlayerIds = new();
public static int ActivePlayersMask;
public static int CurrentTurnPlayerId = -1;
public static bool GameRunning;
public static bool GameOver;
public static bool EndedEarly;
public static int WinnersMask;
public static readonly int[] TileOwners = new int[TurnBasedRules.TileCount];
public static void Reset()
{
PlayerNames.Clear();
ConnectedPlayerIds.Clear();
ActivePlayersMask = 0;
CurrentTurnPlayerId = -1;
GameRunning = false;
GameOver = false;
EndedEarly = false;
WinnersMask = 0;
for (int i = 0; i < TileOwners.Length; i++) TileOwners[i] = -1;
}
public static bool IsActivePlayer(int playerId) =>
playerId >= 0 && playerId < 4 && (ActivePlayersMask & (1 << playerId)) != 0;
// Host-only. Snapshots whichever players are connected RIGHT NOW as this round's fixed
// roster -- up to 4, ascending by id. Re-reads ConnectedPlayerIds fresh every call (not a
// remembered original lineup), so Play Again naturally picks up whoever is still around.
public static List<int> StartGame()
{
for (int i = 0; i < TileOwners.Length; i++) TileOwners[i] = -1;
var active = new List<int>();
foreach (var id in ConnectedPlayerIds)
{
if (active.Count >= 4) break;
active.Add(id);
}
active.Sort();
ActivePlayersMask = 0;
foreach (var id in active)
{
ActivePlayersMask |= 1 << id;
var (x, y) = TurnBasedRules.CornerFor(id);
TileOwners[TurnBasedRules.Index(x, y)] = id;
}
CurrentTurnPlayerId = active.Count > 0 ? active[0] : -1;
GameRunning = active.Count >= 2;
GameOver = false;
EndedEarly = false;
WinnersMask = 0;
return active;
}
public static TileClaimResult TryClaimTileAndAdvance(int x, int y, int playerId)
{
var result = new TileClaimResult();
if (!GameRunning || GameOver) return result;
if (playerId != CurrentTurnPlayerId || !IsActivePlayer(playerId)) return result;
if (!TurnBasedRules.IsLegalMove(TileOwners, x, y, playerId)) return result;
TileOwners[TurnBasedRules.Index(x, y)] = playerId;
result.claimed = true;
ApplyTurnAdvance(playerId, ref result);
return result;
}
// Host-only -- called when the turn timer elapses with no claim made. An idle player is
// treated exactly like a player with no legal move.
public static TileClaimResult SkipIdlePlayer(int playerId)
{
var result = new TileClaimResult();
if (!GameRunning || GameOver || playerId != CurrentTurnPlayerId) return result;
ApplyTurnAdvance(playerId, ref result);
return result;
}
// Host-only. A disconnected player's tiles stay exactly as they are (frozen) -- see
// Part 7's Departure Handling section for why. If it was their turn, the turn moves on
// immediately rather than waiting for the turn timer. If fewer than 2 of the active
// roster are still connected afterward, the round ends with no winner computed.
public static TileClaimResult HandlePlayerDisconnected(int playerId)
{
ConnectedPlayerIds.Remove(playerId);
var result = new TileClaimResult();
if (!GameRunning) return result;
int remainingActiveConnected = 0;
for (int id = 0; id < 4; id++)
if (IsActivePlayer(id) && ConnectedPlayerIds.Contains(id)) remainingActiveConnected++;
if (remainingActiveConnected < 2)
{
GameRunning = false;
GameOver = true;
EndedEarly = true;
WinnersMask = 0;
result.gameOver = true;
result.endedEarly = true;
return result;
}
if (IsActivePlayer(playerId) && playerId == CurrentTurnPlayerId)
ApplyTurnAdvance(playerId, ref result);
return result;
}
private static void ApplyTurnAdvance(int afterPlayerId, ref TileClaimResult result)
{
int next = FindNextTurnPlayerOrNegativeOne(afterPlayerId);
if (next < 0)
{
GameRunning = false;
GameOver = true;
WinnersMask = ComputeWinnersMask();
result.gameOver = true;
result.winnersMask = WinnersMask;
}
else
{
CurrentTurnPlayerId = next;
result.turnChanged = true;
result.nextTurnPlayerId = next;
}
}
// Walks the 4 possible ids starting right after afterPlayerId, wrapping around, picking
// the first CONNECTED active player who still has a legal move. A disconnected active
// player's slot is skipped unconditionally; a connected one with no legal move is skipped
// the same way an idle timeout is. Returns -1 if nobody qualifies -- the round is over.
public static int FindNextTurnPlayerOrNegativeOne(int afterPlayerId)
{
for (int step = 1; step <= 4; step++)
{
int candidate = (afterPlayerId + step) % 4;
if (!IsActivePlayer(candidate)) continue;
if (!ConnectedPlayerIds.Contains(candidate)) continue;
if (TurnBasedRules.HasAnyLegalMove(TileOwners, candidate)) return candidate;
}
return -1;
}
public static int ComputeWinnersMask()
{
var counts = new Dictionary<int, int>();
for (int i = 0; i < TileOwners.Length; i++)
{
int owner = TileOwners[i];
if (owner < 0) continue;
counts.TryGetValue(owner, out var c);
counts[owner] = c + 1;
}
int best = -1;
foreach (var kv in counts)
if (kv.Value > best) best = kv.Value;
int mask = 0;
foreach (var kv in counts)
if (kv.Value == best) mask |= 1 << kv.Key;
return mask;
}
}
}3.3 HandleLogin
| Field | Value |
|---|---|
| Handler Name | HandleLogin |
| Trigger Message | Login |
| Response Event | PlayerJoined |
| Logic Type | Custom |
Custom Host Code:
string displayName = trigger.displayName;
// Snapshot for the new joiner: every already-known player, then (if a round is already
// running or already over) the same GameStarted/GameOver event everyone else already
// received, THEN every already-claimed tile -- GameStarted is this schema's single
// authoritative board-clear point on every peer (see
// TurnBasedDemoController.OnGameStartedReceived), so it must arrive and be applied BEFORE
// the tile replay below, never after, or it would wipe out the very tiles this replay is
// about to (re)send. See Part 7's Late Join section for why this is the whole policy and
// nothing more.
foreach (var kv in TurnBasedGameState.PlayerNames)
{
var existingPlayer = new PlayerJoinedEvent { playerId = kv.Key, displayName = kv.Value };
connection.SendToPeer(senderId, "PlayerJoined", existingPlayer.Serialize(), true);
}
if (TurnBasedGameState.GameOver)
{
var over = new GameOverEvent
{
winnersMask = TurnBasedGameState.WinnersMask,
endedEarly = TurnBasedGameState.EndedEarly
};
connection.SendToPeer(senderId, "GameOver", over.Serialize(), true);
}
else if (TurnBasedGameState.GameRunning)
{
var started = new GameStartedEvent
{
activePlayersMask = TurnBasedGameState.ActivePlayersMask,
currentTurnPlayerId = TurnBasedGameState.CurrentTurnPlayerId
};
connection.SendToPeer(senderId, "GameStarted", started.Serialize(), true);
}
for (int i = 0; i < TurnBasedGameState.TileOwners.Length; i++)
{
int owner = TurnBasedGameState.TileOwners[i];
if (owner < 0) continue;
var existingTile = new TileClaimedEvent
{
x = i % TurnBasedRules.GridSize,
y = i / TurnBasedRules.GridSize,
playerId = owner
};
connection.SendToPeer(senderId, "TileClaimed", existingTile.Serialize(), true);
}
TurnBasedGameState.PlayerNames[senderId] = displayName;
TurnBasedGameState.ConnectedPlayerIds.Add(senderId);
var joined = new PlayerJoinedEvent { playerId = senderId, displayName = displayName };
connection.BroadcastToAll("PlayerJoined", joined.Serialize(), true);
TurnBasedDemoController.Instance?.OnPlayerJoinedReceived(senderId, displayName);Why the direct TurnBasedDemoController.Instance?.OnPlayerJoinedReceived(...) call right after the broadcast? BroadcastToAll never loops back to the sender's own incoming-message pipeline — without this call, the host's own screen would never show a newly-joined player until something else happened to refresh it. Every broadcast this schema's Host Actions send is followed by the matching direct call for exactly this reason.
3.4 HandleClaimTile
| Field | Value |
|---|---|
| Handler Name | HandleClaimTile |
| Trigger Message | ClaimTile |
| Response Event | TileClaimed |
| Logic Type | Custom |
Custom Host Code:
var result = TurnBasedGameState.TryClaimTileAndAdvance(trigger.x, trigger.y, senderId);
if (result.claimed)
{
var claimed = new TileClaimedEvent { x = trigger.x, y = trigger.y, playerId = senderId };
connection.BroadcastToAll("TileClaimed", claimed.Serialize(), true);
TurnBasedDemoController.Instance?.OnTileClaimedReceived(trigger.x, trigger.y, senderId);
if (result.gameOver)
{
var over = new GameOverEvent { winnersMask = result.winnersMask, endedEarly = false };
connection.BroadcastToAll("GameOver", over.Serialize(), true);
TurnBasedDemoController.Instance?.OnGameOverReceived(result.winnersMask, false);
}
else if (result.turnChanged)
{
var turn = new TurnChangedEvent { currentTurnPlayerId = result.nextTurnPlayerId };
connection.BroadcastToAll("TurnChanged", turn.Serialize(), true);
TurnBasedDemoController.Instance?.OnTurnChangedReceived(result.nextTurnPlayerId);
}
}Why no "move rejected" message. TryClaimTileAndAdvance returns claimed = false for every invalid request (wrong turn, occupied tile, not adjacent, no round running) and this code does nothing at all in that case — no broadcast, no reply. This is safe specifically because the client's own board-highlighting (Part 6) already filters which tiles even look clickable using the exact same TurnBasedRules logic the host uses to validate — a legitimate client should essentially never be able to generate an illegal request in the first place, so a silent ignore here is a defense-in-depth backstop, not something a well-behaved client is expected to routinely hit.
Part 4 — Configure Relay Settings#
Open the Relay Settings tab. Relay Host and Rendezvous URL ship blank — fill in your own coturn/rendezvous instance's values (see the Server Deployment Guide) or you'll see required-field warnings the moment you try to Generate. Rendezvous URL accepts a bare host:port.
Part 5 — Generate#
Open the Build Code tab and click Generate All Files. This writes client code (message classes + RelayConnectionManager), host code (HostActionRouter), and config into this schema's own Generated/ folder — a sibling of the hand-authored Messages//HostActions//Prefabs/ folders, never inside them.
Open the Prefab Generator tab and click Create Connection Manager Prefab. This creates RelayConnectionManager.prefab under this schema's own Prefabs/ folder, wired to this schema's own RelaySettings.asset live (not a baked copy — editing Relay Settings later always reaches every scene using this prefab with no recreation step).
Part 6 — Build the scene#
6.1 Create the scene and relay boilerplate
Create a new scene, Assets/PivecLabs/Examples/TurnBasedDemo/TurnBasedDemoScene.unity. Add a Main Camera. Open Scene Assistant and click Add to Existing Scene — this adds, in one click:
- RelayConnectionManager (your Part 5 prefab instance) — starts inactive.
Connect()(called from its ownStart()) defaults to hosting a new session when no join code is pending, so an active instance would start hosting the instant the scene loads, bypassing Login/Lobby entirely. - HostActionRouter — this schema's generated host-logic dispatcher.
- NetCrafterEventDispatcher — base-package, framework-level notifications (
RelayHosting/ConnectionLost/RelayStatus).
Scene Assistant will not add a NetCrafterSyncManager here — correctly, since this schema defines no sync rules at all (Part 0).
Add an EventSystem GameObject with Event System + Input System UI Input Module components (not the legacy Standalone Input Module — this project's Active Input Handling is Input System Package, and the legacy module throws at runtime under that setting).
6.2 Canvas and Login/Lobby panels
Add a Canvas (Screen Space - Overlay, CanvasScaler set to Scale With Screen Size) with a child panel per state: LoginPanel, LobbyPanel, GameplayPanel (with a nested GameOverPanel inside it). Only one of the three top-level panels is ever active at once — see ShowLogin/ShowLobby/ShowGameplay in Part 6.5.
| Panel | Contents |
|---|---|
| LoginPanel | Title text, DisplayNameInput (TMP Input Field), ContinueButton |
| LobbyPanel | JoinCodeInput, JoinCodeDisplay, HostButton, JoinButton, StatusText, LobbyRosterText, StartGameButton (host-only, see below) |
| GameplayPanel | TurnText, RosterScoreText, Grid (36 tiles, Part 6.3), nested GameOverPanel |
| GameOverPanel | GameOverText, PlayAgainButton (host-only) |
Unlike Chat/Co-op/Character/Racing, there's an explicit Lobby gate here: connecting does not fall straight into Gameplay. Everyone lands in the Lobby and stays there — seeing who else has joined via LobbyRosterText — until the host clicks Start Game, which is the only thing that transitions anyone to Gameplay (see Part 7 for why).
6.3 The tile prefab and the grid
Create a TurnBasedTile prefab: an Image (the tile's background/color) + a Button + a TurnBasedTile component (Part 6.4), with the Button's own On Click () wired, on the prefab itself, to its own OnClicked() method. Wiring it once at the prefab level — not per instance — means every one of the 36 tiles you place inherit that wiring for free.
Under GameplayPanel, add a Grid GameObject with a GridLayoutGroup (6 columns, fixed column count). Instantiate the tile prefab 36 times as its children, and for each one set its TurnBasedTile.x/.y fields directly (index = y * 6 + x) — never computed from the object's own position in the hierarchy, so the grid's visual layout and its logical coordinates can never silently drift apart.
6.4 TurnBasedTile.cs
Show TurnBasedTile.cs
using UnityEngine;
using UnityEngine.UI;
namespace PivecLabs.NetCrafterRelay.Examples.TurnBasedDemo
{
public class TurnBasedTile : MonoBehaviour
{
public int x;
public int y;
public Button button;
public Image background;
// -1 = unclaimed. Purely a rendering concern -- this is this CLIENT's own mirror of what
// the host has broadcast so far, rebuilt from TileClaimed events, never written to from
// a local click.
public int ownerPlayerId = -1;
public static readonly Color UnclaimedColor = new Color(0.82f, 0.82f, 0.82f, 1f);
public static readonly Color[] PlayerColors =
{
new Color(0.85f, 0.25f, 0.25f, 1f), // player 0 (host) -- red
new Color(0.25f, 0.45f, 0.85f, 1f), // player 1 -- blue
new Color(0.30f, 0.70f, 0.35f, 1f), // player 2 -- green
new Color(0.90f, 0.75f, 0.20f, 1f), // player 3 -- yellow
};
public static Color ColorForPlayer(int playerId) =>
playerId >= 0 && playerId < PlayerColors.Length ? PlayerColors[playerId] : UnclaimedColor;
public void SetOwner(int playerId)
{
ownerPlayerId = playerId;
if (background != null)
background.color = playerId < 0 ? UnclaimedColor : ColorForPlayer(playerId);
}
// Advisory only -- the host re-validates this exact same check independently
// (TurnBasedRules.IsLegalMove) and silently ignores anything illegal, so a stale value
// here can only make a legal tile look unclickable or waste a click, never let an
// illegal claim through.
public void SetInteractable(bool interactable)
{
if (button != null) button.interactable = interactable;
}
// Wired to this prefab's own Button -> On Click () -- see Part 6.3.
public void OnClicked()
{
TurnBasedDemoController.Instance?.RequestClaimTile(x, y);
}
}
}6.5 TurnBasedDemoController.cs
The main controller — Login/Lobby/Gameplay flow, Start Game / Play Again, claiming a tile, the optional turn timer, departure handling, and every incoming-broadcast handler. Wire its public fields in the Inspector to the panels and controls you built above, and wire every button's On Click () to the matching method the same way (ContinueButton → OnLoginClicked, HostButton → OnHostClicked, JoinButton → OnJoinClicked, StartGameButton → OnStartGameClicked, PlayAgainButton → OnPlayAgainClicked).
Show TurnBasedDemoController.cs
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using PivecLabs.Generated.TurnBased;
using PivecLabs.NetCrafterRelay;
namespace PivecLabs.NetCrafterRelay.Examples.TurnBasedDemo
{
public class TurnBasedDemoController : MonoBehaviour
{
public static TurnBasedDemoController Instance { get; private set; }
public GameObject relayConnectionManagerObject;
public GameObject loginPanel;
public TMP_InputField displayNameInput;
public GameObject lobbyPanel;
public TMP_InputField joinCodeInput;
public TMP_Text joinCodeDisplay;
public TMP_Text statusText;
public TMP_Text lobbyRosterText;
public GameObject startGameButtonObject;
public Button startGameButton;
public GameObject gameplayPanel;
public TMP_Text turnText;
public TMP_Text rosterScoreText;
public TurnBasedTile[] tiles;
public GameObject gameOverPanel;
public TMP_Text gameOverText;
public GameObject playAgainButtonObject;
public Button playAgainButton;
private RelayConnectionManager relay;
private string displayName = "Player";
// Bumped on every real turn change so the turn-timer coroutine can tell "a move already
// happened" apart from "the turn landed back on the same player after a full lap" --
// comparing CurrentTurnPlayerId alone can't distinguish those in a 2-player game.
private int _turnVersion;
private Coroutine _turnTimerCoroutine;
private const float TurnTimeoutSeconds = 30f;
private static readonly string[] ColorNames = { "Red", "Blue", "Green", "Yellow" };
private void Awake()
{
Instance = this;
TurnBasedGameState.Reset();
relay = relayConnectionManagerObject.GetComponent<RelayConnectionManager>();
if (relay == null)
{
Debug.LogError($"[TurnBasedDemo] '{relayConnectionManagerObject.name}' has no " +
$"{typeof(RelayConnectionManager).FullName} component attached.");
}
relayConnectionManagerObject.SetActive(false);
ShowLogin();
}
private void Start()
{
if (NetCrafterEventDispatcher.Instance == null)
{
Debug.LogError("[TurnBasedDemo] No NetCrafterEventDispatcher in the scene.");
return;
}
NetCrafterEventDispatcher.Instance.Subscribe("RelayHosting", OnRelayHosting);
NetCrafterEventDispatcher.Instance.Subscribe("ConnectionLost", OnConnectionLost);
NetCrafterEventDispatcher.Instance.Subscribe("RelayStatus", OnRelayStatus);
}
private void ShowLogin()
{
loginPanel.SetActive(true);
lobbyPanel.SetActive(false);
gameplayPanel.SetActive(false);
if (gameOverPanel != null) gameOverPanel.SetActive(false);
}
private void ShowLobby()
{
loginPanel.SetActive(false);
lobbyPanel.SetActive(true);
gameplayPanel.SetActive(false);
if (gameOverPanel != null) gameOverPanel.SetActive(false);
}
private void ShowGameplay()
{
loginPanel.SetActive(false);
lobbyPanel.SetActive(false);
gameplayPanel.SetActive(true);
if (gameOverPanel != null) gameOverPanel.SetActive(false);
}
public void OnLoginClicked()
{
displayName = string.IsNullOrEmpty(displayNameInput.text) ? "Player" : displayNameInput.text;
ShowLobby();
}
public void OnHostClicked()
{
if (relay == null) return;
if (statusText != null) statusText.text = "";
relayConnectionManagerObject.SetActive(true);
}
public void OnJoinClicked()
{
if (relay == null) return;
string code = joinCodeInput.text.Trim();
if (string.IsNullOrEmpty(code)) return;
if (statusText != null) statusText.text = "";
relay.UpdateHost(code);
relayConnectionManagerObject.SetActive(true);
StartCoroutine(SendLoginWhenConnected());
}
private void OnRelayHosting(object joinCodeObj)
{
joinCodeDisplay.text = "Join code: " + (string)joinCodeObj;
StartCoroutine(SendLoginWhenConnected());
}
private void OnConnectionLost(object reason)
{
if (statusText != null) statusText.text = NetCrafterConnectionStatus.ToPlayerMessage((string)reason);
}
private void OnRelayStatus(object message)
{
if (statusText != null) statusText.text = (string)message;
}
private const float HostLobbyDisplaySeconds = 4f;
private const float ConnectTimeoutSeconds = 60f;
private IEnumerator SendLoginWhenConnected()
{
float deadline = Time.realtimeSinceStartup + ConnectTimeoutSeconds;
while (!relay.IsConnected && Time.realtimeSinceStartup < deadline) yield return null;
if (!relay.IsConnected)
{
Debug.LogError($"[TurnBasedDemo] Failed to connect within {ConnectTimeoutSeconds}s.");
yield break;
}
if (relay.IsHost)
{
TurnBasedGameState.PlayerNames[relay.LocalUserId] = displayName;
TurnBasedGameState.ConnectedPlayerIds.Add(relay.LocalUserId);
relay.OnPeerDisconnected += OnHostDetectedPeerDisconnected;
yield return new WaitForSeconds(HostLobbyDisplaySeconds);
}
else
{
relay.OnPeerDisconnected += OnJoinerDetectedHostDisconnected;
new LoginRequest { displayName = displayName }.Send(relay);
}
RefreshLobbyRoster();
RefreshStartButtonState();
ShowLobby();
}
public void OnStartGameClicked()
{
if (relay == null || !relay.IsHost) return;
if (TurnBasedGameState.ConnectedPlayerIds.Count < 2) return;
var active = TurnBasedGameState.StartGame();
// GameStarted broadcasts BEFORE the corner TileClaimed events, not after -- see
// OnGameStartedReceived's own comment: GameStarted is this schema's single
// authoritative board-clear point on every peer, so anything it's meant to clear
// must still be clearable when it arrives, not already overwritten by claims that
// got there first.
var started = new GameStartedEvent
{
activePlayersMask = TurnBasedGameState.ActivePlayersMask,
currentTurnPlayerId = TurnBasedGameState.CurrentTurnPlayerId
};
relay.BroadcastToAll("GameStarted", started.Serialize(), true);
OnGameStartedReceived(started.activePlayersMask, started.currentTurnPlayerId);
foreach (var id in active)
{
var (x, y) = TurnBasedRules.CornerFor(id);
var claimed = new TileClaimedEvent { x = x, y = y, playerId = id };
relay.BroadcastToAll("TileClaimed", claimed.Serialize(), true);
OnTileClaimedReceived(x, y, id);
}
}
public void OnPlayAgainClicked() => OnStartGameClicked();
public void RequestClaimTile(int x, int y)
{
if (relay == null) return;
if (relay.IsHost)
{
var result = TurnBasedGameState.TryClaimTileAndAdvance(x, y, relay.LocalUserId);
if (!result.claimed) return;
var claimed = new TileClaimedEvent { x = x, y = y, playerId = relay.LocalUserId };
relay.BroadcastToAll("TileClaimed", claimed.Serialize(), true);
OnTileClaimedReceived(x, y, relay.LocalUserId);
if (result.gameOver)
{
var over = new GameOverEvent { winnersMask = result.winnersMask, endedEarly = false };
relay.BroadcastToAll("GameOver", over.Serialize(), true);
OnGameOverReceived(result.winnersMask, false);
}
else if (result.turnChanged)
{
var turn = new TurnChangedEvent { currentTurnPlayerId = result.nextTurnPlayerId };
relay.BroadcastToAll("TurnChanged", turn.Serialize(), true);
OnTurnChangedReceived(result.nextTurnPlayerId);
}
}
else
{
new ClaimTileRequest { x = x, y = y }.Send(relay);
}
}
private void StartTurnTimerIfHost()
{
if (relay == null || !relay.IsHost) return;
if (_turnTimerCoroutine != null) StopCoroutine(_turnTimerCoroutine);
_turnTimerCoroutine = StartCoroutine(TurnTimerCoroutine(_turnVersion, TurnBasedGameState.CurrentTurnPlayerId));
}
private IEnumerator TurnTimerCoroutine(int versionAtStart, int playerIdAtStart)
{
yield return new WaitForSeconds(TurnTimeoutSeconds);
if (versionAtStart != _turnVersion) yield break;
if (!TurnBasedGameState.GameRunning || TurnBasedGameState.GameOver) yield break;
var result = TurnBasedGameState.SkipIdlePlayer(playerIdAtStart);
if (result.gameOver)
{
var over = new GameOverEvent { winnersMask = result.winnersMask, endedEarly = false };
relay.BroadcastToAll("GameOver", over.Serialize(), true);
OnGameOverReceived(result.winnersMask, false);
}
else if (result.turnChanged)
{
var turn = new TurnChangedEvent { currentTurnPlayerId = result.nextTurnPlayerId };
relay.BroadcastToAll("TurnChanged", turn.Serialize(), true);
OnTurnChangedReceived(result.nextTurnPlayerId);
}
}
private void OnHostDetectedPeerDisconnected(int peerId)
{
var left = new PlayerLeftEvent { playerId = peerId };
relay.BroadcastToAll("PlayerLeft", left.Serialize(), true);
OnPlayerLeftReceived(peerId);
var result = TurnBasedGameState.HandlePlayerDisconnected(peerId);
if (result.gameOver)
{
var over = new GameOverEvent { winnersMask = result.winnersMask, endedEarly = result.endedEarly };
relay.BroadcastToAll("GameOver", over.Serialize(), true);
OnGameOverReceived(result.winnersMask, result.endedEarly);
}
else if (result.turnChanged)
{
var turn = new TurnChangedEvent { currentTurnPlayerId = result.nextTurnPlayerId };
relay.BroadcastToAll("TurnChanged", turn.Serialize(), true);
OnTurnChangedReceived(result.nextTurnPlayerId);
}
}
private void OnJoinerDetectedHostDisconnected(int peerId)
{
OnPlayerLeftReceived(peerId);
if (statusText != null) statusText.text = "Host disconnected — connection lost.";
}
public void OnPlayerJoinedReceived(int playerId, string joinedDisplayName)
{
TurnBasedGameState.PlayerNames[playerId] = joinedDisplayName;
RefreshLobbyRoster();
RefreshRosterScoreText();
RefreshStartButtonState();
}
public void OnPlayerJoinedBroadcastReceived()
{
var ev = (PlayerJoinedEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("PlayerJoined");
OnPlayerJoinedReceived(ev.playerId, ev.displayName);
}
public void OnPlayerLeftReceived(int playerId)
{
TurnBasedGameState.PlayerNames.Remove(playerId);
TurnBasedGameState.ConnectedPlayerIds.Remove(playerId);
RefreshLobbyRoster();
RefreshRosterScoreText();
RefreshStartButtonState();
}
public void OnPlayerLeftBroadcastReceived()
{
var ev = (PlayerLeftEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("PlayerLeft");
OnPlayerLeftReceived(ev.playerId);
}
public void OnTileClaimedReceived(int x, int y, int playerId)
{
TurnBasedGameState.TileOwners[TurnBasedRules.Index(x, y)] = playerId;
int idx = TurnBasedRules.Index(x, y);
if (tiles != null && idx >= 0 && idx < tiles.Length && tiles[idx] != null)
tiles[idx].SetOwner(playerId);
RefreshRosterScoreText();
RefreshBoardInteractable();
}
public void OnTileClaimedBroadcastReceived()
{
var ev = (TileClaimedEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("TileClaimed");
OnTileClaimedReceived(ev.x, ev.y, ev.playerId);
}
public void OnTurnChangedReceived(int currentTurnPlayerId)
{
TurnBasedGameState.CurrentTurnPlayerId = currentTurnPlayerId;
_turnVersion++;
StartTurnTimerIfHost();
RefreshTurnText();
RefreshBoardInteractable();
}
public void OnTurnChangedBroadcastReceived()
{
var ev = (TurnChangedEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("TurnChanged");
OnTurnChangedReceived(ev.currentTurnPlayerId);
}
// Fires both for a genuine "the host just started the round" broadcast AND for the
// snapshot HandleLogin re-sends to a player who joined mid-round -- either way, the only
// correct reaction is the same, so no special-casing is needed here for which case it is.
//
// This is also this schema's single authoritative board-clear point, on every peer
// (host included): without it, a new round only ever overwrote the 2-4 starting
// corners, leaving every tile claimed during a PREVIOUS round still showing its old
// owner. Both OnStartGameClicked and HandleLogin send GameStarted before any
// TileClaimed for the new/current round specifically so this reset can never wipe out
// a claim that already arrived.
public void OnGameStartedReceived(int activePlayersMask, int currentTurnPlayerId)
{
for (int i = 0; i < TurnBasedGameState.TileOwners.Length; i++)
TurnBasedGameState.TileOwners[i] = -1;
if (tiles != null)
foreach (var tile in tiles)
if (tile != null) tile.SetOwner(-1);
TurnBasedGameState.ActivePlayersMask = activePlayersMask;
TurnBasedGameState.CurrentTurnPlayerId = currentTurnPlayerId;
TurnBasedGameState.GameRunning = true;
TurnBasedGameState.GameOver = false;
_turnVersion++;
StartTurnTimerIfHost();
ShowGameplay();
RefreshTurnText();
RefreshRosterScoreText();
RefreshBoardInteractable();
}
public void OnGameStartedBroadcastReceived()
{
var ev = (GameStartedEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("GameStarted");
OnGameStartedReceived(ev.activePlayersMask, ev.currentTurnPlayerId);
}
public void OnGameOverReceived(int winnersMask, bool endedEarly)
{
TurnBasedGameState.GameRunning = false;
TurnBasedGameState.GameOver = true;
TurnBasedGameState.WinnersMask = winnersMask;
TurnBasedGameState.EndedEarly = endedEarly;
_turnVersion++;
if (gameOverPanel != null) gameOverPanel.SetActive(true);
if (playAgainButtonObject != null)
playAgainButtonObject.SetActive(relay != null && relay.IsHost);
RefreshGameOverText();
RefreshBoardInteractable();
}
public void OnGameOverBroadcastReceived()
{
var ev = (GameOverEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("GameOver");
OnGameOverReceived(ev.winnersMask, ev.endedEarly);
}
private void RefreshLobbyRoster()
{
if (lobbyRosterText == null) return;
var sb = new StringBuilder();
foreach (var kv in TurnBasedGameState.PlayerNames) sb.AppendLine(kv.Value);
lobbyRosterText.text = sb.ToString();
}
private void RefreshStartButtonState()
{
bool isHost = relay != null && relay.IsHost;
if (startGameButtonObject != null) startGameButtonObject.SetActive(isHost);
if (startGameButton != null) startGameButton.interactable = TurnBasedGameState.ConnectedPlayerIds.Count >= 2;
}
private void RefreshTurnText()
{
if (turnText == null) return;
int current = TurnBasedGameState.CurrentTurnPlayerId;
string name = TurnBasedGameState.PlayerNames.TryGetValue(current, out var n) ? n : $"Player {current}";
string color = current >= 0 && current < ColorNames.Length ? ColorNames[current] : "?";
bool isMe = relay != null && current == relay.LocalUserId;
bool spectating = relay != null && TurnBasedGameState.GameRunning &&
!TurnBasedGameState.IsActivePlayer(relay.LocalUserId);
string whoseTurn = isMe ? "Your turn" : $"{name}'s turn ({color})";
turnText.text = spectating ? $"Spectating — {whoseTurn}" : whoseTurn;
}
private void RefreshRosterScoreText()
{
if (rosterScoreText == null) return;
var counts = new int[4];
foreach (var owner in TurnBasedGameState.TileOwners)
if (owner >= 0 && owner < 4) counts[owner]++;
var sb = new StringBuilder();
foreach (var kv in TurnBasedGameState.PlayerNames)
{
int id = kv.Key;
string suffix = id >= 0 && id < 4 ? $" ({ColorNames[id]}): {counts[id]}" : "";
sb.AppendLine($"{kv.Value}{suffix}");
}
rosterScoreText.text = sb.ToString();
}
private void RefreshGameOverText()
{
if (gameOverText == null) return;
if (TurnBasedGameState.EndedEarly)
{
gameOverText.text = "Game ended — not enough players remaining.";
return;
}
var winners = new List<string>();
for (int id = 0; id < 4; id++)
{
if ((TurnBasedGameState.WinnersMask & (1 << id)) == 0) continue;
string name = TurnBasedGameState.PlayerNames.TryGetValue(id, out var n) ? n : $"Player {id}";
winners.Add(name);
}
if (winners.Count == 0) gameOverText.text = "Game over.";
else if (winners.Count == 1) gameOverText.text = $"{winners[0]} wins!";
else gameOverText.text = "Tie between " + string.Join(" and ", winners) + "!";
}
private void RefreshBoardInteractable()
{
if (tiles == null || relay == null) return;
bool myTurn = TurnBasedGameState.GameRunning && !TurnBasedGameState.GameOver &&
TurnBasedGameState.CurrentTurnPlayerId == relay.LocalUserId;
foreach (var tile in tiles)
{
if (tile == null) continue;
bool legal = myTurn && TurnBasedRules.IsLegalMove(TurnBasedGameState.TileOwners, tile.x, tile.y, relay.LocalUserId);
tile.SetInteractable(legal);
}
}
public bool IsConnectedForAutomation => relay != null && relay.IsConnected;
}
}6.6 Wire the Scene Event Router
Add a SceneEventRouter GameObject with a NetCrafterSceneEventRouter component. Open the Scene Events tab and add six bindings, each wired to the matching controller method's On Received:
| Event Name | On Received → |
|---|---|
PlayerJoined | OnPlayerJoinedBroadcastReceived |
PlayerLeft | OnPlayerLeftBroadcastReceived |
TileClaimed | OnTileClaimedBroadcastReceived |
TurnChanged | OnTurnChangedBroadcastReceived |
GameStarted | OnGameStartedBroadcastReceived |
GameOver | OnGameOverBroadcastReceived |
Finally, select the TurnBasedDemoController GameObject and wire every public field in the Inspector to the panel/control you built for it (see the field names in Part 6.5 and the table in Part 6.2).
Part 7 — Late join & departure handling#
7.1 Late join policy: the Lobby stays open, late joiners spectate
The Lobby never rejects a connection — HandleLogin (Part 3.3) always succeeds, always assigns the joiner's id, and always replays the full current snapshot (every known player, every claimed tile, and if a round is running or already over, the matching GameStarted/GameOver event too). What changes is whether that player ends up active: TurnBasedGameState.StartGame() snapshots whoever is connected at the moment Start Game is clicked (up to 4, ascending by id) as the round's fixed roster. Anyone who connects after that — or beyond the first 4 — receives the same snapshot and sees the same board, but is never part of activePlayersMask: their turn never comes up, and RequestClaimTile would just be ignored by the host if they tried (Part 3.4). RefreshTurnText shows "Spectating" for exactly this case.
This was the simplest policy that's still robust: it needs no connection-layer rejection logic at all (the relay has no concept of "game started" to reject against), and it never has to re-open an already-started board to make room for a newcomer — their only option is to wait for the host's next Play Again.
7.2 A joiner leaves or times out
The host detects this the same way every tutorial in this package does: ~15 seconds of silence (RelayConnectionManager's own PeerTimeoutSeconds) fires OnPeerDisconnected. OnHostDetectedPeerDisconnected broadcasts PlayerLeft, then calls TurnBasedGameState.HandlePlayerDisconnected, which:
- Leaves their claimed tiles exactly as they are — frozen, not freed back to unclaimed. This was chosen over freeing them because re-opening already-settled territory mid-game would let remaining players fight over ground that was already decided, which felt like the wrong kind of "comeback mechanic" for a tutorial about a simple, predictable ruleset.
- If it was their turn, hands the turn to the next eligible player immediately — not after waiting out the turn timer on someone already confirmed gone.
- If fewer than 2 of the active roster are still connected afterward, ends the round immediately (
GameOverwithendedEarly = true) with no winner computed — "Game ended — not enough players remaining."
7.3 The host leaves
A joiner's only peer is always the host (always id 0), so OnPeerDisconnected fires for a joiner with id 0. OnJoinerDetectedHostDisconnected shows "Host disconnected — connection lost." and nothing else — there is genuinely no path back to the Lobby from here. This isn't a missing feature: a joiner never relays to other joiners (host-relayed star, Part 0), and with the host gone there is nobody left to relay through regardless. The only recovery is for someone to host a brand new session and for everyone to rejoin with a new code.
7.4 The turn timer (optional feature)
This tutorial adds a host-side 30-second turn timer (TurnTimeoutSeconds) that auto-skips an idle player — exactly the kind of "only if it stays simple" extra the core rules don't require. It restarts on every real turn change and is invalidated by a monotonically-increasing _turnVersion counter rather than comparing player ids, specifically because a 2-player game can hand the turn straight back to the same player after a single skip, which a naive "is it still the same player's turn" check would misread as "nothing happened yet."
Part 8 — Run it#
This game works entirely with keyboard and mouse — no headset, no special input setup. Build one standalone player and run a second instance from the Editor (or two Editor instances via ParrelSync/a second project copy, the same multiplayer-testing approach every other tutorial in this package uses) — one Host, one Join with the displayed code. Host clicks Start Game once at least 2 players have joined; everyone sees the board, starting corners, and whose turn it is. Click an empty tile orthogonally adjacent to one you own on your turn; watch the host's own click and a joiner's click behave identically from every other client's point of view, both arriving as a TileClaimed broadcast rather than an instant local change.
Common mistakes#
App hosts a session immediately on Play. Same cause and fix as every other tutorial: RelayConnectionManager's GameObject (Part 6.1) was left active. Scene Assistant creates it already disabled; if you built it by hand instead, TurnBasedDemoController.Awake() forces it inactive defensively regardless.
NullReferenceException in SendLoginWhenConnected() the moment you click Host Game (or Join Game). Every schema's generated connection-manager class is named RelayConnectionManager — identical short name, differing only by namespace — so a GameObject accidentally copied/reused from a different schema's scene carries the wrong schema's typed component with no visual way to tell in the Inspector. TurnBasedDemoController.Awake() logs a specific Console error identifying this exact cause. Fix: delete the GameObject and re-add it via Scene Assistant.
A tile the client's own highlighting says is legal gets silently ignored. This means the client's and host's copies of TurnBasedRules/the board have drifted — most likely a missed TileClaimed broadcast during the exact snapshot-replay window right after Login. Double-check HandleLogin's roster/tile replay loop (Part 3.3) is wired exactly as shown, including SendToPeer (not BroadcastToAll) for the replay.
Clicking a tile does nothing at all, ever, not even for the host. Check the tile prefab's own Button → On Click () is wired to TurnBasedTile.OnClicked (Part 6.3) — this must be wired on the prefab, not per-instance; if you instantiated tiles without ever wiring the prefab itself, none of the 36 copies will have it either.
A late joiner never sees the board, or sees it but can never take a turn. The first means HandleLogin's GameStarted/GameOver re-send (Part 3.3) is missing or guarded wrong — double check the if (GameOver) ... else if (GameRunning) order exactly. The second is very likely correct behavior, not a bug — see Part 7.1: anyone who joins after Start Game was clicked is a spectator for that round by design.
NetCrafter Relay — Pivec Labs
What's Next#
You've now seen the host-authoritative request → validate → broadcast pattern in its simplest, most complete form — no continuous sync, no third-party assets, nothing to distract from the core idea. From here:
- Try adding a simple win-condition variant (first to a tile count, instead of most tiles when the board locks up) — it only touches
TurnBasedGameState.ApplyTurnAdvance/ComputeWinnersMask. - The Co-op tutorial shows the same host-authoritative idea applied to continuous, real-time gameplay instead of discrete turns.
- The Racing tutorial shows host-authoritative lap/checkpoint validation layered on top of per-frame position sync.