Blog

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
A dragon-prowed junk with glowing lanterns sailing across a choppy bay at dusk, forested hills on both sides.

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.

The junk from behind on a deep blue, choppy sea at midday, green hills on both sides and white clouds.
Midday: the hull rides the same six waves the shader draws.

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.

The harbour town at midday: rows of lit houses, a pagoda and pine-covered hills behind a sunlit bay.
Harbour Town at noon, lit from the front.
The same harbour town at night: lit windows and lanterns reflecting on dark water.
The same spot at night: lanterns and windows take over.

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.

The junk at night on a dark sea, lanterns glowing on the mast and pillars of light rising from the water ahead.
Night on the bay: the mast lanterns light the water around the hull.

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 localStorage every 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.
The junk at dawn on a calm grey-blue sea under a pale sky.
Dawn, roughly halfway through the five-minute day.

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.

Sounds like what you need?

Tell me about your project or role – I’d love to hear from you.