Building a multiplayer 3D sailing game with plain three.js
Gerstner waves shared by GPU and CPU, one sky function for sky, water and fog, a five-minute day/night cycle and a tiny WebSocket relay – the tech behind the game on my portfolio.
Balázs Csorba··8 min read
- three.js
- WebGL
- GLSL
- WebSockets
- Game development

My portfolio has a game in it. Dragon Voyage is a small sailing game: you steer a dragon-prowed junk through a lantern-lit harbour, and it runs right in the page. There are five quests (light the lanterns, race through the gates, rescue castaways, beat a pirate fleet, sink the flagship), broadside cannon fights, trading between two ports and a five-minute day/night cycle. Everyone who has the page open sails in the same harbour.
There's no game engine: it's three.js plus about 9,700 lines of TypeScript, GLSL and Vue. These are the parts I found most interesting to build.
Waves the ship can actually float on
Most three.js ocean demos move the water in the vertex shader, and that's the end of it. A game needs more: the ship has to sit on the wave you see, pitch with it and roll into the trough.
The ocean is a sum of six Gerstner waves. Each one is defined by a wavelength, a steepness and a direction offset from the wind, and its speed comes from deep-water dispersion (c = √(g/k)). That array is the single source of truth:
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
]The same numbers go to the vertex shader as uniforms and to a CPU function that returns the height and normal at any point. The ship samples it under the hull every frame. So do the pirate ships, the other players' boats, the castaway rafts and the cannonball splashes.
One catch: Gerstner waves also move points sideways, so the surface point above (x, z) didn't start at (x, z). A couple of fixed-point iterations undo that drift before reading the height. That's cheap, and it's what stops the ship from visibly sliding against the waves.

One sky function for everything
The sky dome, the water's reflection and the fog on every mesh all call the same GLSL function, skyColor(dir). It's shared as a string chunk and injected into each shader. For the fog, it goes into three.js's built-in materials via onBeforeCompile.
This is the most useful decision in the whole renderer:
- Reflections always match the sky above them. There's no cube map to keep in sync.
- The distant mountains fade into the sky colour behind them, not into a flat fog grey.
- When the sky changes, everything follows, which made the day/night cycle almost free.
A five-minute day
The harbour was designed around one dusk. Turning that into a full cycle meant making the sun move, and making everything that assumed "dusk" read the sun instead.
The sun and moon rotate together around an axis tilted 35° off the horizon, so the midday sun stands high in the western sky. That puts it in front of the town rather than behind it. The first version backlit the harbour all day, and the town looked like cardboard.
Two numbers derived from the sun's height drive everything:
export const dayAmount = (sunY: number) => smoothstep(-0.06, 0.3, sunY)
export const duskAmount = (sunY: number) => Math.exp(-(((sunY + 0.02) / 0.13) ** 2)) Every colour then has three values (night, day and dusk) and blends from night to day by dayAmount, then toward dusk by duskAmount. The same curves exist in GLSL, so the sky, the water, the hemisphere light, the tone-mapping exposure, the fog density and even the tint of the mist sprites all agree.


Image-based lighting was the tricky part. The environment map for PBR materials is baked from the sky with PMREMGenerator. That used to happen once at startup, but with a moving sun it now re-bakes every few seconds of sky time. The previous render target is disposed each time, or GPU memory climbs steadily.
Multiplayer with a ~280-line relay
The multiplayer needed to be cheap to host and boring to operate. The server is a dependency-free WebSocket relay: RFC 6455 framing on top of node:http, behind nginx. It knows almost nothing about the game. It:
- keeps a roster of up to 24 players;
- elects a host: the player who's been connected longest;
- relays ship states and cannon shots between players;
- forwards game actions to the host;
- caches the host's latest world snapshot, so newcomers, or a newly elected host, can pick up the current quest.
The host's browser runs the authoritative game logic: quests, pirates, who rescued which castaway. If the host leaves, the next-longest player takes over from the cached snapshot. This isn't a competitive shooter, so trusting a client is fine, and in return the server costs almost nothing.

Keeping it at 60 fps on a laptop
It lives on a portfolio, so the first visit has to be smooth on whatever the visitor has:
- Adaptive resolution: the renderer watches the average frame time. If it goes above about 21 ms, the pixel ratio drops a step. Below about 14 ms, it climbs back. Most laptops settle on a sharp image without anyone touching a setting.
- Instanced meshes for repeated things: market crates and posts, and every cannonball in flight.
- Nothing renders until the game is on screen, and the loop pauses when the tab is hidden.
The small things that make it feel finished
- Input: keyboard, touch controls on phones, and gamepads.
- Reduced motion: respected. There's no camera shake and no controller or phone rumble.
- Autosave: progress is saved to
localStorageevery few seconds and when you leave the page. After a refresh you get "Continue voyage – Quest 3 of 5". - Languages: English, German and Hungarian, like the rest of the site.

Try it
Sail at balazscsorba.com/game. If someone else is on the page, you'll see their ship. If you're curious about the other half of my work, I've also written about the skills that let my coding agents go from a bug report to a pull request.