Michael Bolli ·

TL;DR

Conway's Game of Life running as a real-time multiplayer demo — every browser sees the same world, every interaction is broadcast to all participants instantly. All state lives on the server; the client executes zero logic of its own. Powered by php-via, a reactive web engine for PHP built on OpenSwoole.

The guiding design principle: the dumbest approach that still works — until it doesn’t. No canvas, no SVG, no Redis, no diff algorithm, no state-sync protocol. Only reach for complexity when simplicity stops working.

Wait — this is PHP?

PHP isn't exactly known for real-time or high-performance applications. That's true, but only for traditional request/response PHP. OpenSwoole flips the model: the PHP process stays alive permanently, holds connections open, and actively pushes updates to all clients. No Node.js, no Go, no Rust. Just PHP, fast enough for 60 fps DOM morphing and multiplayer real-time.

Live Demo

Open it in a second tab: all connected users share the same board and the same colour palette.

Open in its own tab ↗

Tip: DevTools Network

Open DevTools → Network and watch the _sse request; you can see the updates streaming in real time.

How does it work?

The board is a plain PHP data structure on the server: an array of 2500 strings ('dead' or a colour name like 'red', 'blue', …). An OpenSwoole timer fires every 200 ms, computes the next generation using Conway's rules, and pushes the new HTML fragment to all connected browsers via Server-Sent Events (SSE). Datastar handles DOM morphing: the browser receives ready-made HTML and surgically replaces only what changed.

No application JavaScript, no WebSocket protocol, no client-side game logic. The only JS dependency is the 12 KB Datastar shim that receives SSE updates and applies DOM patches; everything else runs in PHP.

Why SSE instead of WebSocket?

SSE is plain HTTP: no special protocol, no browser library required. For pure server→client streams it's simpler and sufficient. Player inputs (clicking a cell) are ordinary GET requests.

SSE gets Brotli compression, HTTP/2 multiplexing, and auto-reconnect for free, and works through firewalls and proxies without any special handling. WebSockets require custom solutions for all of this. With Brotli the 2,500 div elements of the full board compress to ~0.1 – 0.2 KB per update.

A look at the code

The full example is ~215 lines of PHP. Here are the most interesting parts:

1. Global state inside the OpenSwoole worker

OpenSwoole keeps the PHP process alive between requests. That makes the static properties of the example class itself viable as shared in-memory state for all connected clients:

final class GameOfLifeExample {
    /** @var array<int, string> */
    private static array $board = [];
    private static bool  $running        = true;
    private static int   $generation     = 0;
    private static int   $sessionCounter = 0;

    /** @var array<string, int> context-id → session number (determines colour) */
    private static array $sessionIds = [];
}

No Redis, no database: process memory is the state. No separate state object needed: the class’s own static properties are the state. Works perfectly as long as all clients hit the same worker (which is always true on a single server).

2. Conway's rules in pure PHP

The logic class is completely independent of php-via and trivially testable:

private static function nextGeneration(): void {
    $size = self::BOARD_SIZE; // 50
    $next = [];

    for ($idx = 0; $idx < $size * $size; ++$idx) {
        $row = intdiv($idx, $size);
        $col = $idx % $size;
        $cell = self::$board[$idx];

        $living = [];
        foreach (self::NEIGHBORS as [$dr, $dc]) {
            $r = $row + $dr; $c = $col + $dc;
            if ($r >= 0 && $c >= 0 && $r < $size && $c < $size) {
                $n = self::$board[$c + $r * $size];
                if ($n !== 'dead') $living[] = $n;
            }
        }
        $count = count($living);
        $alive = $cell !== 'dead';

        // Survive: 2–3 neighbours / Born: exactly 3 neighbours
        $next[$idx] = (($alive && ($count === 2 || $count === 3))
                    || (!$alive && $count === 3))
            ? $living[array_rand($living)]
            : 'dead';
    }

    self::$board = $next;
    ++self::$generation;
}

When a new cell is born it inherits its colour randomly from one of its living neighbours. Player colours blend organically as generations pass.

3. Multiplayer broadcast via Route Scope

php-via distinguishes several scopes that determine who receives an update:

ScopeView cacheRecipients
TABNoOwn tab only
ROUTEYes (per route)Everyone on the same route
GLOBALYes (app-wide)All connected clients

Game of Life uses Scope::ROUTE: the timer calls $app->broadcast(Scope::routeScope(...)) and every 200 ms all players see the same new generation. New: the timer pauses itself when no clients are connected:

self::$timerId = Timer::tick(200, function () use ($app): void {
    // pause when no clients are connected
    if (!self::$running
        || $app->getContextsByScope(Scope::routeScope('/examples/game-of-life')) === []) {
        return;
    }

    self::nextGeneration();

    if (self::isAllDead()) self::$running = false;

    $app->broadcast(Scope::routeScope('/examples/game-of-life')); // → all clients
});
Render once, broadcast to all

php-via ensures the HTML frame is rendered exactly once per broadcast cycle, regardless of how many clients are connected. A new connection simply receives the same already-compressed frame. Connections are cheap; the system scales horizontally with no per-client rendering overhead.

4. Interaction: drawing cells

A click on the board triggers a plain GET request; no hand-written JavaScript needed:

<!-- data-on:pointerdown is a Datastar attribute -->
<div class="gol-board"
     data-on:pointerdown="@get('{{ tapUrl }}?id=' + event.target.dataset.id)">
    <div class="tile red" data-id="42"></div>
    <!-- … 2499 more tiles … -->
</div>

The server receives the clicked cell ID, draws a cross pattern (5 cells) in the player’s colour, then broadcasts to everyone. The colour is determined deterministically from the session number: COLORS[$sessionId % count(COLORS)].

What the stats display shows

The demo shows a live profiling overlay: number of connected clients, render counter with average/min/max time, memory usage and uptime. These figures come directly from php-via and reveal the overhead per broadcast cycle.

Brotli makes the difference

2500 <div class="tile red" data-id="42"></div> sounds like a lot. The numbers tell a different story:

~500×

Brotli compression ratio

~11ms

DOM morphing · 2,500 cells · Ryzen 7 PRO 7840

127 KB transferred vs. 60,589 KB uncompressed, over 2 minutes of gameplay. The highly repetitive structure of the board works in Brotli's favour: most tiles don't change between frames, so the algorithm encodes only the deltas. Measured February 2026 · Chrome DevTools Network.

Could you do better? Theoretically you could encode the 7 cell states (6 colours + dead) as 3 bits per cell: ~938 bytes raw for the full board. But then you need a custom decoder in the browser, lose Datastar's morphing, and end up writing a binary streaming protocol. The naive approach (send fully rendered HTML, let Brotli compress) achieves nearly the same result without any of that complexity.

Takeaways

Naive is not the same as wrong. Sending 2,500 HTML divs every 200 ms sounds like a design mistake. Brotli makes it right. Before you reach for a diff algorithm, a binary streaming protocol, or a canvas renderer: measure first. The naive approach is often fast enough, and its simplicity has real value.

The backend doesn't matter. Datastar receives SSE and morphs the DOM regardless of whether the server is PHP, Go, Python, Ruby, or Rust. Everyone keeps their preferred language; the pattern scales because it’s built on standard HTTP. php-via demonstrates it with PHP; the pattern works with any server language.

SSE + DOM morphing is an underrated combination. One persistent HTTP connection, standard compression, zero hand-written client code. For anything that’s primarily server→client, it’s worth reaching for this stack first, before pulling in WebSockets, client-side state machines, and build pipelines.

The web platform can do more than we use. SSE, Brotli, HTTP/2 multiplexing: all of it is available in every browser today, no library required, no build step. Most of it goes unused because the ecosystem reaches for the next abstraction before exhausting the standards underneath. DOM morphing is Datastar’s contribution (12 KB, no build step). Sometimes the right move is to do less.

⚠️ php-via is in early stage

php-via is a side project under active development; the API is not yet stable and may change without deprecation notices. Not recommended for production use yet. Feedback and contributions are welcome.

Reactive web engine for PHP — SSE, DOM morphing, no JavaScript.

More examples

Game of Life is one of now 18 examples at via.zweiundeins.gmbh: chat room, multiplayer Type Race, live auction with anti-snipe bidding, real-time stock ticker, and more. All run without application JavaScript, without a build step, just PHP, OpenSwoole, and the 12 KB Datastar shim.

A dedicated blog post covering php-via's architecture, API and backstory is coming soon.