I built a multiplayer tic-tac-toe demo as the capstone of a PHP framework's learn site. Open it in two browser tabs, join the same room ID, and you're playing yourself:
- Lesson https://php.zeal.ninja/learn/tictactoe
- Working Demo https://php.zeal.ninja/demo/view/tictactoe/play
It's a real WebSocket game. WebSocket run on pure PHP!!!
First two players in a room get X and O; everyone after that joins as a spectator and sees the board update live. Add
?view=1to force spectator mode even with a seat free.Win detection, alternating starter on reset, running scoreboard, reconnect handling - all server-authoritative, all over one socket per client.
What it's built on
The framework is ZealPHP — a PHP framework I've been building on top of OpenSwoole. Same PHP you already know (session_start(), $_GET(?), header(), the lot), but the runtime model is different: workers stay alive between requests, routes and shared state load once, and WebSockets share the same process as your HTTP routes. No boot-per-request, no Node sidecar for sockets, no Redis for in-memory state on small apps.
It's PSR-15 middleware end-to-end and ships with the boring stuff in the box — CORS, ETag + 304, gzip, Range requests, rate limiting, IP allow/deny, Basic Auth. Apache .htaccess reflexes mostly carry over, and unmodified WordPress runs on it via a CGI worker bridge.
That runtime model is what makes a multiplayer game small enough to fit in one file.
The heart of it
OpenSwoole gives each connection a numeric $fd (file descriptor). Seat assignment is just: look at the room's current px_fd / po_fd, claim whichever is 0 first, otherwise spectate.
$app->ws('/ws/tictactoe',
onOpen: function ($server, $request) {
$room = ttt_sanitize_room((string) ($request->get['room'] ?? ''));
if ($room === '') { $server->disconnect($request->fd, 1008, 'no_room'); return; }
$viewMode = ((string) ($request->get['view'] ?? '')) === '1';
// ?view=1 forces spectator regardless of free seats.
$symbol = 'S';
if (!$viewMode) {
$row = \ZealPHP\Store::get('ws_tictactoe_rooms', $room);
if ((int) $row['px_fd'] === 0) {
$symbol = 'X';
\ZealPHP\Store::set('ws_tictactoe_rooms', $room,
['px_fd' => $request->fd, 'px_name' => $username]);
} elseif ((int) $row['po_fd'] === 0) {
$symbol = 'O';
\ZealPHP\Store::set('ws_tictactoe_rooms', $room,
['po_fd' => $request->fd, 'po_name' => $username]);
}
}
\ZealPHP\Store::set('ws_tictactoe_clients', (string) $request->fd, [
'room' => $room, 'name' => $username,
'symbol' => $symbol, 'joined' => time(),
]);
ttt_broadcast_state($room);
},
onMessage: /* validate + mutate + broadcast */,
onClose: /* zero the seat, keep the name */,
);
Store is a thin wrapper around OpenSwoole\Table — a lock-free hash map in shared memory that every worker sees. Two tables: one keyed by fd for who's connected, one keyed by room for the board state. No Redis, no extra process.
Total server-side: ~270 lines of PHP for the game (two Store schemas, four helpers, the WS handler with move / reset / reset_score / leave). Client is ~180 lines of vanilla JS. The whole thing lives in one file alongside the framework's other routes.
The whole API surface, in 15 lines
<?php
require __DIR__ . '/vendor/autoload.php';
use ZealPHP\App;
App::superglobals(false);
$app = App::init();
$app->route('/hello/{name}', fn($name) => "Hi {$name} 👋");
$app->ws('/chat', function ($server, $frame) {
$server->push($frame->fd, "echo: {$frame->data}");
});
$app->run();
One process, HTTP + WebSocket. $ php app.php and it's serving. Coroutines mean $app->route('/users', fn() => User::all()) blocks the coroutine, not the worker — straight-line code, no async/await ceremony.
See full working demo here: https://php.zeal.ninja/demo/view/tictactoe/play
A few more demos worth clicking
- Personal Notes — open in two tabs, watch them stay in sync via WS broadcasts.
-
Streaming —
yield "<section>";flushes immediately. Generators are responses. -
Coroutines — async PHP without
async/await. - AI Chat — SSE token streaming from a Python agent bridge.
Try it locally
composer create-project sibidharan/zealphp-project my-app
cd my-app && php app.php
http://localhost:8080 and you're running the same site, locally. If you get into any issues, please raise issue in Github. The project is still in alpha!
Need your feedbacks to improve it.
- GitHub: sibidharan/zealphp
-
Tic-tac-toe source: route/learn.php (search for
tictactoe)
If you've ever wished PHP felt a little more alive between requests — go play a round.
Top comments (0)