bcardarella/sailgame

★ 0Forks 0JavaScriptGitHub ↗Compare

README

Leeward

A third-person age-of-sail racing game in the browser. Three.js, WebGL2, no engine.

Everything you see is generated in code — there is not a single authored art asset in this repository. The hull is lofted from station curves, the canvas is a cloth model, the sea is a Tessendorf spectrum inverted on the GPU, the sky is an atmospheric scattering integral, and the islands are noise shaped by a signed distance field.

Running it

npm install
npm run dev

Open the URL Vite prints (usually http://localhost:5173).

Requires a browser with WebGL2 and EXT_color_buffer_float. Developed against Chrome and Safari on Apple Silicon.

Controls

Input Action
A / D, ← / → Helm
W / S, ↑ / ↓ Brace the yards (sail trim)
R / F Reef in / shake out
Q / E Fire the port / starboard broadside
Space Hand the wheel back to the mate
Mouse drag / wheel Orbit and zoom; returns to the chase camera after 6s idle
Gamepad Left stick helm, right stick camera, triggers brace, bumpers reef, A hands back

Every control is the mate's until you touch it, so picking up a gamepad mid-passage does not yank the wheel out of her hands.

She is a square rigger. She will not sail closer than about 45° to the wind, she tacks only from close-hauled and only with way on, and from anywhere else she wears — turning away through the run, which is slower and costs you ground. That is the historically correct trade and the game does not spare you from it.

Architecture

src/core/world.js holds the entire mutable world state. Every subsystem reads from it and nothing else, which is what lets the capture harness pin the world to an exact state and get a reproducible frame. Subsystems follow one contract — create<X>(ctx) returning { object3D?, step?, update?, resize?, dispose? } — where step() runs on a fixed 1/60 timestep and update() once per rendered frame.

src/ocean — Tessendorf spectrum (JONSWAP wind sea plus an explicit swell partition) inverted by GPU IFFT across three cascades at deliberately incommensurate tile sizes, so the surface has no shared repeat period at any range. Drawn on a projected grid whose density is solved from the framebuffer for a target screen-space cell size. Shaded with Beer-Lambert extinction against separate RGB coefficients, GGX sun specular, subsurface scattering through backlit crests, and whitecaps generated from the folding Jacobian with crest-aligned orientation. A world-anchored wake field carries the ship's disturbance. The CPU sampler agrees with the GPU surface, which is what the hull floats on.

src/sky — Bruneton-class atmospheric scattering in Hillaire's LUT formulation, with a real sun disc (limb darkening, correct angular diameter, transmittance-driven reddening) and a circumsolar aureole integrated separately because the lobe is narrower than a LUT texel. Volumetric clouds are raymarched and temporally amortised: a quarter of the buffer is marched per frame on a rotating sub-texel pattern, the rest reprojected. The whole sky is captured to a PMREM cube so everything else is lit by the sky it is actually standing under.

src/post — the only place in the engine that tonemaps or writes sRGB. TAA with depth-validated reprojection and YCoCg variance clipping, a threshold-free dual-filter bloom pyramid, log-average metering with eye adaptation, then AgX with an hour-driven grade.

src/ship — a brig, ~26 m on deck, 32 m sparred, mainmast 28 m above the waterline. Hull lofted from station curves with a real sheer line and tumblehome; planking, wales, channels, gunports; full standing and running rigging with ratlines as one instanced draw. Sails are deformed by a cloth model driven by apparent wind — they belly on a reach and luff head to wind. Six draw calls.

src/physics — 6-DOF rigid body. Buoyancy is integrated over submerged hull panels against the actual wave surface, not one sample, which is what makes her roll. Sail force comes from lift/drag polars against apparent wind. The points of sail are not scripted anywhere: the no-go zone, the best VMG angles, heel from the sail/righting couple, leeway and weather helm all fall out of the force balance.

src/world — procedural island chain with beaches, triplanar-blended materials, and instanced wind-animated palms. Publishes a bathymetry field that the ocean samples for shallow-water colour, wave steepening and shoreline surf.

src/fx — GPU bow spray, impact spray on wave slams, spindrift torn off crests in a blow, wake mist, and a screen-space wet-lens overlay.

src/combat — seven guns a side, sited on the hull's actual gunports. Round shot is a real 5.44 kg ball integrated with velocity Verlet against quadratic drag, so it arcs, slows, and gets pushed to leeward by the wind — you have to lead a moving target. A broadside fires as a ripple from forward aft rather than as one volley, and each gun reloads on its own clock. Hits are a swept-segment test against the target's hull box, because at these speeds a ball crosses two metres a frame and a point test tunnels straight through.

src/game / src/ui — course laid out relative to the wind so the windward leg must be beaten to, race state machine with a biased start line, and rivals that run the same physics as the player. A rival commands a true wind angle, not a rudder angle, through the same autopilot that holds your course — it has no access to a force you could not generate. It knows laylines, that a tack costs speed, that a mark is rounded wide-in and tight-out, and to tack away from dirty air.

The capture harness

Visual work that cannot be seen cannot be reviewed, so the renderer is driven headlessly:

node tools/screenshot.mjs --out shots/a.png --preset beauty --w 1920 --h 1080
node tools/screenshot.mjs --out shots/b.png --preset chase --hud --race
node tools/raceCheck.mjs --minutes 30      # drives a race and asserts it progresses
node tools/rigCheck.mjs --shots            # presses keys, measures whether the rig moved
node tools/gunCheck.mjs --shots            # fires the guns, checks shot arrives on a hull

Presets: beauty, waterline, chase, storm, dusk, tiling. The tool boots the game in GPU-accelerated Chromium, pins the world to an exact state, advances the simulation deterministically so temporal effects converge, screenshots, and reports console errors — a frame captured with errors is treated as invalid.

window.__SAIL__ is the contract it drives: applyState, settle, fastForward (simulate without drawing — a two-lap race is 13 seconds un-rendered and 40 minutes rendered), stats, raceState.

Measuring performance

Three obvious ways to time this frame all lie on ANGLE/Metal, and the numbers in early development were wrong because of it. requestAnimationFrame deltas do not measure the renderer — Chrome keeps firing rAF while the GPU queue drains, reporting 1.4 ms for a 30 ms frame. gl.finish() does not synchronise. A 1-pixel readPixels is a true barrier, and that is what __SAIL__.bench() uses.

Per-pass GPU timer queries work but ANGLE charges a command-buffer split per query, so absolute magnitudes run high and only the ranking is meaningful. Because this is a shared machine where six identical measurements returned 111/61/27/22/27/48 fps, the number to trust is the interleaved in-session A/B:

node tools/screenshot.mjs --preset dusk --w 1920 --h 1080 --ab       # legacy vs current, alternated
node tools/screenshot.mjs --preset dusk --ablate                     # per-system cost attribution

Tests

npm test     # 1294 tests, 82 files

Pure math is test-driven: the wave spectrum, the FFT, buoyancy, the sailing polars, laylines, mark rounding, start-line bias, tonemap curves, reprojection. Shader and visual work is verified by screenshot instead, because a test that asserts a shader compiles tells you nothing about whether the water looks like water.

Known limitations

This is the honest list.

  • It is not Assassin's Creed IV. That was the target. A browser page does not get the GPU budget, the authored assets, or the studio-years that game had. The sky and the ship hold up well; the ocean is good but reads more "very competent real-time water" than "AAA hero asset".
  • Whitecaps still repeat. They are crest-aligned and per-instance varied now, but at storm coverage you can pick out recurring shapes.
  • The far field is soft. The anisotropic fetch was widened, but the last kilometre or two of ocean carries less crest structure than it should and reads flat at grazing angles.
  • 60 fps is not universal. At 1920×1080 the light presets clear 60 comfortably; dusk — low sun in frame, longest scattering path, heaviest cloud — measured 44 fps on a machine under load 14. On an idle machine it is meaningfully better, but dusk is still the worst case and has not been confirmed at 60.
  • The mate does not race. With no input she holds the course you left her on; she will not sail the course for you, so an unattended player boat reports wrongWay and never rounds a mark. The rivals race properly. Whether the mate should race is a design question, not a bug.
  • The raceOver transition is unverified. Driven headlessly for 100 simulated minutes (tools/raceCheck.mjs --minutes 100), the rivals sail the whole course: gun fires, all seven stations round in order across both laps, places shuffle throughout, and they pass the finish station. But the phase only becomes finished when state.finished >= state.entries — every boat, including the player — so with nobody at the wheel the race stays in racing forever and the finish gun has never actually been observed to fire. That condition is correct; it simply cannot be exercised without a human sailing the course.
  • A lap takes ~30 minutes of simulated time. Realistic for a real sailing race, possibly too long for a game. The course scale has not been tuned for fun.
  • Gun smoke is thin. It reads as a few grey puffs rather than the bank of powder smoke a broadside should leave hanging to leeward. The system is there; the density and lifetime are not yet art-directed.
  • Nothing shoots back, and nothing sinks. Hits accumulate on a damage counter that has no consequence — no rigging damage, no crew, no AI that returns fire. The guns work; the fight around them does not exist.
  • No audio. Not started, which a broadside makes conspicuous.
  • Desktop only. No touch controls, and the fill cost would need real work on mobile.

Contributors

bcardarella

Issues