Ein 3D-Segelspiel mit Multiplayer in reinem three.js
Gerstner-Wellen, die GPU und CPU gleich berechnen, eine Himmelsfunktion für Himmel, Wasser und Nebel, ein Tag-Nacht-Wechsel in fünf Minuten und ein winziger WebSocket-Relay – die Technik hinter dem Spiel auf meiner Website.
Balázs Csorba··8 Min. Lesezeit
- three.js
- WebGL
- GLSL
- WebSockets
- Game development

In meinem Portfolio steckt ein Spiel. Dragon Voyage ist ein kleines Segelspiel: Du steuerst eine Dschunke mit Drachenbug durch einen laternenbeleuchteten Hafen, direkt auf der Seite. Es gibt fünf Quests (Laternen entzünden, durch die Tore rasen, Schiffbrüchige retten, eine Piratenflotte schlagen, das Flaggschiff versenken), Breitseiten-Gefechte, Handel zwischen zwei Häfen und einen Tag-Nacht-Wechsel in fünf Minuten. Alle, die die Seite gerade offen haben, segeln im selben Hafen.
Eine Game-Engine gibt es nicht: nur three.js und rund 9.700 Zeilen TypeScript, GLSL und Vue. Das sind die Teile, die mir beim Bauen am meisten Spaß gemacht haben.
Wellen, auf denen das Schiff wirklich schwimmt
Die meisten Ozean-Demos in three.js bewegen das Wasser im Vertex-Shader, und das war's. Ein Spiel braucht mehr: Das Schiff muss auf der Welle liegen, die man sieht, mit ihr stampfen und ins Wellental rollen.
Der Ozean ist die Summe aus sechs Gerstner-Wellen. Jede ist durch Wellenlänge, Steilheit und eine Richtungsabweichung vom Wind definiert, ihre Geschwindigkeit ergibt sich aus der Tiefwasser-Dispersion (c = √(g/k)). Dieses Array ist die einzige Quelle der Wahrheit:
const SPEC: [number, number, number][] = [
// wavelength (m), steepness, direction offset (rad)
[46, 0.085, 0.0],
[27, 0.1, 0.38],
[15.5, 0.12, -0.52],
// …three shorter waves
]Dieselben Zahlen gehen als Uniforms an den Vertex-Shader und an eine CPU-Funktion, die Höhe und Normale an jedem Punkt liefert. Das Schiff fragt sie jeden Frame unter dem Rumpf ab. Genauso die Piratenschiffe, die Boote der anderen Spieler, die Flöße der Schiffbrüchigen und die Einschläge der Kanonenkugeln.
Ein Haken: Gerstner-Wellen verschieben Punkte auch seitlich, der Oberflächenpunkt über (x, z) kommt also nicht von (x, z). Ein paar Fixpunkt-Iterationen gleichen diese Verschiebung aus, bevor die Höhe gelesen wird. Das kostet fast nichts und verhindert, dass das Schiff sichtbar über die Wellen rutscht.

Eine Himmelsfunktion für alles
Die Himmelskuppel, die Spiegelung im Wasser und der Nebel auf jedem Mesh rufen dieselbe GLSL-Funktion auf, skyColor(dir). Sie wird als String-Baustein in jeden Shader eingefügt, beim Nebel über onBeforeCompile auch in die eingebauten Materialien von three.js.
Das ist die nützlichste Entscheidung im ganzen Renderer:
- Spiegelungen passen immer zum Himmel darüber. Es gibt keine Cube-Map, die synchron gehalten werden muss.
- Ferne Berge verblassen in der Himmelsfarbe dahinter, nicht in einem flachen Nebelgrau.
- Ändert sich der Himmel, folgt alles mit – deshalb war der Tag-Nacht-Wechsel fast geschenkt.
Ein Tag in fünf Minuten
Der Hafen war um eine einzige Abenddämmerung herum gebaut. Daraus einen vollen Zyklus zu machen hieß: Die Sonne muss sich bewegen, und alles, was „Dämmerung“ voraussetzte, muss stattdessen die Sonne lesen.
Sonne und Mond drehen sich gemeinsam um eine Achse, die 35° gegen den Horizont geneigt ist. Die Mittagssonne steht deshalb hoch im Westen, vor der Stadt statt dahinter. Die erste Version hat den Hafen den ganzen Tag im Gegenlicht gezeigt, und die Stadt sah aus wie Pappe.
Zwei Werte, abgeleitet aus der Sonnenhöhe, steuern alles:
export const dayAmount = (sunY: number) => smoothstep(-0.06, 0.3, sunY)
export const duskAmount = (sunY: number) => Math.exp(-(((sunY + 0.02) / 0.13) ** 2)) Jede Farbe hat drei Werte (Nacht, Tag und Dämmerung) und mischt sich über dayAmount von Nacht zu Tag, dann über duskAmount Richtung Dämmerung. Dieselben Kurven gibt es in GLSL. So stimmen Himmel, Wasser, Hemisphärenlicht, Tone-Mapping-Belichtung, Nebeldichte und sogar die Tönung der Dunst-Sprites überein.


Knifflig war das bildbasierte Licht. Die Environment-Map für die PBR-Materialien wird mit PMREMGenerator aus dem Himmel gebacken. Früher einmal beim Start, mit wandernder Sonne jetzt alle paar Sekunden Himmelszeit neu. Das vorherige Render-Target wird jedes Mal freigegeben, sonst wächst der GPU-Speicher stetig.
Multiplayer mit einem Relay von rund 280 Zeilen
Der Multiplayer sollte billig zu hosten und langweilig im Betrieb sein. Der Server ist ein WebSocket-Relay ohne Abhängigkeiten: RFC-6455-Framing auf node:http, hinter nginx. Vom Spiel weiß er fast nichts. Er:
- führt eine Liste von bis zu 24 Spielern;
- wählt einen Host: den Spieler, der am längsten verbunden ist;
- leitet Schiffszustände und Kanonenschüsse zwischen den Spielern weiter;
- reicht Spielaktionen an den Host durch;
- speichert den letzten Welt-Snapshot des Hosts, damit Neuankömmlinge – oder ein neu gewählter Host – bei der laufenden Quest einsteigen können.
Der Browser des Hosts führt die maßgebliche Spiellogik aus: Quests, Piraten, wer welchen Schiffbrüchigen gerettet hat. Geht der Host, übernimmt der Nächste aus dem gespeicherten Snapshot. Das ist kein kompetitiver Shooter, einem Client zu vertrauen ist also in Ordnung – und dafür kostet der Server fast nichts.

60 fps auf dem Laptop
Das Spiel läuft auf einem Portfolio, also muss schon der erste Besuch flüssig sein, egal auf welchem Gerät:
- Adaptive Auflösung: Der Renderer beobachtet die durchschnittliche Frame-Zeit. Liegt sie über etwa 21 ms, sinkt das Pixel-Verhältnis um eine Stufe, unter etwa 14 ms steigt es wieder. Die meisten Laptops pendeln sich auf ein scharfes Bild ein, ohne dass jemand eine Einstellung anfasst.
- Instanced Meshes für Wiederholtes: Kisten und Pfosten am Markt und jede fliegende Kanonenkugel.
- Gerendert wird erst, wenn das Spiel sichtbar ist, und die Schleife pausiert, wenn der Tab verborgen ist.
Die Kleinigkeiten, die es fertig wirken lassen
- Eingabe: Tastatur, Touch-Steuerung am Handy und Gamepads.
- Reduzierte Bewegung wird respektiert: kein Kamerawackeln, kein Vibrieren von Controller oder Handy.
- Automatisches Speichern: Der Fortschritt landet alle paar Sekunden und beim Verlassen der Seite im
localStorage. Nach einem Neuladen heißt es „Fahrt fortsetzen – Quest 3 von 5“. - Sprachen: Englisch, Deutsch und Ungarisch, wie die restliche Website.

Ausprobieren
Leinen los auf balazscsorba.com/de/game. Ist gerade jemand anderes auf der Seite, siehst du sein Schiff. Und wer wissen will, woran ich sonst arbeite: Ich habe auch über die Skills geschrieben, mit denen meine Coding-Agenten vom Bug-Report bis zum Pull Request kommen.