Christoph Hofer ← Zurück zur Startseite
Blog · Projekte

Aotera: Wie aus Feierabend-Code ein eigenes Kartenspiel wurde

Juli 2026 · Christoph Hofer · ~14 Min. Lesezeit

.NET SignalR Angular Azure Gamedev

Ich spiele seit Ewigkeiten Kartenspiele und baue fast genauso lange Software. Irgendwann habe ich beschlossen, beides zu kombinieren. Das Ergebnis heisst Aotera: ein Echtzeit-Multiplayer-Kartenduell, bei dem nicht nur die Karten, sondern auch das Terrain über den Sieg entscheidet.

Larina Blackfeather, eine legendäre Kreaturenkarte aus Aotera
Larina Blackfeather, eine der legendären Karten aus Aotera

Die Idee: Terrain als Waffe

Die meisten digitalen Kartenspiele spielen sich auf einem neutralen Tisch ab: zwei Spieler, ein paar Karten, fertig. Bei Aotera liegt zwischen den Spielern ein Hex-Terrain, das jede Kreatur unterschiedlich beeinflusst. Ein Sumpf macht aus einem unscheinbaren Untoten eine ernsthafte Bedrohung, ein Berg macht aus einem Drachen eine Festung. Die eigentliche Strategie beginnt also nicht beim Deckbau, sondern bei der Frage: Wo spiele ich diese Karte?

Dazu kommt: Aotera läuft in Echtzeit. Kein Warten auf Rundentimer, beide Spieler spielen gleichzeitig. Genau diese Entscheidung, Echtzeit statt striktem Rundenwechsel, hat im Backend fast alles schwieriger gemacht, als ich anfangs dachte.

Flameseer Oracle
Flameseer Oracle
Naga Tidecaller
Naga Tidecaller
Plaguehusk
Plaguehusk
300+
Karten
10
Terrains
Free
To Play

Ab hier wird es technisch. Die Kurzfassung: Das Spiel läuft, macht Spass und ist kostenlos spielbar unter aotera.ch. Wer wissen will, wie es im Detail funktioniert, liest einfach weiter.

Backend: Echtzeit ohne Chaos

Der Server läuft auf .NET mit SignalR als Transportschicht. Das Grundproblem: Sobald beide Spieler gleichzeitig agieren dürfen, kann derselbe Spielzustand von zwei Requests parallel verändert werden. Ohne Schutz führt das zu Race Conditions, die man im Livebetrieb nur einmal erleben will.

Nebenläufigkeit: ein Lock pro Match, nicht global

Die Lösung ist bewusst simpel: Jeder laufende Match hat sein eigenes SemaphoreSlim. Jede zustandsverändernde Aktion (Karte ausspielen, Angreifer setzen, Blocker setzen, Kampf auflösen) läuft durch denselben schmalen Wrapper:

private async Task WithMatchAsync(string roomId, Func<Match, Task> action)
{
    var match = _state.GetMatch(roomId);
    if (match == null) return;

    await match.Lock.WaitAsync();
    try { await action(match); }
    finally { match.Lock.Release(); }
}

Aktionen zweier Spieler, die "gleichzeitig" ankommen, werden am Semaphore serialisiert, statt sich gegenseitig den Zustand zu überschreiben. Das Matchmaking hat ein eigenes, separates Lock, damit zwei fast zeitgleiche Queue-Joins nicht denselben Gegner doppelt zugewiesen bekommen. Die Zustandsverwaltung (wartende Spieler, aktive Matches, Mapping von Username zu Connection-ID) läuft über ConcurrentDictionary, damit schon die Suche nach einem Match thread-sicher ist, unabhängig vom Match-Lock.

Ehrlich gesagt

Im Match-Modell gibt es ein Revision-Feld mit einer NextRevision()-Methode, ein Überbleibsel eines früheren Plans für echtes State-Versioning. Es wird heute nirgends mehr aufgerufen. Die tatsächliche Nebenläufigkeitssicherheit kommt komplett vom Lock, nicht von einer Versionsnummer. Steht auf der Liste für das nächste grössere Refactoring.

Reconnect, ohne dem Gegner ins Blatt zu schauen

Bricht die Verbindung während eines Matches ab (Gerätewechsel, Browser-Refresh, WLAN-Aussetzer), darf das Spiel weder abstürzen noch dem Gegner Informationen zeigen, die er nicht sehen sollte. Das war architektonisch der unangenehmste Teil.

Der Zustand auf dem Server ist pro ConnectionId indiziert, nicht pro stabiler Spieler-ID. Verbindet sich ein Spieler neu, bekommt er eine neue SignalR-Connection-ID, und plötzlich zeigen sämtliche Referenzen im laufenden Match ins Leere: das Terrain des Spielers, wer gerade am Zug ist, der Besitzer jedes aktiven Buffs, der Besitzer jeder Karte in Hand, Deck, Friedhof und auf dem Feld. Der Reconnect-Pfad muss all das in-memory umschreiben, bevor der Spieler wieder etwas sieht.

Der Teil, der mich am meisten gefreut hat, als er endlich sauber lief: Das Snapshot-Objekt, das beim Reconnect an den Client geht, baut bewusst zwei unterschiedliche Sichten. Die eigene Hand komplett, vom Gegner nur die Anzahl der Karten. Im Code markiert ein Kommentar die Grenze explizit:

// Gegner (nur öffentliche Infos)
OpponentHandCount = match.Opponent.Hand.Count;
// (nicht: OpponentHand = match.Opponent.Hand)

Klein, aber genau die Art von Detail, das man beim ersten Entwurf vergisst und nach dem ersten Community-Bugreport schmerzhaft nachbessert.

Noch nicht ganz sauber

Aktuell gibt es zwei unterschiedliche Timeout-Werte im Disconnect-Pfad: eine erste 45-Sekunden-Warnung an den Gegner und ein separates 10+10-Sekunden-Schema, das den Auto-Sieg tatsächlich auslöst. Funktioniert, ist aber nicht konsistent benannt. Steht auf meiner Liste, bevor ich das nächste Mal öffentlich über "die 20 Sekunden Reconnect-Zeit" rede.

Der KI-Bot: kein Deep Learning, aber ehrlich schlau

Damit niemand vor einer leeren Warteschlange steht, springt bei Bedarf ein Bot ein. Kein Machine-Learning-Modell, kein Minimax mit Suchtiefe, sondern ein regelbasiertes System mit drei Schwierigkeitsstufen, das auf dieselben Update-Events reagiert, die auch ein echter Client bekommt.

Auf "Schwer" rechnet er zusätzlich aktiv auf Lethal hin und hält bewusst einen Blocker zurück, wenn er im Gegenzug sonst selbst sterben würde. Die gesamte Entscheidungslogik läuft ausserdem durch einen defensiven Wrapper, der Exceptions abfängt. Ein Bug in der KI soll im schlimmsten Fall einen Zug überspringen, aber niemals den Match für den menschlichen Gegner einfrieren.

Void Walker
Void Walker
Mana Spring
Mana Spring

Karten-Effekte: halb generisch, halb bewusst fix verdrahtet

Ein datengetriebenes Effektsystem war von Anfang an das Ziel: Neue Karten sollen möglichst ohne Code-Änderung ins Spiel kommen. Erreicht habe ich das nur teilweise, und das darf man auch so sagen.

Am Ende ist es ein Zwei-Tier-System: Die Hälfte, die datengetrieben sein sollte, ist es auch wirklich. Die andere Hälfte ist ein ehrlicher, wartbarer Switch-Block. Keine perfekte Rule Engine, aber auch keine Lüge in der Doku.

Logout bei stateless JWTs

Ein klassisches Problem: Ein JWT ist per Design stateless. Der Server kann ein einzelnes Token nicht zurückrufen, bevor es abläuft. Für einen Logout-Button reicht das nicht. Die Lösung ist bewusst klein gehalten: ein In-Memory-ConcurrentDictionary, das pro widerrufenem Token dessen jti und die ursprüngliche Ablaufzeit speichert. Jeder Widerruf räumt nebenbei bereits abgelaufene Einträge auf. Kein Hintergrund-Timer, das wird einfach lazy beim nächsten Logout miterledigt. Geprüft wird direkt in der JWT-Validierungs-Pipeline, nicht erst irgendwo weiter unten im Code.

Bewusste Einschränkung: Das ist ein Single-Process-Speicher und skaliert nicht über mehrere Serverinstanzen. Dafür müsste man auf einen gemeinsamen Store wie Redis wechseln. Da Tokens ohnehin nur eine Stunde gültig sind, ist der Schaden im Zweifel überschaubar. Aber "produktionsreif verteilt" ist etwas anderes, und das behaupte ich auch nicht.

Frontend: Echtzeit-UI ohne Datenchaos

Auf der Angular-Seite bringt dieselbe Echtzeit-Entscheidung ein eigenes Problem mit: Die UI muss reagieren, sobald ein SignalR-Event reinkommt, nicht erst beim nächsten Klick des Nutzers.

OnPush + gezieltes markForCheck()

Praktisch jede Gameplay-Komponente läuft mit ChangeDetectionStrategy.OnPush und ruft nach jeder zustandsverändernden SignalR-Nachricht gezielt cdr.markForCheck() auf. Der zentrale Update-Stream, durch den fast alle Spielaktionen laufen, ist zusätzlich explizit in NgZone.run() gewrappt, damit Angular garantiert mitbekommt, dass sich etwas geändert hat, egal wie SignalR intern seine Reconnect- und Backoff-Timer plant. Kein Autopilot, sondern bewusst defensiv um genau den einen hochfrequenten Datenpfad herum, der die meisten UI-Updates auslöst.

Das Hex-Feld: kein Koordinatensystem, sondern Handarbeit

Ich hatte erwartet, dass das Hex-Feld der Teil mit der cleveren Koordinatenmathematik wird: axiale oder Cube-Koordinaten, wie man sie aus klassischen Hex-Grid-Tutorials kennt. Am Ende ist es simpler und deutlich pragmatischer: Pro Spielerseite gibt es genau fünf Terrain-Slots, jeder mit fest verdrahteten Pixel-Offsets für Position und Perspektive. Die Sechseckform kommt über clip-path: polygon(...), nicht über SVG. Für die leicht isometrische 3D-Optik liegt das ganze Spielbrett in einem Container mit perspective und rotateX(45deg).

Der Bug, der am längsten wehgetan hat

Drag & Drop, Zielauswahl für Zauber, Rechtsklick-Menüs: Alles muss wissen, über welchem Hex-Feld sich der Mauszeiger gerade befindet. Der naheliegende Weg wäre document.elementFromPoint(). Funktioniert hier aber nicht zuverlässig: Chrome und Edge identifizieren clip-path-Elemente innerhalb eines 3D-Transform-Kontexts (preserve-3d/perspective) schlicht falsch. Die Lösung war, das gesamte Hit-Testing manuell zu bauen, über @ViewChildren und deren getBoundingClientRect(), statt sich auf die Punktabfrage des Browsers zu verlassen. Nicht elegant, aber die einzige Variante, die in allen getesteten Browsern gleich funktioniert hat.

Kampflinien ohne extra Library

Die Linien, die im Kampf zwischen Angreifer und Blocker gezeichnet werden, sind ebenfalls selbst gebaut: Positionsdifferenzen über getBoundingClientRect(), daraus eine quadratische Bézierkurve berechnet und als SVG-Pfad gerendert. Für zwei, drei animierte Linien pro Kampf wäre eine Grafik-Library klarer Overkill gewesen.

Was ich daraus mitnehme

Keiner dieser Punkte war für sich genommen unlösbar. Aber in Summe zeigen sie gut, warum "Echtzeit statt Runden" eine Entscheidung ist, die sich durch den gesamten Stack zieht, nicht nur durch eine einzelne Komponente. Nebenläufigkeit, Hidden Information beim Reconnect, ein Bot, der dieselben Regeln wie der Server anwenden muss, und eine UI, die schneller reagiert, als der Nutzer klickt: Das alles hängt an derselben Grundentscheidung.

Aotera ist bewusst kein Angriff auf Hearthstone oder MTG Arena. Es ist ein Ein-Personen-Projekt, das inzwischen vom Verein Grammont Games getragen wird. Das Ziel ist eine kleine, treue Community, die das Spiel weiterträgt, nicht die Eroberung des Markts.

Aotera jetzt spielen

Kostenlos, kein Download, direkt im Browser. Der Verein steht dahinter, die Karten entscheiden den Rest.