> Multiplayer 3D sailing in plain three.js: Gerstner waves shared by GPU and CPU, one sky function for sky, water and fog, and a tiny WebSocket relay.
>
> Web page: https://balazscsorba.com/blog/multiplayer-sailing-game-threejs · Language: English · Also available in: [Deutsch](https://balazscsorba.com/de/blog/multiplayer-sailing-game-threejs.md) · [Magyar](https://balazscsorba.com/hu/blog/multiplayer-sailing-game-threejs.md)
> Author: Balázs Csorba · Published: 2026-10-05 · Keywords: three.js game, three.js multiplayer, Gerstner waves, WebGL ocean shader, day night cycle three.js, WebSocket relay, browser game development, GLSL

[Blog](https://balazscsorba.com/blog)/[Web engineering](https://balazscsorba.com/blog/category/web)

# Building a multiplayer 3D sailing game with plain three.js

Multiplayer 3D sailing in plain three.js: Gerstner waves shared by GPU and CPU, one sky function for sky, water and fog, and a tiny WebSocket relay.

[Balázs Csorba](https://balazscsorba.com/about)·October 5, 2026·8 min read

-   three.js
-   WebGL
-   GLSL
-   WebSockets
-   Game development

![Multiplayer sailing game in three.js: two matching wavy rails parallel across the frame, a half-divided circle above them.](https://balazscsorba.com/images/blog/multiplayer-sailing-game-threejs/dusk.webp?v=08d19ae5fb)

## Key takeaways

-   The ocean is six Gerstner waves; the same parameters feed the vertex shader and a CPU function, so ships float on the wave you see.
-   Sky, water reflection and fog all call one GLSL function, skyColor(dir), which made the day/night cycle almost free.
-   Two curves derived from the sun height (dayAmount, duskAmount) blend every colour, light and fog value in JS and GLSL alike.
-   Multiplayer runs on a dependency-free ~280-line WebSocket relay; the longest-connected browser is the host and runs the game logic.
-   Adaptive pixel ratio, instanced meshes and pausing off-screen keep it smooth on ordinary laptops.

On this page

1.  [Waves the ship can actually float on](https://balazscsorba.com/#waves)
2.  [One sky function for everything](https://balazscsorba.com/#sky)
3.  [A five-minute day](https://balazscsorba.com/#day-night)
4.  [Multiplayer with a ~280-line relay](https://balazscsorba.com/#multiplayer)
5.  [Keeping it at 60 fps on a laptop](https://balazscsorba.com/#performance)
6.  [The small things that make it feel finished](https://balazscsorba.com/#details)
7.  [Try it](https://balazscsorba.com/#try-it)
8.  [Sources](https://balazscsorba.com/#sources)

Listen to this article

0:00

My portfolio has a game in it. [Dragon Voyage](https://balazscsorba.com/game) 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](https://developer.nvidia.com/gpugems/gpugems/part-i-natural-effects/chapter-1-effective-water-simulation-physical-models). 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:

TypeScript

```
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.](https://balazscsorba.com/images/blog/multiplayer-sailing-game-threejs/noon.webp?v=1ef0093f4a)

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:

TypeScript

```
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.](https://balazscsorba.com/images/blog/multiplayer-sailing-game-threejs/town-noon.webp?v=b51d60f5ff)

Harbour Town at noon, lit from the front.

![The same harbour town at night: lit windows and lanterns reflecting on dark water.](https://balazscsorba.com/images/blog/multiplayer-sailing-game-threejs/town-night.webp?v=9624959406)

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.](https://balazscsorba.com/images/blog/multiplayer-sailing-game-threejs/night.webp?v=5305bd7830)

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.](https://balazscsorba.com/images/blog/multiplayer-sailing-game-threejs/dawn.webp?v=4eaa3c22a8)

Dawn, roughly halfway through the five-minute day.

## Try it

Sail at [balazscsorba.com/game](https://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](https://balazscsorba.com/blog/coding-agent-skills-workflow). For the practical side, there is a [Home Assistant app that moves EV charging into the cheapest hours](https://balazscsorba.com/blog/home-assistant-ev-charging-energy-manager) too.

## Sources

1.  [GPU Gems, Chapter 1: Effective Water Simulation from Physical Models](https://developer.nvidia.com/gpugems/gpugems/part-i-natural-effects/chapter-1-effective-water-simulation-physical-models)
2.  [RFC 6455: The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)

## Frequently asked questions

How do you make a ship float on a three.js ocean shader?

Keep the wave parameters in one array and use it twice: as shader uniforms for the rendered surface, and in a CPU function that returns height and normal at any point. Sample that function under the hull every frame. For Gerstner waves, iterate a couple of times to undo their sideways displacement first.

What are Gerstner waves?

Gerstner (trochoidal) waves move surface points in circles instead of only up and down, which gives sharp crests and flat troughs. A sum of a few waves with different wavelengths, steepness and directions is a cheap, convincing ocean for real-time graphics.

Do you need a game server for a small multiplayer browser game?

Not a full one. A small WebSocket relay can keep the roster, pick one browser as host, forward messages and cache the host's latest world snapshot. The host runs the game logic. That is fine for a casual co-op game, not for a competitive one where clients must not be trusted.

Written by Balázs Csorba

Senior fullstack & AI engineer in Styria, Austria – 10+ years of Vue, Nuxt, Node.js and PHP, now building tooling for AI agents.

[Vue.js & Nuxt development →](https://balazscsorba.com/expertise/vue-nuxt-developer)[About me →](https://balazscsorba.com/about)

## More articles

-   [Schema.org structured data for B2B shops: prices behind logins, units and variants](https://balazscsorba.com/blog/schema-org-structured-data-b2b-shop)
-   [An AI assistant for B2B shop support: tools, permissions and the human handoff](https://balazscsorba.com/blog/ai-assistant-b2b-shop-customer-support)
-   [Streaming AI chat in Vue and Nuxt: accessible by design](https://balazscsorba.com/blog/vue-ai-chat-ui-streaming-accessibility)
-   [An LLM translation pipeline for i18n: glossary, placeholders and review](https://balazscsorba.com/blog/llm-translation-pipeline-i18n)

## Sounds like what you need?

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

[Get in touch](mailto:contact@balazscsorba.com) [Connect on LinkedIn](https://www.linkedin.com/in/balazs-csorba)
