© StudioByTheSea/Shutterstock.com
Kartenspielen trotz Kontaktbeschränkung

Mau-Mau in Go


Dieses kleine Projekt ist kurz vor den Osterferien entstanden. Das Ziel war es, das Kartenspiel Mau-Mau als Onlinespiel umzusetzen, damit meine Tochter trotz der Kontaktbeschränkungen gegen ihren Opa Karten spielen kann. Außerdem wollte ich die Zeit zu Hause auch für ein nicht ganz so ernstes Projekt nutzen. Warum nicht einfach mal ein Spiel mit Go schreiben?

Wie Mau-Mau funktioniert, wird auf Wikipedia unter [1] beschrieben. Je nach Region hat das Spiel auch andere Namen, wie z. B. „Neunerln“, „Auflegen“ oder „Tschau Sepp“. Die Regeln sind im Grundsatz gleich, jedoch unterscheiden sich die Spielvarianten in kleinen Details. Aus diesem Grund werden wir den Spielern so viele Freiheiten wie möglich lassen. Das heißt, die Spieler sollten während des Spiels mindestens miteinander sprechen können, denn Kartenspielen hat ja auch etwas Kommunikatives. Das lässt sich in der aktuellen Situation per Telefon oder Skype ganz leicht umsetzen. Bei der Umsetzung war ich überrascht, wie viele Konzepte und Basics in dieser einfachen Umsetzung stecken. Diese möchte ich in diesem Artikel gerne vorstellen.

Folgende spannende Themen erwarten dich deshalb in den folgenden Absätzen:

  • Eine schnelle Umsetzung von Event Sourcing mit Go

  • Wie wir Closures geschickt als Event verwenden können

  • Wie Clients Nachrichten vom Server via WebSockets erhalten

  • Das JSON-Encoding von Daten

  • Embedding bzw. Einbinden von Strukturen

Das Spiel wird über einen Server verwaltet, und die Spieler sollen sich über einen normalen Webbrowser verbinden. Der Browser ist dabei die einfachste Art, den Client zu gestalten, denn Go bringt hierfür alles Notwendige mit und ein Browser ist auch auf jeder Plattform an Bord. Um unser Spiel möglichst einfach zu gestalten, implementieren wir es mit CSS und HTML nur für zwei Spieler. Die Datenstrukturen auf dem Server legen wir aber schon einmal flexibel an, sodass auch irgendwann mehr als zwei Spieler möglich sind.

Abbildung 1 zeigt, wie unser Spiel am Ende im Browser aussehen wird. Dabei ist der Bereich in drei große Segmente gegliedert:

  • oben: die Rückseiten der Karten des Gegners

  • mitte: der Kartenstapel zum Ziehen neuer Karten und der Stapel zum Ablegen

  • unten: die eigenen Karten und ein paar Steuerungsknöpfe

Die Umsetzung des Spiels soll Event-basiert sein. Das bedeutet, dass jede Aktion eines Spielers als Event gespeichert wird. Der jeweilige Spielstand ergibt sich durch die Summe aller Events. Bei Mau-Mau gibt es nur ein paar unterschiedliche Aktionen. Ein Spieler kann eigentlich nur Karten nehmen oder ablegen.

schroepfer_maumau_1.tif_fmt1.jpgAbb. 1: So sieht das Spiel am Ende im Browser aus

Um den Rahmen des Artikels nicht zu sprengen, werde ich hier nur die wichtigsten Ausschnitte des Codes vorstellen. Der komplette und lauffähige Code zum Artikel befindet sich unter [2]. Ich habe dafür ein eigenes Repository angelegt, damit die hier gezeigten Listings auch mit dem Code auf GitHub übereinstimmen. Das ursprüngliche Projekt selbst erfährt laufend Änderungen und liegt unter [3].

Architektur

Den HTTP-Server für das Backend werden wir in Go umsetzen. Denn die Standardbibliothek bietet mit dem HTTP-Paket fast schon alles, was wir benötigen. Zusätzlich benötigen wir für die WebSocket-Funktionalität das Gorilla Web Toolkit [4].

Das Frontend wird in HTML und CSS umgesetzt. Der jeweilige Spielstand wird als JSON über den WebSocket an den Browser geschickt und dort clientseitig gerendert. Für das Rendern verwenden wir Vue.js. Dieses JavaScript Framework benötigt nicht zwingend weitere JavaScript-Tools. Wir können somit alles direkt in einer statischen HTML-Seite schreiben und müssen uns nicht groß mit irgendwelchen Bundlern oder abhängigen JavaScript-Modulen herumärgern. Sobald die Seite im Browser geladen ist, soll sie dann über JavaScript die WebSocket-Verbindung zum Spielserver herstellen. Sobald Daten gesendet wurden, soll der jeweilige Spielstand gerendert werden.

schroepfer_maumau_2.tif_fmt1.jpgAbb. 2: Der schematische Aufbau des Projekts

In Abbildung 2 ist der Aufbau unseres Projekts schematisch dargestellt. Das Spiel bilden wir über die Struktur Game ab. Dort werden die Events erfasst und der jeweilige Spielstand aggregiert.

Der Bereich Routes definiert die Schnittstelle des Servers. Jedem Endpunkt wird dabei ein HTTP Handler zugeordnet. Ein Endpunkt definiert sich über den URL. Über den Pfad /playcard teilt der Browser eines Spielers die Karte mit, die gerade gespielt wird. Diese Aktion wird unter Routes einer Funktion zugeordnet. Auf dieser Ebene validieren wir die Aktion des Spielers und leiten sie nach erfolgreicher Validierung als Event an das Spiel weiter.

Anschließend führen wir intern alle Events des Spiels aus und erhalten dann einen State, den wir über die WebSocket-Verbindungen an alle Spieler schicken. Intern werden wir für jede Verbindung eine eigene Goroutine verwenden und den State über einen Channel einzeln an jeden Client schicken.

Implementierung

Nachdem wir jetzt den groben Aufbau kennen, beginnen wir mit der Implementierung. Da Go – als kompilierte Sprache – sehr flott läuft, ist Geschwindigkeit normalerweise kein Thema. Deshalb werden wir die einzelnen Features einfach implementieren und uns keine Gedanken bezüglich des Speicherbedarfs oder der Geschwindigkeit machen.

Organisation des Codes

Wenn wir mit einem kleinen Projekt beginnen, ist es sinnvoll, eine flache Struktur zu wählen, d. h., im ersten Iterationsschritt schreiben wir unseren Code direkt im main-Paket. Sollte das Projekt in Zukunft weiterwachsen, kann es sinnvoll sein, die Funktionalitäten in einzelne Pakete aufzuteilen. Das Verschieben von Code in ein anderes Paket ist in Go relativ sicher, da uns der Compiler dabei hilft. Denn dieser gibt einen Fehler aus, wenn ein Typ oder eine Methode fehlen sollte, oder wenn wir beim Aufruf des ausgelagerten Codes noch etwas vergessen haben.

Aber warum ist es in diesem konkreten Fall nicht sinnvoll, weitere Pakete zu erstellen? Gemäß der Unix-Philosophie sollte ein Paket auch nur eine Aufgabe erfüllen. Wenn das erfüllt ist, dann ist es auch ganz leicht, einen Namen für das Paket zu finden. Dieser sollte die Funktion widerspiegeln. Allgemeine Namen wie model oder helper sind eher ungeeignet, wobei time oder http ganz klar die Funktion des Pakets verraten.

Für unser Spiel könnten wir also ein Paket Mau-Mau erstellen, das die Spielelogik beinhaltet. Dieses Paket würde ich auch nur für dieses Projekt freigeben. Dafür gibt es in Go das Verzeichnis internal. Für unser kleines Projekt würde diese zusätzliche Aufteilung nur unnötige Komplexität erzeugen, deshalb schreiben wir unseren gesamten Code im main-Paket. Das hat den großen Vorteil, dass wir uns auch keine Gedanken über zusätzliche Schnittstellen zwischen den Paketen machen müssen. Deshalb werden wir so viele Elemente wie möglich als unexported (also in Kleinschreibung) deklarieren.

Da wir unsere Daten als JSON zwischen Server und Browser hin- und herschicken werden und für das En- und Decoding das json-Paket verwenden, müssen alle Elemente für den Datenaustausch exported sein. Denn das Paket muss ja die Werte für das JSON aus der Struktur auslesen, und das funktioniert nur, wenn diese Elemente auch exported sind. Bis auf diese Ausnahme werden wir deshalb alle anderen Elemente als nicht exportiert definieren.

Typdefinition von event

Zu Beginn definieren wir, wie die Ereignisse des Spiels abgebildet werden. Das Spiel soll komplett über Events gesteuert werden. Das bedeutet, dass nur Events den Spielstand verändern sollen. Wir verwenden dafür eine Funktion, die als Input *Game (Pointer auf Game) besitzt:

type event func(g *Game)

Da in Go Funktionen sogenannte First Class Citizens sind, können wir diese wie jeden anderen Typ verwenden. So können wir die Events in einem []event speichern. Über dieses Slice können wir dann iterieren und die Events der Reihe nach ausführen.

Typdefinition von Game

Für das Spiel definieren wir den Typ Game (Listing 1). Dieser Typ muss alle Karten des Spiels verwalten. Der Spielstand definiert sich somit darüber, wo sich die einzelnen Karten während des Spiels befinden.

Der gemischte umgedrehte Kartenstapel ist der Stack, die abgelegten Karten der Heap. Die oberste aufgedeckte Karte ist HeapHead. Die Spieler werden unter Players gespeichert. Mit ActivePlayer merken wir uns, welcher Spieler gerade an der Reihe ist. Zusätzlich speichern wir unter events alle Aktionen der Spieler als []event.

Listing 1: Game

type Game struct { Stack *CardStack `json:"stack"` Heap *CardStack `json:"heap"` HeapHead Card `json:"heap_head"` Players []*Player `json:"players"` ActivePlayer int `json:"active_player"` NrCards int `json:"nr_cards"` events []event }

Den Typ Game müssen wir als exportiert definieren. Auch alle Felder, die den Spielstand abbilden, müssen großgeschrieben werden. Das ist nötig, da diese Daten als JSON ausgegeben werden. Da in JSON die einzelnen Parameter klein dargestellt werden sollen, verwenden wir dafür JSON-Tags.

Das Einzige nicht exportierte Feld ist events. Da dort nur Funktionen gespeichert sind, ist es nicht sinnvoll, diese in JSON umzuwandeln. Dafür reicht es aus, das Feld als nicht-exportiert auszusteuern.

Als Nächstes schreiben wir ein paar hilfreiche Methoden (Listing 2). Mit event können wir dem Spiel ein neues Ereignis hinzufügen. Mit init() löschen wir den aktuellen Stand.

Listing 2: Spielstand erzeugen

func (g *Game) event(e event) { g.events = append(g.events, e) } func (g *Game) init() { g.Stack = &CardStack{} g.Heap = &CardStack{} g.Players = []*Player{} } func (g *Game) state() { g.init() for _, e := range g.events { e(g) } g.HeapHead = g.Heap.peek() }

Mit state() führen wir alle events nacheinander aus und erzeugen den aktuell...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang