Inhalt

Mitte Oktober war es nun wieder mal Zeit für den 60. Ludum Dare, diesmal mit dem Thema "Old Tech". Mein Ansatz war, dies technisch recht wörtlich zu nehmen. Somit machte ich mich auf, ein Mainframe-Spiel zu bauen, für welches sich die Spielenden auf diesem einloggen müssen und dann in einer gemeinsam dort geteilten Welt spielen. Geboren war TERMINALWARS, der Name ist dabei an Klassiker wie Decwar (1978 auf dem PDP-10), MegaWars & TradeWars 2002 (CompuServe bzw. Mailboxen, 1980er bis 1990er) angelehnt. Gespielt hatte ich die Klassiker selbst nie, dafür aber spätere Browsergames wie OGame oder GalaxyWars.

Das Spiel

Daraus entstanden ist nun ein im Terminal spielbares Mainframe-Spiel, in dem ihr einen kleinen Außenposten in einem Sektor voller Wracks betreibt, den Hinterlassenschaften einer verschollenen Zivilisation. Ihr schickt Scouts zu den Relay Beacons und Data Vaults in der Nachbarschaft, greift mit Fightern an und holt mit Haulern Titan und vor allem AI-Cores nach Hause. Ab Stufe drei braucht fast jedes Bauprojekt AI-Cores, das Dock für Fregatten und Kreuzer zum Beispiel oder ein Schild. Die größten Wracks sind die Mainframes dieser Zivilisation, die mit bis zu 60 Geschützen verteidigt werden und für die ihr schon eine ordentliche Flotte zusammenbekommen müsst.

Animation der ersten Minute in TERMINALWARS: Einwahl per Modem, Login mit Callsign, erstes Upgrade, Scout, Angriff auf ein Wrack und der Kampfbericht
Die erste Minute: Einwahl, Login, Upgrade, Scout, Angriff und der Bericht

Ausprobieren könnt ihr es direkt im Browser auf terminalwars.darwyn.io, zum Zugang per ssh steht weiter unten mehr.

Die Welt läuft weiter, wenn ihr offline seid. Die Bohrer fördern, die Werft baut, Flotten kommen heim und die Wracks um euch herum füllen sich nach. Wer am nächsten Tag wieder einwählt, bekommt als Erstes eine Bilanz, was in der Zwischenzeit passiert ist.

Jedes ◆ auf der Karte ist ein anderer Spieler. Nach 24 Stunden Neulingsschutz könnt ihr euch gegenseitig angreifen oder ihr schließt euch zu einer Corporation zusammen. Beitreten kann man über einen fünfstelligen Code, den ihr selbst weitergebt, zum Beispiel in den Kommentaren auf der LD-Seite. Innerhalb der Corp teilt ihr Scout-Berichte, schickt euch Titan und Cores und stationiert Schiffe beim Außenposten eines Mitglieds, wenn der gerade Besuch erwartet.

Bewusst nicht drin

So ein Spiel in 72 Stunden umzusetzen ist nur möglich, wenn auch Features weggelassen werden. Damit dies in der Zeit zu schaffen war, habe ich typische Funktionen wie Forschung, gemeinsame Angriffe, Handelsposten, Kolonisierung und damit mehrere Basen und auch Textkommunikation weggelassen. Gerade der letzte Punkt war ein großer Einschnitt, ein Multiplayer-Spiel ohne Chat ist erst mal ungewohnt. Freitext müsste allerdings jemand moderieren und bei einem Jam-Spiel sitze ich nun mal nicht rund um die Uhr daneben. Das an einen KI-Dienst auszulagern, der mitliest, kam für mich aus Datenschutzgründen auch nicht infrage. Geblieben sind Signale, kurze feste Meldungen zu dem, was gerade ausgewählt ist, etwa "need 2,000 Ti at 3:0" oder "under attack by osprey, need support". Ihr wählt nur die Meldung aus, den Wortlaut setzt der Server zusammen.

Ganz ohne Moderation geht es trotzdem nicht, denn Freitext bleibt in den Namen, also Callsign, Corp-Tag und Corp-Name. Die laufen durch eine Sperrliste und jeder kann einen Namen mit /report melden. Wer über die Stränge schlägt, bekommt von mir entweder einen neuen Namen verpasst oder den Bann. Zu einem Bann gehört erst einmal eine Flotte, die die Basis aufräumt. Zwei davon habe ich eingebaut, beide gehören keinem Spieler, die Exterminatus-Flotte der Inquisition und die Anti.Nuisance.Tactical.Intervention.Fleet.Alliance. Sie halten mit zwanzig Minuten sichtbarer Anflugzeit auf den Außenposten zu, damit man zuschauen kann, wie das Unheil näher kommt, und danach ist der Außenposten ein Wrack.

Statt zu forschen, müsst ihr raus zu den Wracks. AI-Cores sind eine Ressource, für die aktiv etwas getan werden muss, und die Wracks sind etwas, das ihr angehen könnt, ohne darauf angewiesen zu sein, dass andere Spieler etwas tun. Getauscht wird nur innerhalb der Corp, dort schickt ihr euch Titan und Cores. Accounts mit E-Mail oder Discord-Login habe ich mir ebenfalls gespart, als Identität reicht der SSH-Key oder ein Cookie, dazu gleich mehr.

Die Technik dahinter

Nun zum Eingemachten, dem Tech-Stack. Das ganze Spiel ist in Go geschrieben und gar nicht mal so komplex. Der State wird hauptsächlich im Speicher gehalten und einfach als JSON-Snapshot persistiert. Das Heavy Lifting machen die Libraries von Charm, deren Namen eher nach einem Teenie-Einkaufszentrum-Besuch klingen: Bubble Tea, Lip Gloss, Bubbles und Wish. Was diese Bibliotheken ermöglichen, ist eine ziemlich einfache Möglichkeit, TUI-Anwendungen (Terminal UI) zu bauen, ganz ohne großes Rumgefummel mit Escape-Sequenzen und Cursor-Positionen.

Grob sieht der Aufbau so aus:

Architektur von TERMINALWARS: Browser und Terminal sprechen über WebSocket und SSH mit einem Go-Prozess, der pro Session ein Bubble-Tea-Programm startet; alle Programme arbeiten auf derselben Spielwelt, der Hub verteilt Ereignisse. ein Go-Prozess Browser xterm.js · Cookie-Token Terminal ssh -p 2323 · SSH-Key WebSocket-Handler coder/websocket Wish SSH-Server activeterm · Pubkey-Auth tea.Program pro Session Bubble Tea Model Lip Gloss · Bubbles World im Speicher · Snapshot alle 30 s Hub Ereignisse an die Sessions WebSocket SSH Reader/Writer Session Befehle · Sicht Ereignisse p.Send
Aufbau von TERMINALWARS: zwei Zugänge, ein Go-Prozess, pro Session ein eigenes Bubble-Tea-Programm, alle auf derselben Spielwelt.

Bubble Tea folgt der Elm-Architektur und fühlt sich dadurch ein wenig wie React an. Ein Model hält den State, eine Update-Funktion verarbeitet Messages und eine View-Funktion rendert daraus den kompletten Screen als String. Das passiert bei jeder Änderung von vorn, den Diff zum letzten Frame übernimmt die Library, ähnlich wie React das mit dem Virtual DOM macht. Ein lauffähiges Programm braucht nicht mehr als das hier, ein Zähler nach dem Muster des Library-Tutorials:

type model struct{ cores int }

func (m model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "s":
			m.cores++
		case "q", "ctrl+c":
			return m, tea.Quit
		}
	}
	return m, nil
}

func (m model) View() string {
	return fmt.Sprintf("cores: %d\n\n[s] salvage   [q] quit\n", m.cores)
}

func main() {
	if _, err := tea.NewProgram(model{}).Run(); err != nil {
		log.Fatal(err)
	}
}

Messages sind einfache Go-Typen. Tastendrücke liefert die Library selbst, alles andere kommt aus Commands, also Funktionen, die irgendwann eine Message zurückgeben, in TERMINALWARS zum Beispiel ein Timer, der jede Sekunde tickt. Um Raw-Mode, Alternate Screen, Tastencodes und das Neuzeichnen kümmert sich tea.NewProgram. Im Spiel ist das Model entsprechend größer und jeder Screen hat sein eigenes Update, das Prinzip bleibt aber dasselbe. Was andere Spieler anrichten, kommt über denselben Weg herein, der Server ruft dafür p.Send(msg) auf dem Programm der betroffenen Session auf und der Angriffsalarm landet in derselben Update-Funktion wie ein Tastendruck.

Lip Gloss übernimmt dabei in etwa die Rolle von CSS. Ein Style ist ein Wertobjekt aus Farben, Padding, Rahmen und Breite, Render wendet ihn auf einen Text an und die Join-Funktionen setzen fertige Blöcke neben- oder untereinander, ungefähr wie eine Flexbox-Zeile. Der Gewinn dabei ist, dass die Blöcke trotz der unsichtbaren Escape-Sequenzen richtig vermessen werden, denn daran scheitert man, wenn man es selbst versucht.

var (
	amber = lipgloss.Color("#e3a857")

	panel = lipgloss.NewStyle().
		Border(lipgloss.RoundedBorder()).
		BorderForeground(amber).
		Padding(0, 1).
		Width(24)
	title  = lipgloss.NewStyle().Bold(true).Foreground(amber)
	keycap = lipgloss.NewStyle().Reverse(true).Padding(0, 1)
)

func view() string {
	base := panel.Render(title.Render("BASE") + "\nTitanium  1200 / 2000\nPower       80 / 100")
	fleet := panel.Render(title.Render("FLEET") + "\nFighters   120\nFrigates     4")
	hints := keycap.Render("b") + " build   " + keycap.Render("q") + " quit"
	return lipgloss.JoinVertical(lipgloss.Left,
		lipgloss.JoinHorizontal(lipgloss.Top, base, " ", fleet),
		hints)
}

func main() { fmt.Println(view()) }

Das ergibt im Terminal diese zwei Panels:

╭────────────────────────╮ ╭────────────────────────╮
│ BASE                   │ │ FLEET                  │
│ Titanium  1200 / 2000  │ │ Fighters   120         │
│ Power       80 / 100   │ │ Frigates     4         │
╰────────────────────────╯ ╰────────────────────────╯
 b  build    q  quit

Die Farben passt Lip Gloss pro Terminal an, SSH-Clients bekommen in TERMINALWARS mindestens 256 Farben, der Browser 24 Bit, am Code ändert das nichts. Bubbles bringt schließlich fertige Komponenten mit, vergleichbar mit einer Komponentenbibliothek wie Material UI, bei mir vor allem das Text-Input für Callsign, Corp-Code und Kommandozeile. Den Rest, also Panels mit Titel im Rahmen, Tastenhinweise, Buttons und Fortschrittsbalken, habe ich auf dieser Basis in gut 200 Zeilen selbst gebaut, im Grunde ein kleines Designsystem, das auf jedem Screen gleich aussieht.

Wish schließlich ist ein SSH-Server, der für jede eingehende Session ein eigenes Bubble-Tea-Programm startet und mit dem Terminal des Clients verbindet. Der Kern davon sieht bei mir so aus:

wish.NewServer(
	wish.WithAddress(":2323"),
	wish.WithHostKeyPath(".ssh/host_key"),
	wish.WithPublicKeyAuth(acceptPubkey),
	wish.WithMiddleware(
		bubbletea.MiddlewareWithProgramHandler(newProgram, termenv.ANSI256),
		activeterm.Middleware(),
	),
)

Die activeterm-Middleware weist Verbindungen ohne Terminal ab, alle anderen bekommen ihre eigene Programminstanz. Der Fingerprint des SSH-Keys ist gleichzeitig die Identität, beim ersten Login sucht ihr euch nur noch ein Callsign aus.

Für ein Game-Jam-Spiel reicht ssh als einziger Zugang allerdings nicht, die meisten wollen einfach einen Link anklicken, somit brauchte es noch einen zweiten Zugang direkt im Browser. Wish kann nur SSH, aber die Architektur von Bubble Tea hilft hier. Das TUI-Model hängt nicht an der Verbindung, ein tea.Program bekommt beim Start lediglich einen io.Reader für die Eingabe und einen io.Writer für die Ausgabe. Bei SSH verdrahtet Wish das intern mit der Session, für den Browser macht es ein kleiner Handler auf Basis von coder/websocket. Tastendrücke kommen als Binary-Frames über den WebSocket und landen in einer Pipe, die als Reader dient, der gerenderte Screen geht als Binary-Frames zurück und bei jedem Resize schickt der Browser eine JSON-Textnachricht, aus der eine tea.WindowSizeMsg wird. Im Browser rendert xterm.js den Output, als Identität dient statt des SSH-Keys ein zufälliges Token im Cookie. SSH und Browser instanziieren damit dasselbe Model aus demselben Package, nur mit einem anderen Transport dahinter, die Webseite selbst enthält keine Spiellogik.

Nach dem LDJam

TERMINALWARS ist in den 72 Stunden des Jams entstanden und seit der Abgabe vor drei Wochen läuft die Welt einfach durch. Keine halbe Stunde nach dem Einreichen stand der erste Corp-Code in den Kommentaren, im Schnitt waren rund sechs Leute gleichzeitig online und in der Spitze fast 30. Ich selbst habe nach dem Start eher zugeschaut. Vor allem die vier Spieler an der Spitze haben sich inzwischen mehrere Schlachten geliefert, bei einer davon sind knapp 50.000 Fighter, 5.000 Fregatten und fast 2.000 Kreuzer draufgegangen. Ein Spieler hat die Zehn-Millionen-Marke geknackt und damit nebenbei einen Fehler in der Rangliste gefunden, die ab einer Million die letzte Stelle verschluckt, so weit waren meine Tests schlicht nie gekommen. Dazu kam eine Handvoll Balancing-Hinweise, die ich größtenteils unterschreiben würde und von denen ich ein paar noch anfassen werde, soweit das geht, ohne das Terminal weiter vollzustopfen.

Kommentare gab es mehr als erwartet und in fast allen ging es um den Zugang per ssh. Ein alter MUD-Spieler hat es in seinem Livestream gespielt. Als Stärke nennen mehrere, dass es sich gut nebenbei spielen lässt, im Terminal sogar neben der Arbeit. Anderen fehlte dagegen die Führung, das kleine Tutorial-Fenster unten rechts hat ihnen nicht gereicht, um zu wissen, was als Nächstes dran ist. Dafür habe ich auch schon ein paar Ideen.

Bewertet wird beim Ludum Dare in Jam und Compo getrennt und TERMINALWARS hat im Jam bei 736 Spielen Platz 132 in der Gesamtwertung und Platz 77 bei Innovation bekommen, die höchste Note gab es mit 4,1 von 5 Punkten in der Kategorie Theme, also dafür, wie gut das Spiel das vorgegebene Thema trifft.

Das Bauen hat mir viel Spaß gemacht, ich hatte schon länger nach einem Grund gesucht, mal eine TUI-Anwendung zu schreiben. Das i-Tüpfelchen war dann, dass es so viel Anklang fand und es wirklich viele Spielende gab, denen man zuschauen konnte, wie sie sich hochbauen und in den Kommentaren Feedback geben.

Meine Favoriten vom LD60

Gut 40 der knapp tausend Spiele habe ich in der Bewertungsphase selbst gespielt und fertig bin ich mit dem Stöbern noch lange nicht. Diese sind mir bisher hängen geblieben.

The Last Route

Mein Favorit, den ich inzwischen mehrfach gespielt habe, und das als Compo-Beitrag, also allein in 48 Stunden entstanden. Ihr flüchtet in einem Zug vor einer tödlichen Nebelwand, gemanagt wird aber vor allem der Zug selbst. Ihr baut ihn aus und schickt an festen Haltepunkten, die ihr auf einer Karte wie in Slay the Spire auswählt, die Bewohner auf Expeditionen, um Material zu sammeln. Das hat etwas von Frostpunk und holt mich damit voll ab, dieses winterliche Frieren und Überleben, nur eben auf einem Zug, und das ist einfach richtig gut umgesetzt.

Pixelart-Nachtszene in The Last Route: ein langer Zug mit beleuchteten Waggons fährt durch eine dunkle Hügellandschaft, oben Ressourcen und Zeitleiste, unten die Aufgabe Reach the Northern Terminal
The Last Route: Tag 2, der Nebel liegt 130 km zurück

Welcome to my Homepage

Ein inkrementelles Spiel über das Internet der 2000er. Ihr schürft Dateien, werft sie ins Netz und seht zu, wie daraus Geld wird, dann kommen Upgrades und irgendwann läuft die Sammelei von allein. Das drückt einfach auf die Nostalgie-Drüse und ich bin leicht zu haben, wenn es um solche Spiele geht, bei denen man automatisieren kann und die Zahlen einfach immer größer werden.

Welcome to my Homepage: Pixel-Oberfläche im Stil des 2000er-Internets, links Upgrades für Computer, Papierkorb, Browser und Auto-Bots, rechts ein Browserfenster, in das Dateien geworfen werden
Welcome to my Homepage: Dateien ins Netz werfen, bis es von allein läuft

Light Seeker

Ihr steuert einen kleinen Roboter durch ein verlassenes Kraftwerk, der sich nur bewegen kann, solange er am Strom hängt, und bringt dem Werk nach und nach das Licht zurück. Mir gefällt vor allem der Grafikstil und das Physische in der Entwicklungsgeschichte, die Sachen wurden in der echten Welt gemalt und gebastelt. Bei einem Jam, in dem das Thema KI so untergründig brodelte, war das ein schönes Erlebnis.

Light Seeker: ein kleiner Roboterwagen aus Pappe, Schaltern und Zeigern steht auf einer Schiene in einem grün-blau gemalten Kraftwerk, per Kabel mit einer Steckdose verbunden, oben rechts ein Poster mit Glühbirne
Light Seeker: ohne Kabel zur Steckdose geht nichts