Aotera: Wie aus Feierabend-Code ein eigenes Kartenspiel wurde
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.
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.
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.
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.
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.
- Kartenwahl: Terrain zuerst (schaltet Felder frei), danach Kreaturen aufsteigend nach Manakosten, der Rest nach Kosten sortiert. Auf "Leicht" wird stattdessen einfach gewürfelt.
- Zielwahl bei Zaubern: abhängig vom Effekt. Bei Schadenszaubern sucht der Bot zuerst nach einem garantierten Kill unter den wertvollsten Zielen, sonst nimmt er die grösste Bedrohung ins Visier. Bei Heilung wählt er die Kreatur, der prozentual am meisten Leben fehlt.
- Kampfsimulation: Der Bot rechnet Trades durch, bevor er sich festlegt, und zwar mit denselben Todes- und Keyword-Regeln (Deathtouch, Flying-Blockrestriktionen), die auch der Server bei der echten Kampfauflösung anwendet. Ein Kommentar im Code bringt es auf den Punkt: Die Bot-Logik spiegelt bewusst die Serverregeln, damit die Vorhersage des Bots mit dem übereinstimmt, was danach wirklich passiert.
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.
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.
- Passive Stat- und Keyword-Effekte ("+1/+1 auf Wald-Terrain", "+1/+1 für
jeden anderen Drachen") sind reine Datenzeilen (
EffectType,PowerDelta,RequiredTerrainund Co.), die von einer kleinen, wirklich generischen Switch-Logik ausgewertet werden. Neue Tribal-Synergien dieser Art brauchen keine einzige Zeile neuen Code. Mana Spring oben ist so ein Fall: eine reine Datenkarte, keine Sonderbehandlung im Code. - Aktive Zauber- und Buff-Effekte (Schaden, Heilung, Einfrieren, Bounce,
Seelenernte) sind dagegen ein klassisches
switchüber ein festes Enum. Ein wirklich neuer Effekttyp, nicht nur neue Zahlen, braucht weiterhin eine Codeänderung.
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).
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.