by Pivec Labs
⏱ 45–60 min Turn-Based Tutorial

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.

Time: 45–60 minutes 13 parts C# & Java 2-4 players
A1

Prerequisites

Before starting this tutorial you need:

🧩
NetCrafter Pro

What it is: Already imported and working — you've completed at least one earlier tutorial (Chat or Character).

Namespace for this project: TurnBased

🖥
SmartFoxServer 3

What it is: Running locally — same setup as the earlier tutorials.

New zone: TurnBasedZone on port 9977

☕
Maven + JDK 21

What it is: Same build tools used in every other NetCrafter tutorial.

Already detected by NetCrafter if an earlier tutorial is working.

📦
No other assets needed

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.

A2

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.

A3

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 broadcasts TileClaimed to everyone. If invalid, it sends RejectedMove back to the sender only — nobody else even learns the attempt happened.
  • 4. Clients render — TurnBasedGameBoard.cs paints 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.

B1

Schema Setup

In the NetCrafter window, create a new schema named TurnBased. Set Settings → Project Settings → Namespace to TurnBased.

SmartFox Settings tab

FieldValue
Host127.0.0.1
Zone NameTurnBasedZone
Extension NameTurnBased
Extension Classcom.piveclabs.turnbased.TurnBasedExtension
TCP Port9977
Enable MMO☐ unchecked
⚠️
Leave Enable MMO unchecked. A live test on the VR tutorial found that SFS3's MMORoom type auto-evicts every player from the room after 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

MessageFieldsServer Handler Name
StartGame(none)HandleStartGame
ClaimTilex (int), y (int)HandleClaimTile

Messages tab — ServerToClient (leave Server Handler Name blank on all of these)

MessageFields
PlayerJoinedplayerId (int), playerName (string), colorIndex (int), playerCount (int), hostPlayerId (int)
PlayerLeftplayerId (int)
GameStartedplayerOrder (int[]), playerColors (int[]), playerNames (string[]), firstPlayerId (int)
TileClaimedplayerId (int), x (int), y (int), nextPlayerId (int), scores (int[])
TurnSkippedplayerId (int), nextPlayerId (int)
RejectedMovereason (string)
GameOverwinnerPlayerId (int), scores (int[]), isTie (bool)
StateSnapshotboardData (int[]), currentPlayerId (int), scores (int[]), playerOrder (int[]), playerNames (string[]), playerColors (int[]), gameRunning (bool)
HostLeftreason (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 NameTrigger MessageResponse Event
HandleStartGameStartGameGameStarted
HandleClaimTileClaimTileTileClaimed

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.

B2

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.

⚠️
Check CleanGeneratedCode behaviour before generating. If your NetCrafter build offers a "clean generated code" option, make sure it's scoped to the 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.

B3

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.

java — excerpt from TurnBasedExtension.java
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

java — fields on TurnBasedExtension
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;
⚠️
EMPTY is -1, not 0. SFS3 assigns user ids starting from 0 in some configurations. If the board's "empty" sentinel were also 0, a legitimate player with id 0 would be indistinguishable from an empty tile the moment they claimed one.

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":

java — TurnBasedExtension.validateClaim()
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:

java — TurnBasedExtension.processClaimTile()
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:

java — HandleClaimTile.java
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.

B4

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.

GameObjectRole
NetworkManagerTurnBasedNetworkManager — the top-level coordinator
CanvasTurnBasedUIManager + the four panels (Login, Lobby, Game, GameOver)
Canvas/GamePanelTurnBasedGameBoard, plus an empty BoardContainer (a GridLayoutGroup) that tiles are instantiated into at runtime — there are no hand-placed tiles in the scene
EventDispatcherNetCrafterEventDispatcher — same as every other tutorial
SceneEventRouterNetCrafterSceneEventRouter, with one binding per server event straight to the matching TurnBasedNetworkManager.On*() method
ConnectionManagerSmartFoxConnectionManager, 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.

B5

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 if IsHost is 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 PlayerLeft drops the active count below 2 mid-game, the server broadcasts GameOver right 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 specific GameOver so 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).
B6

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() calls SmartFoxConnectionManager.ConnectAndLogin(name), not Login() 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 a StartGame with 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.

B7

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 36 TilePrefab copies into boardContainer on first use (its GridLayoutGroup handles 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). GameStarted only sends playerOrder; both the server's cornerPositions() and the client's CornerPositions() 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's Button.interactable based 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 normal ClaimTile request, and the server rejects it exactly like any other invalid one.
  • ApplyFullBoard(flatBoardData) — used only for StateSnapshot (a late joiner). The server flattens its 2D board as x * 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.

B8

Zone Config & Deployment

In the NetCrafter window's Config Generator tab, generate and deploy TurnBasedZone.zone.xml:

SettingValue
Zone nameTurnBasedZone
ExtensionTurnBasedExtension
isForceLogouttrue — same as every other tutorial
RoomTurnBasedRoom, maxUsers: 4, isDynamic: false
⚠️
isGame is false, not true. In this generator, 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.

B9

Test Sequence (2 Players Minimum)

  1. Build or run two instances of TurnBasedDemoScene (two Editor instances, or one Editor + one standalone build).
  2. 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).
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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).
  8. On instance 1 (host), press Play Again. Both instances should return to a fresh board with the same two players.
  9. Quit instance 2 mid-game. Instance 1 should show "Not enough players" and return to its Lobby panel, ready for someone new to join.
B10

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 StateSnapshot explicitly 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 ConnectionManager is 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_EXAMPLE and NETCRAFTER_TURNBASED_GENERATED are required — the first alone will not compile once a script references SmartFoxConnectionManager or 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. TurnBasedRoom only has to be unique within TurnBasedZone — 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.
NEXT

What's Next

You have a working server-authoritative turn-based game. Here are natural next features:

In-Game Chat

Add a SendChat message (from the Chat tutorial) to the TurnBased schema; hook the UI to TurnBasedUIManager.

Larger / Non-Square Boards

BOARD_DIM is one constant on each side; a non-square board needs separate width/height constants and the corner-position formula updated to match on both sides.

Host Migration

Part B3 deliberately doesn't reassign a new host when one leaves — a real implementation would need to pick a successor, re-announce them, and decide what happens to a game already in progress.

Persistent Matchmaking

This tutorial uses one static room. A lobby-browser-style game would need SFS3's dynamic room creation and a room-list UI instead.