Bytro: Legacy Multiplayer-Game-Backend modernisieren
Bytros PHP-Game-Backend zu event-driven Microservices mit CQRS migriert - Latenz ~35% gesenkt, live Spieler in laufenden Matches unberührt.
Bytro baut Echtzeit-Strategiespiele. Supremacy 1914. Conflict of Nations: WW3. Browser-basiert, real-time, massively multiplayer. Spieler koordinieren globale Kriegskampagnen über Tage oder Wochen. Matches haben Hunderte von Spielern. State - Einheitenpositionen, Ressourcenmengen, diplomatische Abkommen, Kampfergebnisse - muss konsistent und für jeden Teilnehmer jederzeit sichtbar sein.
Das war das System, das ich modernisieren sollte.
Was “legacy” wirklich bedeutete
Das originale Backend war PHP. Nicht PHP 8 mit modernem Async-Handling und gut getypten Contracts - Legacy-PHP, jahrelang, mit den akkumulierten Entscheidungen einer Codebase, die ihren Spielern treu gewachsen war, aber den Punkt erreicht hatte, an dem jedes neue Feature eine Verhandlung mit Technical Debt war.
Die Probleme waren nicht subtil. State-Mutations für Game-Events - eine Einheit bewegt sich, eine Schlacht löst sich auf, eine diplomatische Nachricht wird gesendet - passierten durch ein Durcheinander synchroner Calls mit unklarem Ownership. Wenn mehrere Events gleichzeitig ankamen (was in einem Live-Game mit Hunderten von Spielern immer passiert, nicht manchmal), waren die Konsistenz-Garantien des Systems lastabhängig. Ein ruhiger Server handhabte es. Ein ausgelasteter Server hatte Races.
Read-Load war ebenfalls ein Problem. Game-State-Queries - jeder Spieler, der seine Karte refreshed, jeder Client, der auf Updates pollt - trafen denselben Datenbankpfad wie Writes. Es gab keine Caching-Strategie, die Match-Peaks sauber überleben konnte.
Latenz bei p99 wirkte sich sichtbar auf die Player-Experience aus. In den Maßstäben, in denen Bytro operierte, ist das ein Retention-Problem, keine Engineering-Unannehmlichkeit.
Die Architektur-Wette: CQRS + Event Sourcing + Kafka
Die Kernentscheidung war, Command-Handling von Query-Handling auf Architektur-Ebene zu trennen, nicht nur in der Code-Organisation.
Commands - “Einheit bewegt sich von Provinz A nach Provinz B”, “Spieler erklärt Fraktion X den Krieg”, “Ressourcentausch wird ausgeführt” - gehen durch einen Command-Handler, der ein Domain-Event nach Kafka published. Das Event ist der Truth-Record. Der Command-Handler schreibt keinen Application-State direkt.
State wird aus Events abgeleitet. Read-Models - die materialisierten Projektionen, die Spieler abfragen, wenn sie ihre Karte ansehen - werden von Event-Consumers gebaut, die PostgreSQL-Read-Replicas und Redis-Caches aktualisieren. Ein Read-Request berührt den Command-Pfad nie. Ein Write berührt den Read-Pfad nie. Sie skalieren unabhängig.
Event Sourcing bedeutete, dass der Game-State zu jedem Zeitpunkt aus dem Event-Log rekonstruierbar war. Das ist nicht nur eine architektonische Nettigkeit - es ist die Antwort auf “Was ist mit meiner Einheit passiert”-Disputes, die in einem Spiel, wo Entscheidungen folgenreich sind und Spieler genau hinschauen, eine reale Kategorie von Player-Support-Tickets darstellen.
Live-Spieler durch die Migration am Leben halten
Das ist der Teil, der im Nachhinein offensichtlich aussieht und durchzuleben elend ist.
Man kann Supremacy nicht für ein Migrations-Wochenende offline nehmen. Spieler sind mitten im Match. Manche Matches laufen wochenlang. Man kann nicht sagen “Matches, die vor dem Cutover gestartet wurden, werden zum neuen System migriert; Matches, die danach starten, laufen auf dem neuen System” - die Anzahl der in-flight Game-States macht das operativ unmöglich ohne ein dediziertes Migrations-Team, das es nicht gibt.
Die Migrationsstrategie war ein event-driven Strangler-Fig: neue Funktionalität wurde von Tag eins als Event-produzierende Services implementiert. Der Legacy-Code-Pfad blieb live und authoritative. Wir liefen Dual-Write während der Übergangsperiode - neue Events wurden nach Kafka published, Legacy-State wurde noch synchron aktualisiert - was uns ermöglichte zu validieren, dass die event-abgeleiteten Read-Models konsistent mit der Legacy-Source-of-Truth waren, bevor wir Read-Traffic umschalteten.
Die Dual-Write-Phase ist, wo man jede Annahme findet, die das Legacy-System machte, die nicht im Code stand. Jede implizite Ordering-Garantie. Jede Race, die die Single-Threaded-Ausführung des Legacy-Systems zufällig verhindert hatte. Das zu finden war nicht spaßig. Es nicht in Produktion zu finden war der Punkt.
Die ~35% Latenzsenkung
Die Zahl kommt aus p95- und p99-Latenz von Game-State-Queries vor und nach der Read-Model-Migration. Read-Pfade, die Redis-materialisierte Projektionen treffen, sind nicht dieselbe Kategorie von Operation wie Read-Pfade, die eine umkämpfte PostgreSQL-Tabelle treffen, die auch Writes absorbiert. Das ist keine Überraschung. Überraschend wäre gewesen, wenn es sich nicht verbessert hätte.
Die interessantere Zahl ist Write-Path-Latenz, die sich weniger verbesserte - Kafka-Publish-Latenz ist real, Event-Consumer-Lag während Peaks ist real, und der Command-Pfad ist jetzt asynchron, wo er zuvor synchron war. Spieler, die es gewohnt waren, ihre Einheit sofort nach dem Klicken zu sehen, sahen jetzt eine kurze Async-Verzögerung. Das ist ein UX-Tradeoff, der sorgfältige Behandlung erforderte - das Client-seitige Optimistic-Update-Pattern deckte das meiste davon ab, aber das Kalibrieren des Timeout-and-Reconcile-Verhaltens für Fälle, in denen der Event-Consumer vorübergehend hinterher war, erforderte Iteration.
Multi-Squad-Koordination
Die Bytro-Migration umfasste mehrere Squads: ein Platform-Squad, der die Infrastruktur handhabte (Kafka, Kubernetes, Deployment-Pipelines), Domain-Squads für individuelle Game-Systeme (Combat, Diplomacy, Economics) und ein Client-Squad für die Frontend-State-Synchronisierungs-Änderungen.
Lead Developer über diese Squads hinweg bedeutete, den Contract zwischen ihnen zu managen. Das Event-Schema war der Contract. Wenn der Combat-Squad ein Feld zum Battle-Resolution-Event hinzufügen musste, war das eine Schema-Migration, die der Client-Squad handeln musste, die Analytics-Pipeline handeln musste und die Read-Model-Consumers handeln mussten - alles ohne einen Flag-Day. Wir versionierten Events. Das klingt offensichtlich. Es in einer Codebase zu implementieren, die das nie gemacht hatte, ist drei Wochen Arbeit, die niemand machen will und für die alle froh sind, dass du es getan hast.
Kubernetes: das richtige Tool, sorgfältig angewendet
Event-Consumers auf Kubernetes während Match-Peaks autoscalen - Tournament-Events, Major-Updates, der Wochenend-Spike, der dich immer erwischt, wenn du nicht hinschaust - war die richtige Entscheidung. Es war auch das erste Mal, dass diese Codebase auf Kubernetes lief, was bedeutete, dass die Stateless/Stateful-Unterscheidung, die Kubernetes einen zwingt, explizit zu machen, retroaktiv auf eine Codebase angewendet werden musste, die diese Unterscheidung implizit und inkonsistent gemacht hatte.
PHP-Sessions, die im lokalen Memory einer einzelnen Instance gespeichert sind, sind nicht Kubernetes-nativ. Das wussten wir schon. Durch jede Stelle durchzuarbeiten, an der der Legacy-Code diese Annahme gemacht hatte, war die unspektakuläre Voraussetzung dafür, dass alles andere funktionierte.
Was ich verantwortete
- Architekturentscheidungen für das CQRS/Event-Sourcing-Modell und die Kafka-Event-Topologie
- Migrations-Sequenzierung und Dual-Write-Strategie für Live-Game-State
- Event-Schema-Design und Versionierungs-Contracts über Squads hinweg
- Read-Model-Design (PostgreSQL-Projektionen, Redis-Cache-Layers)
- Multi-Squad-Koordination: Platform, Domain und Client Engineering
- Kubernetes-Deployment-Design für Stateless-Event-Consumers
Spiele sind load-variabel in einer Weise, die es bei den meisten Enterprise-Software nicht ist. Eine Tournament-Ankündigung um 14:00 an einem Samstag steht nicht in deiner Kapazitätsplanungs-Tabelle. Ein Backend zu bauen, das diesen Spike absorbieren kann, ohne Spieler auf ein Spinner-Bildschirm starren zu lassen, ist eine andere Klasse von Problem als die Handhabung einer vorhersehbaren B2B-Request-Kurve. Ich habe bei Bytro Dinge über Event-Consumer-Backpressure und Lag-Alerting gelernt, die ich in jedem verteilten System anwende, das ich seitdem entworfen habe.
Conflict of Nations: WW3 läuft noch. Supremacy 1914 läuft noch. Das Backend, das sie bedient, ist bedeutsam anders als das, das ich geerbt habe. Das ist das Ergebnis.