Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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\Table for 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-counter and /ws/store-demo
  • Seats are limited to two (X, O) by checking px_fd and po_fd at 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_line so the client highlights the winning cells
  • onClose frees the seat but keeps the name, so a reconnecting player picks up where they left off