Michael Bolli ·

Kurz & knapp

Conway's Game of Life läuft hier als Echtzeit-Multiplayer-Demo — jeder Browser sieht dieselbe Welt, jede Interaktion wird sofort an alle übertragen. Der gesamte Zustand lebt auf dem Server, der Client führt keinen einzigen eigenen Logik-Schritt aus. Möglich macht das php-via, eine reaktive Web-Engine für PHP.

Das durchgängige Designprinzip: der dümmste Ansatz, der noch funktioniert — solange er nicht an seine Grenzen stösst. Kein Canvas, kein SVG, kein Redis, kein Diff-Algorithmus, kein State-Sync-Protokoll.

Moment mal — das ist PHP?

PHP gilt nicht gerade als Sprache für Echtzeit-Applikationen. Das stimmt: für traditionelles Request/Response-PHP. OpenSwoole dreht das Modell um: Der PHP-Prozess bleibt dauerhaft am Leben, hält Verbindungen offen und sendet Updates aktiv an alle Clients. Kein Nginx, kein Node.js, kein Go. Reines PHP, das schnell genug ist für 60‑fps‑DOM‑Morphing und Echtzeit-Multiplayer.

Live-Demo

Öffne die Demo in einem zweiten Tab: Alle verbundenen Nutzer teilen dasselbe Spielfeld und dieselbe Farbpalette.

In eigenem Tab öffnen ↗

Tipp: DevTools Network

Öffne DevTools → Network und beobachte den _sse-Request, dort siehst du die Updates in Echtzeit streamen.

Wie funktioniert das?

Das Spielfeld ist eine statische PHP-Datenstruktur auf dem Server: ein Array von 2'500 Strings ('dead' oder eine Farbe wie 'red', 'blue', …). Ein OpenSwoole-Timer feuert alle 200 ms, berechnet die nächste Generation nach Conways Regeln und schickt das neue HTML-Fragment per Server-Sent Events (SSE) an alle verbundenen Browser. Datastar übernimmt das DOM-Morphing: Der Browser erhält fertiges HTML und ersetzt nur das Geänderte.

Das Besondere: Kein eigenes JavaScript, kein WebSocket-Protokoll, keine clientseitige Spiellogik. Die einzige JS-Abhängigkeit ist der 12 KB Datastar-Shim, der SSE-Updates empfängt und DOM-Patches einspielt; alles andere passiert in PHP.

Warum SSE statt WebSocket?

SSE ist HTTP: kein spezielles Protokoll, keine Bibliothek im Browser nötig. Für reine Server→Client-Streams ist SSE einfacher und ausreichend. Die Spielereingaben (Klick auf eine Zelle) sind normale GET-Requests.

SSE bekommt Brotli-Kompression, HTTP/2-Multiplexing und Auto-Reconnect gratis und funktioniert durch Firewalls und Proxies hindurch ohne Sonderbehandlung. WebSockets erfordern für all das eigene Lösungen. Mit Brotli schrumpfen die 2'500 Div-Elemente des Spielfelds auf ~0,1 – 0,2 KB pro Update.

Ein Blick in den Code

Das vollständige Beispiel sind ~215 Zeilen PHP. Hier die interessantesten Teile:

1. Globaler Zustand im OpenSwoole-Worker

OpenSwoole hält den PHP-Prozess dauerhaft am Leben. Das ermöglicht statische Properties der Beispiel-Klasse selbst als geteilten In-Memory-Zustand für alle verbundenen 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 (bestimmt Farbe) */
    private static array $sessionIds = [];
}

Kein Redis, keine Datenbank: der Prozessspeicher ist der Zustand. Kein separates State-Objekt nötig: die statischen Properties der Klasse sind direkt der Zustand. Funktioniert solange alle Clients denselben Worker treffen (was bei einem einzelnen Server immer der Fall ist).

2. Conways Regeln in reinem PHP

Die Logik-Klasse ist völlig unabhängig von php-via und leicht testbar:

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';

        // Überleben: 2–3 Nachbarn / Geburt: exakt 3 Nachbarn
        $next[$idx] = (($alive && ($count === 2 || $count === 3))
                    || (!$alive && $count === 3))
            ? $living[array_rand($living)]
            : 'dead';
    }

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

Entsteht eine Zelle neu, erbt sie zufällig die Farbe eines ihrer lebenden Nachbarn. Die Spielerfarben vermischen sich so organisch.

3. Multiplayer-Broadcast via Route Scope

php-via unterscheidet mehrere Scopes, die bestimmen, wer eine Aktualisierung erhält:

ScopeView-CacheEmpfänger
TABNeinNur der eigene Tab
ROUTEJa (pro Route)Alle auf derselben Route
GLOBALJa (app-weit)Alle verbundenen Clients

Game of Life verwendet Scope::ROUTE: der Timer ruft $app->broadcast(Scope::routeScope(...)) auf und alle 200 ms sehen alle Spieler dieselbe neue Generation. Neu: Der Timer pausiert sich selbst, wenn keine Clients verbunden sind:

self::$timerId = Timer::tick(200, function () use ($app): void {
    // Pause wenn keine Clients verbunden
    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')); // → alle Clients
});
Einmal rendern, an alle senden

php-via stellt sicher, dass der HTML-Frame pro Broadcast-Zyklus nur einmal gerendert wird, egal wie viele Clients verbunden sind. Eine neue Verbindung empfängt einfach denselben bereits komprimierten Frame. Verbindungen sind günstig; das System skaliert horizontal ohne Mehraufwand pro Client.

4. Interaktion: Zellen zeichnen

Ein Klick auf das Spielfeld löst einen normalen GET-Request aus, ganz ohne eigenes JavaScript:

<!-- data-on:pointerdown ist ein Datastar-Attribut -->
<div class="gol-board"
     data-on:pointerdown="@get('{{ tapUrl }}?id=' + event.target.dataset.id)">
    <div class="tile red" data-id="42"></div>
    <!-- … 2'499 weitere Tiles … -->
</div>

Der Server empfängt die geklickte Zell-ID, zeichnet ein Kreuz-Muster (5 Zellen) in der Farbe des Nutzers und verteilt die Aktualisierung sofort an alle. Die Farbe wird deterministisch aus der Session-Nummer berechnet: COLORS[$sessionId % count(COLORS)].

Was die Stats-Anzeige zeigt

Im Demo ist eine Live-Profiling-Anzeige eingeblendet: Anzahl verbundener Clients, Render-Zähler mit Durchschnitts-/Min-/Max-Zeit, Speicherverbrauch und Uptime. Diese Daten stammen direkt aus php-via und zeigen den Overhead je Broadcast-Zyklus.

Brotli macht den Unterschied

2'500 <div class="tile red" data-id="42"></div> klingen nach viel. Die Messung sieht anders aus:

~500×

Brotli-Kompressionsfaktor

~11ms

DOM-Morphing · 2'500 Zellen · Ryzen 7 PRO 7840

127 KB übertragen vs. 60 589 KB unkomprimiert, über 2 Minuten Spielbetrieb. Die hochrepetitive Struktur des Spielfelds spielt Brotli in die Hände: die meisten Tiles ändern sich von Frame zu Frame nicht, und der Algorithmus kodiert nur die Deltas. Gemessen Februar 2026 · Chrome DevTools Network.

Könnte man smarter machen? Theoretisch liessen sich die 7 Zellzustände (6 Farben + tot) als 3 Bits pro Zelle kodieren: das wären ~938 Byte roh für das volle Board. Aber dann braucht man einen Custom-Decoder im Browser, verliert Datastar-Morphing und schreibt ein binäres Streaming-Protokoll. Der naive Ansatz «gib fertig gerendertes HTML, lass Brotli komprimieren» ergibt praktisch dasselbe Resultat, ohne diese Komplexität.

Fazit

Naiv ist nicht falsch. 2'500 HTML-Divs alle 200 ms klingen nach einem Designfehler. Brotli macht es erst möglich. Bevor du einen Diff-Algorithmus, ein binäres Streaming-Protokoll oder einen Canvas-Renderer einbaust: miss zuerst. Der naive Ansatz ist oft schnell genug, und seine Einfachheit hat einen echten Wert.

Das Backend spielt keine Rolle. Datastar empfängt SSE und morpht den DOM, egal ob der Server in PHP, Go, Python, Ruby oder Rust schreibt. Jeder kann seine bevorzugte Sprache behalten; das Muster skaliert, weil es auf Standard-HTTP aufbaut. php-via zeigt es mit PHP; das Muster ist sprachunabhängig.

SSE + DOM-Morphing ist eine unterschätzte Kombination. Eine persistente HTTP-Verbindung, Standard-Kompression, null eigener Client-Code. Für alles, was primär Server→Client ist, lohnt es sich, diesen Stack zuerst zu probieren, bevor man WebSockets, clientseitige State-Maschinen und Build-Pipelines einführt.

Die Webplattform kann mehr als du denkst. SSE, Brotli, HTTP/2-Multiplexing: alles davon ist heute in jedem Browser verfügbar, ohne Bibliothek, ohne Build-Step. Vieles davon wird kaum eingesetzt, weil das Ökosystem immer zur nächsten Abstraktion greift, bevor es die darunter liegenden Standards ausreizt. Das DOM-Morphing steuert Datastar bei (12 KB, kein Build-Step). Manchmal reicht es, weniger zu tun.

⚠️ php-via ist in einem frühen Stadium

php-via ist ein Eigenprojekt in aktivem Aufbau; die API ist noch nicht stabil und ändert sich ohne Deprecation-Warnings. Für Produktionseinsatz noch nicht empfohlen. Feedback und Mitwirkung sind willkommen.

Reaktive Web-Engine für PHP — SSE, DOM-Morphing, kein JavaScript.

Mehr Beispiele

Das Game of Life ist eines von inzwischen 18 Beispielen auf via.zweiundeins.gmbh: Chat-Room, Multiplayer-Type-Race, Live-Auktion mit Anti-Snipe-Funktion, Echtzeit-Aktien-Ticker mit Apache ECharts und mehr. Alle laufen ohne eigenes JavaScript, ohne Build-Step, nur PHP, OpenSwoole und der 12 KB Datastar-Shim.

Ein dedizierter Blogpost zu Architektur, API und Entstehungsgeschichte von php-via folgt demnächst.