Multiplayer Tic-Tac-Toe
Two players, one room ID, real-time gameplay over WebSocket. The Build-the-App capstone.
You will learn
- Pair two players by room ID — the same pattern any multiplayer lobby uses
- Store shared game state in
OpenSwoole\Tablefor cross-worker access - Validate moves server-side: who, when, where — never trust the client
- Fan-out broadcasts to spectators (unlimited) while keeping seats limited to two
- Handle disconnects, reconnects, and an alternating starter across rounds
1. Setup — what you’re building
A multiplayer tic-tac-toe game where two players in the same browser, in different browsers, or on different continents enter the same room ID and play live. Every move flows through one WebSocket; every connected tab in that room sees the move within milliseconds. Extra tabs join as viewers and watch the game without taking a seat — like a stream with chat-style fan-out.
You’ll need to log in first — the server identifies players by their session so it can show display names and stop strangers from hijacking a seat. The multiplayer mechanics work the same with or without auth; we just pick auth here because Notes and Chat already require it and we’re reusing the same login flow.
Sign in to play
Pick any username and password. The game uses your username as your display name.
New here? Register
2. Game state — two Store tables
OpenSwoole’s Table is shared memory: every worker process can read and write
the same row by key. Two tables here:
ws_tictactoe_rooms— one row per active room. Holds the board, whose turn it is, the winner (if any), the two players’ fds, names, and the alternating starter for the next round.ws_tictactoe_clients— one row per connected fd. Stores the room the fd is in and the symbol it owns (X, O, or S for spectator). The broadcaster uses this to filter which fds should receive a state push.
Why two tables? The room state is one logical entity that any player or viewer reads from;
the client table is the join key — “who is in this room and what role do they have?”
It’s the same shape /ws/session-counter uses to broadcast only to fds in the
same session — we just swap session_id for room.
// route/learn.php — boot scope, before $app->run()
Store::make('ws_tictactoe_rooms', 1024, [
'board' => [Table::TYPE_STRING, 9], // '_________' 9 chars: '_' | 'X' | 'O'
'turn' => [Table::TYPE_STRING, 2], // 'X' | 'O' | '' when finished
'winner' => [Table::TYPE_STRING, 8], // '' | 'X' | 'O' | 'draw'
'px_fd' => [Table::TYPE_INT, 8], // 0 = X seat empty
'po_fd' => [Table::TYPE_INT, 8],
'px_name' => [Table::TYPE_STRING, 32],
'po_name' => [Table::TYPE_STRING, 32],
'starter' => [Table::TYPE_STRING, 2], // alternates each new round
'rounds' => [Table::TYPE_INT, 4],
]);
Store::make('ws_tictactoe_clients', 4096, [
'room' => [Table::TYPE_STRING, 32],
'name' => [Table::TYPE_STRING, 32],
'symbol' => [Table::TYPE_STRING, 2],
'joined' => [Table::TYPE_INT, 8],
]);
The board is a 9-character string ("_________", then
"X___O____", etc.) — easier to log and serialize than nested arrays, fits in
a tiny fixed-size column, and you can substr_replace a cell in one call.
3. WebSocket pairing — assigning seats
The endpoint /ws/tictactoe?room=alpha-1 uses the same lifecycle pattern you learned
in the WebSocket lesson — onOpen, onMessage, onClose.
onOpen does two things: authenticate (read the session via
G::instance()->session) and pick a seat:
onOpen: function ($server, $request) {
$g = G::instance();
$userId = (int) ($g->session['user_id'] ?? 0);
$username = (string) ($g->session['username'] ?? '');
if (!$userId || $username === '') {
$server->disconnect($request->fd, 1008, 'auth_required'); return;
}
$room = ttt_sanitize_room((string)($request->get['room'] ?? ''));
if ($room === '') { $server->disconnect($request->fd, 1008, 'no_room'); return; }
$viewMode = ((string)($request->get['view'] ?? '')) === '1';
$row = Store::get('ws_tictactoe_rooms', $room) ?? seed_new_room($room);
$symbol = 'S'; // default: spectator
if (!$viewMode) {
if ((int)$row['px_fd'] === 0) { $symbol = 'X'; claim_x_seat($room, $request->fd, $username); }
elseif ((int)$row['po_fd'] === 0) { $symbol = 'O'; claim_o_seat($room, $request->fd, $username); }
}
Store::set('ws_tictactoe_clients', (string)$request->fd, compact('room','username','symbol') + ['joined' => time()]);
$server->push($request->fd, json_encode(['type'=>'welcome', 'symbol'=>$symbol, 'room'=>$room]));
ttt_broadcast_state($room);
}
First fd in a room takes X; second takes O; everyone after gets 'S' (spectator).
The optional ?view=1 query string forces spectator mode even when a seat is open —
useful for casting the game without participating. The result: at most two players, unlimited
viewers. That’s what the user-message constraint “only 2 people can connect, plus
viewer mode that fans out” reduces to in code.
4. Player moves — never trust the client
Every player action arrives over the socket as a JSON message. The server validates everything: who you are, whose turn it is, whether the cell is empty, whether the game is over. The client JS can be tampered with or replaced entirely — the server is the only source of truth.
onMessage: function ($server, $frame) {
$me = Store::get('ws_tictactoe_clients', (string)$frame->fd); if (!$me) return;
$msg = json_decode($frame->data, true); if (!is_array($msg)) return;
$row = Store::get('ws_tictactoe_rooms', $me['room']); if (!$row) return;
if ($msg['type'] === 'move') {
if ($me['symbol'] === 'S') return; // spectator: no moves
if ($row['winner'] !== '') return; // game already over
if ($me['symbol'] !== $row['turn']) return; // wrong turn
$cell = (int)($msg['cell'] ?? -1);
if ($cell < 0 || $cell > 8) return;
$board = $row['board'];
if ($board[$cell] !== '_') return; // cell occupied
$board[$cell] = $me['symbol'];
[$winSymbol, $winLine] = ttt_detect_winner($board);
$update = ['board' => $board];
if ($winSymbol) $update += ['winner'=>$winSymbol, 'turn'=>''];
elseif (!str_contains($board, '_')) $update += ['winner'=>'draw', 'turn'=>''];
else $update += ['turn'=> $row['turn']==='X' ? 'O' : 'X'];
Store::set('ws_tictactoe_rooms', $me['room'], $update);
ttt_broadcast_state_with($me['room'], $winLine ? ['win_line'=>$winLine] : []);
}
}
Win detection is eight three-in-a-row checks — three rows, three columns, two diagonals. Cheap to compute on every move, no need for clever incremental algorithms at this scale.
function ttt_detect_winner(string $board): array {
$lines = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
foreach ($lines as [$a, $b, $c]) {
$s = $board[$a];
if ($s !== '_' && $s === $board[$b] && $s === $board[$c]) return [$s, [$a, $b, $c]];
}
return [null, null];
}
5. Broadcasting — fan-out to the room
After every state mutation, the server pushes the new state to every fd in the room. The
iteration is the same loop you saw in the WebSocket lesson — the only addition is filtering by
room:
function ttt_broadcast_state(string $room): void {
$server = App::getServer();
$row = Store::get('ws_tictactoe_rooms', $room); if (!$row) return;
$payload = json_encode([
'type' => 'state',
'board' => $row['board'],
'turn' => $row['turn'],
'winner' => $row['winner'],
'rounds' => (int)$row['rounds'],
'players' => [
'X' => ['name' => $row['px_name'], 'connected' => (int)$row['px_fd'] > 0],
'O' => ['name' => $row['po_name'], 'connected' => (int)$row['po_fd'] > 0],
],
'viewers' => count_viewers($room),
]);
foreach (Store::table('ws_tictactoe_clients') as $fd => $c) {
if ($c['room'] !== $room) continue;
$fd = (int)$fd;
if ($server->isEstablished($fd)) $server->push($fd, $payload);
}
}
Players and viewers both receive the same payload — they all need the board, the turn, the players’ names, and the winner. The client decides what to do with the state: players can click cells, viewers see a disabled board, the active player’s tab shows “your turn.”
6. Disconnects, viewers, and reconnects
When a player’s tab closes, onClose runs. The fd is removed from the clients
table, and if it held a player seat, that seat is freed (the name stays so the same user can
reclaim it on reconnect):
onClose: function ($server, $fd) {
$me = Store::get('ws_tictactoe_clients', (string)$fd);
Store::del('ws_tictactoe_clients', (string)$fd);
if (!$me) return;
$row = Store::get('ws_tictactoe_rooms', $me['room']);
if (!$row) return;
$update = [];
if ((int)$row['px_fd'] === $fd) $update['px_fd'] = 0;
if ((int)$row['po_fd'] === $fd) $update['po_fd'] = 0;
if ($update) Store::set('ws_tictactoe_rooms', $me['room'], $update);
ttt_broadcast_state($me['room']); // tell the room someone left
}
Reset ({"type":"reset"}) is also a socket message: only seated players can send
it; spectators see the button hidden in the UI and the server rejects the message if
it somehow gets through. The reset flips starter so X and O take turns going
first across rounds — a small fairness detail, easy to miss until you play three games in a
row and realize X always opens.
7. Keeping score — extend the row, not the schema
Players want to know how many rounds X has won versus O across the session. The
naive instinct is to spin up a new table for it — but the scoreboard is just three more
counters that live for the lifetime of the room. They belong in the SAME
ws_tictactoe_rooms row we already have. Three new fixed-width columns:
// route/learn.php — extending the existing Store::make call from step 2
Store::make('ws_tictactoe_rooms', 1024, [
// …existing columns…
'x_wins' => [Table::TYPE_INT, 4],
'o_wins' => [Table::TYPE_INT, 4],
'draws' => [Table::TYPE_INT, 4],
]);
Mutate where you already mutate
The win/draw branches of onMessage already write to the room row when a game
ends. Bumping the scoreboard in the SAME Store::set call means the counters
can never disagree with the winner field — it’s one critical section,
one round-trip to shared memory:
if ($winSymbol !== null) {
$update['winner'] = $winSymbol;
$update['turn'] = '';
$update['rounds'] = (int) $rowRoom['rounds'] + 1;
// Bump the matching counter in the SAME update — atomic with the
// winner field, no chance of a "we say X won but the score doesn't
// reflect it" desync.
if ($winSymbol === 'X') $update['x_wins'] = (int) $rowRoom['x_wins'] + 1;
else $update['o_wins'] = (int) $rowRoom['o_wins'] + 1;
} elseif (!str_contains($board, '_')) {
$update['winner'] = 'draw';
$update['turn'] = '';
$update['rounds'] = (int) $rowRoom['rounds'] + 1;
$update['draws'] = (int) $rowRoom['draws'] + 1;
}
Store::set('ws_tictactoe_rooms', $room, $update); // one write
Broadcast for free
The scoreboard rides on the same state-broadcast machinery that already carries the board,
the turn, the winner, and the player names. Adding it to the JSON payload makes every tab
in the room receive the new score the moment the game ends — no separate message type, no
separate onMessage branch on the client:
// inside ttt_broadcast_state(), the payload gets one extra key
'score' => [
'X' => (int) $row['x_wins'],
'O' => (int) $row['o_wins'],
'draw' => (int) $row['draws'],
],
Resetting the score
A second socket message, {"type":"reset_score"}, zeroes the three counters
and re-broadcasts. Same authorization guard as the board reset — spectators are rejected,
seated players are allowed:
if ($type === 'reset_score') {
if (($me['symbol'] ?? '') === 'S') return;
Store::set('ws_tictactoe_rooms', $room, [
'x_wins' => 0, 'o_wins' => 0, 'draws' => 0, 'rounds' => 0,
]);
ttt_broadcast_state($room);
}
8. Try it — two tabs, one room
Why no HTTP POST for moves?
The natural design instinct is POST /api/tictactoe/move with htmx. It works — but the server then has to authenticate each request against the player’s session and check that they’re seated in the room they claim, which is two extra lookups per move. By sending moves over the existing socket, the server already knows which fd sent the frame, which room that fd is in, and what symbol it holds. No re-auth, no extra round-trip on session lookups, and the move-validation flow becomes one straight path.
Trade-off: you give up the htmx flow (request/response). For a multiplayer game where you want low-latency two-way comms anyway, the socket is the better fit. For something like a turn-based form submission, HTTP is still simpler.
A third tab joins room "alpha-1" while two players are already seated. What happens?
Key Takeaways
- Two Store tables: one for room state, one for per-fd bookkeeping — same idiom as
/ws/session-counterand/ws/store-demo - Seats are limited to two (X, O) by checking
px_fdandpo_fdat connect time; spectators are unlimited - All game-changing messages go through the socket — the server trusts fd→symbol mapping, not client-supplied tokens
- Win detection is eight three-in-a-row line checks; payload includes
win_lineso the client highlights the winning cells onClosefrees the seat but keeps the name, so a reconnecting player picks up where they left off