Turn-Based Multiplayer: Tile Claim
Build a genuinely playable 2-4 player turn-based game where the server decides everything — the purest demonstration of the pattern that makes SmartFoxServer 3 (and NetCrafter Pro) the professional choice over peer-relay solutions.
Prerequisites¶
Before starting this tutorial you need:
What it is: Already imported and working — you've completed at least one earlier tutorial (Chat or Character).
Namespace for this project: TurnBased
What it is: Running locally — same setup as the earlier tutorials.
New zone: TurnBasedZone on port 9977
What it is: Same build tools used in every other NetCrafter tutorial.
Already detected by NetCrafter if an earlier tutorial is working.
What it is: This tutorial uses only plain Unity UI, TextMeshPro, and the built-in Input System module.
No third-party packages, no 3D models, no animation.
Add the compile guard: NETCRAFTER_TURNBASED_EXAMPLE to Scripting Define Symbols (Edit → Project Settings → Player → Other Settings). You'll add a second define, NETCRAFTER_TURNBASED_GENERATED, after Part B2.
About the Examples folder
Assets/PivecLabs/Examples/TurnBased/ ships with a prebuilt reference scene (TurnBasedDemoScene.unity) for comparison once you're done — it is not a shortcut. Build your own scene from scratch by following the steps below; that's what actually teaches you the server-authoritative pattern this tutorial is about.
Known issue: the shipped reference scene's SmartFoxConnectionManager joins a room named TurnBasedRoom, but its generated zone config (TurnBasedZone.zone.xml) defines the room as GameRoom — a naming mismatch, so the prebuilt scene's room join will fail as shipped. Give your own room a consistent name across the Config Generator and your connection code and you won't hit this.
What You Will Build¶
Tile Claim is a small but genuinely playable 2-4 player game on a 6x6 grid:
- Each player starts on a different corner tile.
- Players take turns in a fixed order (the order they joined). On your turn, claim one empty tile that is orthogonally adjacent to a tile you already own.
- A player with no legal move is skipped automatically.
- The game ends when nobody can move or the board is full. Most tiles wins — ties are handled explicitly, not ignored.
- An optional 30-second server-side turn timer auto-skips an idle player, so one AFK player can't stall the whole game.
It's deliberately simple — the point of this tutorial isn't the game design, it's the networking pattern underneath it, which is directly reusable for any turn-based game: card games, board games, strategy games, anything where "whose turn is it, and was that move legal?" matters more than frame-by-frame movement.
Why Server-Authoritative?¶
Every other NetCrafter tutorial (Character, Racing, VR) still has the server broker messages between clients, but the clients themselves hold meaningful local state — a position, a velocity, an animation state — that the server mostly relays rather than originates. Tile Claim is different, and deliberately so: the client holds no game state at all.
The flow, every single turn
- 1. Client sends a request —
ClaimTile(x, y). This is a question, not a command: "may I claim this tile?" The client does not change its own board display yet. - 2. Server validates — is a game running? Is it this player's turn? Is the tile empty? Is it adjacent to a tile this player owns? All four checks happen in
TurnBasedExtension.java, never on a client. - 3. Server decides and broadcasts — if valid, the server updates its own
board[][]array (the only copy of the board that matters) and broadcastsTileClaimedto everyone. If invalid, it sendsRejectedMoveback to the sender only — nobody else even learns the attempt happened. - 4. Clients render —
TurnBasedGameBoard.cspaints whatever the broadcast says. It never decides anything; it only displays.
This matters because the host's client is just as untrusted as anyone else's. There is no special "host" code path in TurnBasedExtension.java — the room creator's own ClaimTile requests go through HandleClaimTile exactly like every other player's. "Host" exists only as a UI label (who sees the Start/Play Again button); it carries zero extra authority server-side beyond that one specific permission check.
Compare this to a peer-relay architecture, where whoever "owns" an object's state is trusted to report it truthfully, and keeping two or more peers' local copies in agreement after a dropped packet or a late joiner is the integrator's problem to solve by hand, every time. SFS3's extension model makes "the server is the only source of truth" the default shape of the code, not a discipline you have to maintain by convention.
Schema Setup¶
In the NetCrafter window, create a new schema named TurnBased. Set Settings → Project Settings → Namespace to TurnBased.
SmartFox Settings tab
| Field | Value |
|---|---|
| Host | 127.0.0.1 |
| Zone Name | TurnBasedZone |
| Extension Name | TurnBased |
| Extension Class | com.piveclabs.turnbased.TurnBasedExtension |
| TCP Port | 9977 |
| Enable MMO | ☐ unchecked |
userMaxLimboSeconds (50s default) unless the app calls the native MMO position API — which this game, like most turn-based games, never does. With MMO off, TurnBasedRoom is a plain SFSRoom and players stay exactly as long as they're connected.Messages tab — ClientToServer
| Message | Fields | Server Handler Name |
|---|---|---|
StartGame | (none) | HandleStartGame |
ClaimTile | x (int), y (int) | HandleClaimTile |
Messages tab — ServerToClient (leave Server Handler Name blank on all of these)
| Message | Fields |
|---|---|
PlayerJoined | playerId (int), playerName (string), colorIndex (int), playerCount (int), hostPlayerId (int) |
PlayerLeft | playerId (int) |
GameStarted | playerOrder (int[]), playerColors (int[]), playerNames (string[]), firstPlayerId (int) |
TileClaimed | playerId (int), x (int), y (int), nextPlayerId (int), scores (int[]) |
TurnSkipped | playerId (int), nextPlayerId (int) |
RejectedMove | reason (string) |
GameOver | winnerPlayerId (int), scores (int[]), isTie (bool) |
StateSnapshot | boardData (int[]), currentPlayerId (int), scores (int[]), playerOrder (int[]), playerNames (string[]), playerColors (int[]), gameRunning (bool) |
HostLeft | reason (string) |
Two fields you won't find in the original design brief
hostPlayerId on PlayerJoined: every client — old and new — needs to know who the room creator is, both to decide whether to show an enabled Start button in the lobby and to recognise a later HostLeft correctly. PlayerJoined is the only message guaranteed to reach every client at the moment they need this, so it carries the current host id on every join, not just the first one.
gameRunning on StateSnapshot: a late joiner needs to tell "game in progress, I'm spectating" apart from "lobby, nobody's started yet" before it can decide which panel to show.
Why HostLeft isn't called ConnectionLost
SmartFoxConnectionManager already dispatches a "ConnectionLost" event — as a plain string reason — whenever this client's own socket drops. Naming the server-sent "someone else's host session ended" event the same thing would mean the same dispatch key sometimes carries a string and sometimes a typed event object, depending on which happened. HostLeft is its own message specifically to avoid that collision.
Server Actions tab
| Handler Name | Trigger Message | Response Event |
|---|---|---|
HandleStartGame | StartGame | GameStarted |
HandleClaimTile | ClaimTile | TileClaimed |
RejectedMove is sent from inside HandleClaimTile's own logic, to the sender only, when validation fails — it's not the action's declared Response Event, which covers only the success path. You'll write this branch by hand in Part B3; it's beyond what the generated handler scaffold does on its own.
Generate Code¶
Click Generate Client Code. This creates SmartFoxConnectionManager.cs and one event/request class per message under Definitions/TurnBased/Generated/Client/.
Add the second define symbol now: NETCRAFTER_TURNBASED_GENERATED (alongside NETCRAFTER_TURNBASED_EXAMPLE from Part A1). This gates every reference to the generated types in the example scripts, so the project still compiles cleanly even before you've generated anything.
TurnBased schema only — running it unscoped can delete the Generated/ folders of your other schemas (Chat, Racing, Character, GC2, VR) along with this one.Click Build JAR (mvn package) (or Deploy to SFS3 directly — see Part B8) once you've written the extension in Part B3. Doing it now would just build the empty generated scaffold.
Java Extension¶
TurnBasedExtension.java is where this tutorial's whole point lives: every piece of game state — the board, whose turn it is, everyone's score — exists as a field on this one class, and nowhere else. The generated request handlers (HandleStartGame, HandleClaimTile) are separate classes purely because that's how SFS3's request routing works, not because they hold any state of their own.
The userRooms map — and why it exists
SFS3's user.getLastJoinedRoom() looks like it should still resolve the room a user was in even after they disconnect — the name suggests exactly that. It doesn't: confirmed via live testing on the VR tutorial, SFS3 clears that association before dispatching USER_DISCONNECT to extensions, so by the time your disconnect handler runs, it's already null. The fix, used in every NetCrafter tutorial's generated extension template: track room membership yourself.
private final Map<Integer, Room> userRooms = new ConcurrentHashMap<>();
private void onUserDisconnect(ISFSEvent event) {
User user = (User) event.getParameter(SFSEventParam.USER);
if (user == null) return;
Room room = userRooms.get(user.getId());
handleDeparture(user.getId(), room);
}
This is deliberately the same fix across every NetCrafter tutorial's extension, not something unique to Tile Claim — it's baked into the generator's own JavaExtension.tpl template now, so every future schema gets it automatically.
Game state
private static final int BOARD_DIM = 6; // the one constant to change to resize the board
private static final int EMPTY = -1; // NOT 0 — SFS3 user ids can legitimately be 0
private int[][] board; // [x][y], EMPTY or a playerId
private final List<Integer> joinOrder = new ArrayList<>(); // historical join order — host selection only
private List<Integer> playerOrder = new ArrayList<>(); // turn sequence, snapshotted at StartGame
private int currentTurnIndex = 0;
private final Map<Integer, Integer> playerColors = new ConcurrentHashMap<>();
private final Map<Integer, String> playerNames = new ConcurrentHashMap<>();
private final Map<Integer, Integer> scores = new ConcurrentHashMap<>();
private boolean gameRunning = false;
private int hostPlayerId = -1;
Validating a claim
Every rule from Part A2 becomes one explicit check, in order, each with its own rejection reason — never a single catch-all "invalid move":
private String validateClaim(int senderId, int x, int y) {
if (!gameRunning) return "Game is not running";
if (playerOrder.isEmpty() || playerOrder.get(currentTurnIndex) != senderId)
return "Not your turn";
if (x < 0 || x >= BOARD_DIM || y < 0 || y >= BOARD_DIM)
return "Tile is out of bounds";
if (board[x][y] != EMPTY)
return "Tile is already claimed";
if (!isAdjacentToOwned(senderId, x, y))
return "Tile is not adjacent to one you own";
return null;
}
The handler then does exactly one of two things with the result — broadcast TileClaimed to the whole room, or send RejectedMove back to the sender alone:
public void processClaimTile(User sender, int x, int y) {
Room room = userRooms.get(sender.getId());
if (room == null) return;
synchronized (stateLock) {
String reject = validateClaim(sender.getId(), x, y);
if (reject != null) {
ISFSObject rejectMsg = SFSObject.newInstance();
rejectMsg.putString("reason", reject);
send("RejectedMove", rejectMsg, sender); // sender only
return;
}
// ...apply the move, advance the turn, broadcast TileClaimed to everyone...
}
}
Note the synchronized (stateLock) block — SFS3 can dispatch two players' requests concurrently on its own thread pool. Without this, two simultaneous claims could both pass validation against the same stale board snapshot. Every method that reads or mutates game state takes this same lock.
Departure policy
Two completely different rules, depending on who left:
- A joiner leaves: their claimed tiles stay on the board forever — claimed, but nobody else can ever claim them either. Their turn is skipped permanently by removing them from
playerOrder. If that drops the active player count below 2, the game ends immediately in favour of whoever has the most tiles among those remaining. - The host leaves: the extension does not try to promote a new host — reassigning "room creator" cleanly mid-session is more complexity than this tutorial's scope justifies. Instead, every remaining client receives
HostLeft("Host left the game") and disables tile clicking outright. There is deliberately no path back to the lobby after this.
Thin request handlers
HandleStartGame.java and HandleClaimTile.java extract request fields and immediately delegate — via getParentExtension(), SFS3's own mechanism for a handler to reach its owning extension — rather than holding any logic themselves:
public class HandleClaimTile extends BaseClientRequestHandler {
@Override
public void handleClientRequest(User sender, ISFSObject params, TransportType transport) {
Integer x = params.getInt("x");
Integer y = params.getInt("y");
if (x == null || y == null) return;
TurnBasedExtension ext = (TurnBasedExtension) getParentExtension();
ext.processClaimTile(sender, x, y);
}
}
Optional: the 30-second turn timer
A single-thread ScheduledExecutorService, reset every time the turn actually changes, keeps this simple: resetTurnTimerLocked() cancels any pending timer and schedules a new 30-second one; if it ever fires, onTurnTimeout() force-advances past whoever's turn it was — regardless of whether they technically had a legal move — the same way a normal claim's turn-advance does, just without a preceding TileClaimed.
Scene Setup¶
Open Assets/PivecLabs/Examples/TurnBased/Scenes/TurnBasedDemoScene.unity. It already contains everything below, wired — read this section to understand what's there, not to rebuild it from scratch.
| GameObject | Role |
|---|---|
NetworkManager | TurnBasedNetworkManager — the top-level coordinator |
Canvas | TurnBasedUIManager + the four panels (Login, Lobby, Game, GameOver) |
Canvas/GamePanel | TurnBasedGameBoard, plus an empty BoardContainer (a GridLayoutGroup) that tiles are instantiated into at runtime — there are no hand-placed tiles in the scene |
EventDispatcher | NetCrafterEventDispatcher — same as every other tutorial |
SceneEventRouter | NetCrafterSceneEventRouter, with one binding per server event straight to the matching TurnBasedNetworkManager.On*() method |
ConnectionManager | SmartFoxConnectionManager, host/port/zone pre-filled |
If you ever need to rebuild a piece of this by hand: Scene Assistant's "Add to Existing Scene" and Prefab Generator work the same way here as in every other tutorial — ConnectionManager starts inactive in the generated output; this scene activates it since the game has nothing else to toggle it on.
How TurnBasedNetworkManager Works¶
Same coordinator shape as every other tutorial's NetworkManager, with one rule enforced more strictly than usual: this class never mutates board/turn/score state itself. It only forwards whatever the server just broadcast to TurnBasedUIManager / TurnBasedGameBoard, which render it.
SendStartGame()— only meaningful ifIsHostis true, but that's a UI convenience, not an authority check. The server independently re-validates the sender is the actual room creator regardless of what the local UI shows.SendClaimTile(x, y)— sends the request and nothing else. No local board update, no optimistic rendering.UpdateLegalMoveHighlight(currentPlayerId)— the one place the turn indicator and the board's clickable-tile highlighting both get updated, called from every handler that learns a new current player (GameStarted,TileClaimed,TurnSkipped,StateSnapshot). Highlighting is purely cosmetic: it decides what looks clickable, never what the server will actually accept.- Insufficient-players handling — if
PlayerLeftdrops the active count below 2 mid-game, the server broadcastsGameOverright behind it (see Part B3). A normal "Player X wins!" panel would read oddly for "everyone else left," so the client instead shows "Not enough players," returns to the lobby, and swallows that specificGameOverso it doesn't also pop the normal panel. OnHostLeft()— disables the board and shows the reason text. There is no "return to lobby" button here, on purpose (see Part B3's departure policy).
Login & Lobby UI¶
TurnBasedUIManager switches between four panels by simply activating one and deactivating the other three — there's no animation or transition system, deliberately, to keep this tutorial's scope on the networking.
- LoginPanel — a username field and a Connect button.
Connect()callsSmartFoxConnectionManager.ConnectAndLogin(name), notLogin()directly — the connection manager doesn't open its socket until this exact moment, so it can't have already timed out waiting for you to type a name. - LobbyPanel — shows every joined player's name and colour. The Start button is visible to everyone but only interactable for whoever's
IsHost— same layout for everyone, only one person can actually press it. - GamePanel — the board, a turn indicator, and a running score list.
- GameOverPanel — winner (or tie) announcement, final scores, and a Play Again button that's enabled for the host only; everyone else sees "Waiting for host to restart…". Play Again just re-sends
StartGame— there's no separate "can we restart" check, because the server already rejects aStartGamewith fewer than 2 players the same way it would at any other time.
Cursor is unlocked and visible on the login panel, the same convention used in the Character tutorial, in case a later example adds cursor-locking gameplay on top of this one.
Game Board Setup¶
TurnBasedGameBoard owns exactly one job: render whatever the server says, and report clicks. It keeps its own small int[,] _owner cache purely so it can repaint without re-deriving anything — that cache is always overwritten wholesale from a broadcast, never guessed at or extrapolated locally.
SetupBoard(playerOrder, playerColors)— instantiates the 36TilePrefabcopies intoboardContaineron first use (itsGridLayoutGrouphandles layout — nothing here computes pixel positions by hand), then resets every tile to empty except the four starting corners.- Corner positions are computed, not transmitted — index 0→(0,0), 1→(0,max), 2→(max,0), 3→(max,max).
GameStartedonly sendsplayerOrder; both the server'scornerPositions()and the client'sCornerPositions()apply the identical deterministic rule rather than one side telling the other. HighlightLegalMoves(playerId)— only called when it's the local player's turn (see Part B5). Toggles each empty tile'sButton.interactablebased on adjacency to a tile that player owns — a rendering convenience, not validation. A highlighted tile that somehow gets clicked out of turn is still just a normalClaimTilerequest, and the server rejects it exactly like any other invalid one.ApplyFullBoard(flatBoardData)— used only forStateSnapshot(a late joiner). The server flattens its 2D board asx * BOARD_DIM + y; the client unflattens with the identical formula.
TilePrefab is deliberately plain: a root Image + Button, one child TMP_Text (unused by default, there for a future per-tile label if you want one). TurnBasedGameBoard.InstantiateTiles() wires every tile's click handler in code via button.onClick.AddListener(...) — there's nothing to configure on the prefab itself.
Zone Config & Deployment¶
In the NetCrafter window's Config Generator tab, generate and deploy TurnBasedZone.zone.xml:
| Setting | Value |
|---|---|
| Zone name | TurnBasedZone |
| Extension | TurnBasedExtension |
| isForceLogout | true — same as every other tutorial |
| Room | TurnBasedRoom, maxUsers: 4, isDynamic: false |
isGame and MMO mode are the same underlying flag — there's no way to set one without the other. Given Part B1's MMO warning, TurnBasedRoom is generated as a plain room with isGame: false, not an MMO/"game" room, regardless of how "game-like" the room conceptually is.Then build and deploy the server JAR: click Deploy to SFS3 in the NetCrafter window → Code → Server Code tab.
Restart SmartFoxServer 3 after every deploy — a running SFS3 instance does not pick up a replaced extension JAR on its own.
Test Sequence (2 Players Minimum)¶
- Build or run two instances of
TurnBasedDemoScene(two Editor instances, or one Editor + one standalone build). - On instance 1, enter a name and press Connect. You should land on the Lobby panel showing just yourself, with an enabled Start button (you're the host).
- On instance 2, enter a different name and press Connect. Both instances' lobby player lists should now show both names. Instance 2's Start button should be visible but disabled.
- On instance 1, press Start. Both instances should switch to the Game panel with two corner tiles already claimed and a turn indicator naming the first player.
- On whichever instance it is, click a tile orthogonally adjacent to your corner. Both instances should update immediately — the claimed tile's colour, the score list, and the turn indicator switching to the other player.
- Click a tile that is not adjacent to anything you own. You should see a rejected-move status message on your own screen only, and nothing should change on the other instance.
- Keep playing until the board fills or nobody has a legal move. Both instances should show the Game Over panel with matching scores and the same winner (or tie).
- On instance 1 (host), press Play Again. Both instances should return to a fresh board with the same two players.
- Quit instance 2 mid-game. Instance 1 should show "Not enough players" and return to its Lobby panel, ready for someone new to join.
Common Mistakes¶
- Client mutating state locally. If you extend this tutorial, resist the urge to update
TurnBasedGameBoard's tile colour the moment the local player clicks, "for responsiveness." That's exactly the pattern this tutorial exists to demonstrate the alternative to — wait for the broadcast, every time, even for your own moves. - Forgetting StateSnapshot for late joiners. If you add a new piece of visible game state, remember a player who joins mid-game never saw the messages that built up to the current state — they only get what
StateSnapshotexplicitly carries. - Not handling PlayerLeft when fewer than 2 players remain. It's tempting to just let the game keep running with one player stuck waiting for a turn that will never come from anyone else.
- Inactive ConnectionManager. If you copy this scene's pattern into a new one, double-check
ConnectionManageris actually active — Scene Assistant's default output leaves it inactive by design, for scenes where something else decides when to enable it. - Missing define symbols. Both
NETCRAFTER_TURNBASED_EXAMPLEandNETCRAFTER_TURNBASED_GENERATEDare required — the first alone will not compile once a script referencesSmartFoxConnectionManageror any generated event type. - Not restarting SFS3 after a JAR deploy. A running SmartFoxServer instance keeps the old extension class loaded until it's restarted — "Deploy complete" in the Output log is not the same as "the server is running your new code."
- Room name collision between schemas.
TurnBasedRoomonly has to be unique withinTurnBasedZone— SFS3 zones are independent namespaces — but if you ever rename a room, double-check nothing else in the same zone's XML still refers to the old name.
What's Next¶
You have a working server-authoritative turn-based game. Here are natural next features: