Warum wir Gammon42s Echtzeit-Server für Online-Backgammon in Go neu gebaut haben
Wie wir unser Online-Multiplayer-Backgammon unter Last schneller gemacht haben — der Echtzeit-Pfad läuft jetzt auf einem kleinen Go-Dienst.
Jedes Online-Spiel steht und fällt mit einer Sache: wie es sich anfühlt, wenn man einen Stein antippt und darauf wartet, dass sich das Brett bewegt. Wenn dieser Moment sofort passiert, wirkt das Spiel lebendig. Wenn es ruckelt — und sei es nur um eine Sekunde — wirkt das Ganze kaputt, egal wie gut alles andere ist.
Dies ist die Geschichte, wie wir diesen Moment in Gammon42 — unserem kostenlosen Online-Multiplayer-Backgammon-Spiel — schneller und gleichmäßiger gemacht haben, indem wir den Echtzeit-Teil unseres Backends von dem Framework, das den Rest der App betreibt, auf einen kleinen, speziell dafür gebauten Dienst in Go verlagert haben. Er ist für zwei Arten von Lesern geschrieben: Wenn du einfach nur wissen willst, warum sich das Spiel jetzt besser anfühlt, ist die erste Hälfte für dich. Wenn du die technischen Details willst — die Lasttests, die Architektur, die Kompromisse — findest du das alles in der zweiten Hälfte, klar gekennzeichnet. Und wenn du lieber gleich jetzt Backgammon online spielen möchtest, nur zu — der Rest wird auch danach noch hier sein.
Die kurze Version
Unser Backend war eine einzige Dart-Anwendung, aufgebaut auf dem Serverpod-Framework. Es erledigte alles: Konten, Freunde, Turniere, In-App-Käufe, Chat — und das Live-Gameplay selbst. Serverpod ist dafür wirklich hervorragend — es hat es einem kleinen Team ermöglicht, schnell ein vollständiges, typisiertes Client-Server-Produkt auszuliefern, und es betreibt bis heute den Großteil von Gammon42. Aber Live-Gameplay ist grundlegend eine andere Art von Arbeit als der Rest, und je mehr Leute gleichzeitig spielten, desto mehr konkurrierten die beiden um dieselben Ressourcen. Der Teil des Systems, der am dringendsten schnell sein muss — das Bewegen von Steinen in einer laufenden Partie — teilte sich eine Spur mit allem anderen.
Also haben wir den Live-Gameplay-Teil in einen eigenen, dedizierten Dienst herausgelöst. Er kümmert sich nur um den Echtzeit-Hot-Path: Würfel, Züge, Brettzustand und das Synchronhalten beider Spieler. Alles andere blieb genau dort, wo es war. Das Ergebnis ist, dass ein geschäftiger Abend voller Matchmaking den Moment, in dem du einen Zug machst, nicht mehr verlangsamt — und die beiden Hälften des Systems können jetzt unabhängig voneinander wachsen.
Warum das Warten wichtig ist
In einer Online-Multiplayer-Backgammon-Partie sind die Nachrichten, die zwischen deinem Handy und unserem Server hin- und herfliegen, winzig und ständig: Ich habe gewürfelt, das ist gekommen; ich habe diesen Stein bewegt; ist das ein legaler Zug; hier ist das neue Brett; du bist dran. Dutzende davon pro Partie, alle latenzempfindlich, alle müssen fast sofort beide Spieler erreichen — das ist es, was Echtzeit-Multiplayer bedeutet, wenn du gegen einen echten Gegner würfelst.
Das ist eine völlig andere Art von Arbeit als, sagen wir, das Laden deiner Freundesliste oder das Abwickeln eines Kaufs. Diese passieren gelegentlich und können ein paar hundert Millisekunden vertragen. Live-Züge können das nicht — eine Verzögerung von einer Sekunde bei einem Zug ist der Unterschied zwischen einem Spiel, das sich reaktionsschnell anfühlt, und einem, das sich träge anfühlt.
Wenn ein einziges System beides bewältigt, kann die gelegentliche-aber-schwere Arbeit der ständigen-aber-leichten Arbeit in die Quere kommen. Genau das haben wir zu beobachten begonnen.
Die technische Geschichte
Ab hier geht es in die Technik. Wenn das nicht dein Ding ist, die Kernaussage von oben ist der ganze Punkt: Das Spiel ist schneller, weil seine zeitkritischste Arbeit jetzt für sich allein läuft.
Der Ausgangspunkt
Das ursprüngliche Backend ist eine Serverpod-Anwendung, geschrieben in Dart, und es sei klar gesagt: Serverpod ist ein großartiges Framework. Es hat uns vieles geschenkt — generierte Client-SDKs, ein typisiertes Protokoll, das zwischen der Flutter-App und dem Server geteilt wird, Auth, Datenbankzugriff, WebSocket-Streams — und es hat einem kleinen Team ermöglicht, bemerkenswert schnell voranzukommen. Es betreibt bis heute den Großteil des Produkts: Authentifizierung, Nutzer, Freunde, Turniere, In-App-Käufe, Chat und Admin-Tools, und wir haben nicht vor, daran etwas zu ändern.
Das Echtzeit-Gameplay lief innerhalb desselben Prozesses. Der Server war autoritativ: Er würfelte, validierte jeden Zug gegen die Backgammon-Regeln, hielt den kanonischen Brettzustand und schob Updates über WebSocket-Streams an beide Clients. Korrekt und sauber — aber er teilte sich eine Laufzeitumgebung mit jedem anderen Endpunkt im Produkt.
Die Wand finden
Bevor wir irgendetwas änderten, wollten wir Zahlen, keine Vermutungen. Also bauten wir ein Lasttest-Harness, das synthetische Spieler erzeugt, die sich authentifizieren, ins Matchmaking gehen, in echte Spiele gematcht werden und zufällige legale Züge von Anfang bis Ende spielen — über genau dieselben WebSocket-Streams, die ein echter Flutter-Client nutzt. Keine Abkürzungen, keine gemockten Endpunkte.
Ein paar bewusste Entscheidungen machten die Ergebnisse vertrauenswürdig:
- Regionenübergreifend, getrennte Hardware. Der Lastgenerator lief in einem Rechenzentrum (Nürnberg) und griff auf den Server in einem anderen (Helsinki) zu, rund 30 ms voneinander entfernt. Das ist näher an einem echten Nutzer als ein Benchmark auf derselben Maschine, und es garantierte, dass der Lastgenerator dem gemessenen Server keine CPU stehlen konnte.
- Automatisch hochskalierende Parallelität. Das Harness verdoppelte die Anzahl gleichzeitiger Spieler nach einem festen Zeitplan und erfasste alle fünf Sekunden einen Snapshot: Authentifizierungslatenz, Matchmaking-Latenz und Zug-Bestätigungslatenz bei p50/p95/p99, dazu Fehler- und Spiel-Timeout-Zähler.
- Ehrliche Abbruchschwellen. Wir definierten „kaputt” im Voraus — Zug-Ack p95 über 1,5 s oder Zug-Fehlerrate über 2 % — sodass der Test Versagen objektiv feststellte, statt dass wir eine Grafik nach Augenmaß interpretierten.
Die Form der Ergebnisse war eindeutig. Bei geringer Parallelität war der Server vollkommen gesund: Mit ein paar Dutzend Spielern kamen Zugbestätigungen in etwa 100–180 ms zurück. Aber als das Harness hochskalierte — 80, dann 160, dann 320 gleichzeitige Spieler — verschlechterte sich der Echtzeit-Pfad drastisch. Der Zug-Ack p95 kletterte in den Mehrsekundenbereich, der p99-Ausläufer streckte sich weit schlimmer, das Matchmaking wurde langsamer, und Spiele begannen am Vier-Minuten-Aufgabe-Timeout zu sterben: zunächst eine Handvoll, dann Dutzende. Irgendwo um die 320-Spieler-Marke lösten die Abbruchschwellen aus.
Die wichtige Erkenntnis war nicht „Dart ist langsam” oder „Serverpod kann das nicht” — beides ist unwahr, und beide betreiben bis heute problemlos den Rest von Gammon42. Sie war einfacher: Ein universelles Request-Handling-Modell, das jeden schwergewichtigen Endpunkt im Produkt gleichzeitig trägt, ist nicht die ideale Form für einen Schwall winziger, latenzkritischer Spielnachrichten. Das gilt für fast jedes All-in-One-Framework — der Hot-Path hatte seine eigenen Anforderungen entwickelt und brauchte sowohl ein genau darauf abgestimmtes Parallelitätsmodell als auch seine eigene Laufzeitumgebung, damit er aufhörte, mit allem anderen zu konkurrieren.
Die Entscheidung: ein Sidecar, kein Neuschreiben
Der verlockende Schritt — das ganze Backend neu zu schreiben — war auch der falsche. Der Großteil von Serverpods Oberfläche (Konten, IAP, Turniere, Chat) funktionierte einwandfrei und hatte nichts mit dem Engpass zu tun. Es neu zu schreiben wäre ein enormes Risiko bei null Nutzen gewesen.
Stattdessen nutzten wir das Sidecar-Muster. Wir bauten einen kleinen, separaten Dienst, dem nur der Echtzeit-Spiel-Hot-Path gehört, und ließen Serverpod alles andere besitzen. Die beiden laufen nebeneinander auf demselben VPS und teilen sich dasselbe Postgres und Redis, sodass es zwischen ihnen kein Daten-Synchronisationsproblem gibt — nur eine saubere Aufteilung der Verantwortlichkeiten. Der Wirkungsradius war klein, die Einführung konnte schrittweise erfolgen, und falls etwas schiefging, konnten wir zurückfallen.
Für diesen Dienst wählten wir Go, und zwar gerade wegen der Art, wie es mit Parallelität umgeht.
Die Architektur: eine Goroutine pro Raum
Das Kerndesign ist eine Goroutine pro Spielraum.
In Go ist eine Goroutine eine extrem leichtgewichtige Einheit nebenläufiger Ausführung — sie kostet ein paar Kilobyte, nicht einen ganzen Betriebssystem-Thread — und die Go-Laufzeitumgebung multiplext viele Tausende von ihnen über die verfügbaren CPU-Kerne. Diese Eigenschaft ist es, die das Design möglich macht: Jede aktive Partie bekommt ihre eigene Goroutine, die den Zustand dieser Partie exklusiv besitzt.
Das bringt zwei Dinge, die für einen Spielserver enorm wichtig sind:
- Keine Locks auf dem Hot-Path. Weil eine einzelne Goroutine den Zustand jedes Raums besitzt, werden jeder Würfelwurf, Zug und jedes Brett-Update dieser Partie nacheinander von dieser Goroutine verarbeitet. Andere Teile des Systems sprechen mit einem Raum, indem sie ihm einen Befehl senden, nicht indem sie hineingreifen und seine Daten anfassen. Es gibt keinen geteilten veränderlichen Zustand, um den auf dem Hot-Path gerungen werden müsste, was bedeutet: keine Lock-Konkurrenz und keine Data Races — die ganze Klasse von Nebenläufigkeitsbugs wird wegkonstruiert statt wegdebuggt.
- Natürliche Isolation. Ein geschäftiger oder sich fehlverhaltender Raum kann keinen anderen beschädigen oder blockieren. Zehntausend stille Räume kosten fast nichts, während sie warten.
Um diese Raum-Goroutinen herum sitzt eine schlanke unterstützende Besetzung:
- Ein prozessweiter Room-Hub — eine einfache Map hinter einem leseoptimierten Lock — der O(1)-Nachschlagen eines Raums per ID ermöglicht. Der Lock wird nur für das mikroskopische Nachschlagen gehalten; die eigentliche Arbeit pro Raum passiert hinter der jeweils eigenen Goroutine des Raums, sodass der Hub nie zum Engpass wird.
- Eine nicht-blockierende WebSocket-Schicht. Die Read-Loop der Verbindung tut so wenig wie möglich: Sie dekodiert einen eingehenden Nachrichten-Umschlag und übergibt ihn an die richtige Raum- oder Lobby-Goroutine. Schwere Arbeit passiert nie auf dem Socket-Read-Pfad, sodass eine langsame Operation die Verbindung nicht blockieren kann.
- Ein Connection-Pool zu Postgres (via
pgx) und ein kompaktes, versioniertes Wire-Protokoll, dessen Nachrichtentypen bewusst Serverpods Enums widerspiegeln. Dieses letzte Detail ist es, das die Flutter-App mit dem neuen Dienst über dasselbe geteilte Vokabular sprechen ließ, das sie bereits kannte — wir fügten auf der App-Seite einen dedizierten Spiel-Client hinzu und konnten den Live-Traffic Partie für Partie auf den Go-Dienst migrieren, statt in einem riskanten Umschalten auf einmal.
Das Ganze baut sich in einen winzigen Container: ein mehrstufiger Docker-Build, der ein distroless-Laufzeit-Image erzeugt. Kleines Image, schneller Start und ein bescheidener Speicher-Fußabdruck — was zählt, wenn es sich eine Maschine mit dem Haupt-Backend teilt.
Warum das unter Last schneller ist — ehrlich
Wir sind hier bewusst vorsichtig: Wir haben eine rigorose Baseline für den alten Pfad (die Lasttests oben), und wir werden keine präzisen „Nachher”-Zahlen nennen, für die wir die Daten nicht veröffentlicht haben. Aber die Gründe, warum das neue Design unter Parallelität besser standhält, sind struktureller, nicht hoffnungsvoller Natur:
- Parallelität, die zur Arbeitslast passt. Eine Live-Partie lässt sich natürlich als unabhängiger, langlebiger, nachrichtengesteuerter Akteur modellieren. Goroutine-pro-Raum bildet das eins zu eins ab. Tausende gleichzeitiger Partien werden zu Tausenden billiger Goroutinen, die die Laufzeitumgebung über jeden Kern verteilt — statt Tausender Requests, die sich durch ein Modell stauen, das für eine andere Traffic-Form gebaut wurde.
- Keine Hot-Path-Konkurrenz. Den Zustand jedes Raums durch seine eigene Goroutine zu serialisieren, entfernt das Locking aus dem Teil des Systems, der am häufigsten läuft und am meisten zählt. Weniger Koordinationsaufwand bedeutet vorhersehbarere Latenz, besonders im p99-Ausläufer, wo der alte Pfad am meisten wehtat.
- Ressourcenisolation. Der Echtzeit-Dienst und das Haupt-Backend teilen sich keine Laufzeitumgebung mehr. Ein Ansturm von Matchmaking an einem geschäftigen Abend kann den Code, der deinen Zug validiert, nicht aushungern, und umgekehrt. Jeder kann für sich skaliert, profiliert und durchdacht werden.
- Eine kompilierte, vorhersehbare Laufzeitumgebung. Ein natives Go-Binary mit einem Garbage Collector niedriger Latenz bewältigt Schübe winziger, häufiger Nachrichten gleichmäßig — genau das Traffic-Profil von Live-Backgammon.
Was wir behalten haben, und was wir dir raten würden zu kopieren
Der Teil, mit dem wir am zufriedensten sind, ist nicht die Sprachwahl — es ist die Zurückhaltung. Wir haben kein funktionierendes System neu geschrieben. Wir haben die eine Komponente identifiziert, deren Anforderungen von allem drumherum abgewichen waren, dieser Komponente die Laufzeitumgebung gegeben, die sie tatsächlich brauchte, und den Rest in Ruhe gelassen. Geteilte Datenbank, geteilter Cache, kompatibles Protokoll, schrittweise Migration, einfaches Zurückfallen.
Wenn du eine Sache hieraus mitnimmst: Bevor du zu einem Neuschreiben greifst, teste ehrlich genug unter Last, um die echte Wand zu finden, und verschiebe dann nur das, was tatsächlich dagegenstößt.
Willst du den Unterschied selbst spüren? Du kannst Backgammon kostenlos online spielen — ohne Konto — gegen echte Gegner in schnellen, zuverlässigen Multiplayer-Partien, direkt in deinem Browser auf play.table-online.ro. Lieber auf dem Handy? Hol dir Gammon42, unser Online-Backgammon-Würfelspiel, aus dem App Store oder von Google Play und würfle gegen Spieler auf der ganzen Welt.
Würfle gut. 🎲
Bereit zu spielen?
Jetzt spielen