There is a wedge in front of the boat about sixty degrees wide, and the mark is inside it. Every heading in that wedge is a heading the boat will not sail — not slowly, not badly, at all — so the shortest route to the buoy is the one route unavailable, and the whole game is what you do about that.
Sailing is the only race I can think of where the finish line is in a direction you are physically barred from travelling, and where the correct answer is to spend the entire leg going somewhere else. The interesting part is not that it is true. It is that the degree to which it is true is a number, it changes with the wind, and almost every piece of received tactical wisdom about it turns out to be conditional on something nobody says out loud.
There is no polar curve in this game
The tempting way to build a sailing sim is to write down a polar — a table of boat speed against angle to the wind — and interpolate it. You can get one off any sailmaker’s website. It would have taken twenty minutes, and it would have made every interesting thing in here an input rather than a result.
src/sail.mjs never writes a speed down. A sailboat is two foils working
against each other in two different fluids: the rig makes a force in air, the
keel and hull make an equal and opposite one in water, and the boat goes
wherever that pair balances. Each foil has a drag angle — how far its total
force leans back from the perpendicular — and Lanchester’s course theorem says
the closest you can ever sail to the true wind is just their sum:
β_min = ε_air + ε_water
Nothing imposes that. It appears. The driving component of the rig’s force is
F·sin(γ − ε_air), where γ is the apparent wind angle, and that expression is
simply negative when the wind is closer to the bow than the rig’s own drag
angle. A boat stops sailing upwind for the same reason a wing stops flying. One
number in the file — cd0, the rig’s parasitic drag — moves the edge of the
no-go zone, and if you double it the boat loses about four degrees of pointing,
which is why a baggy main and a dirty bottom feel like the same problem.
So the model is: apparent wind from the velocity triangle every frame; a soft sail with a lift/drag pair that stays sane from zero to ninety degrees of attack, because dead downwind a sail is a parachute and not an aerofoil; a keel at whatever leeway the boat is currently making; and two degrees of freedom, surge and sway, integrated in the boat’s own frame.
That last decision is the one that pays. Sway is a state variable, not an assumption, so leeway is an output. Being in irons is an output. The cost of a tack is an output — it is exactly the deceleration through head-to-wind plus the rudder drag of getting there, and it comes out at about twenty metres of windward distance, which is two boat lengths and matches a real crew having an average day. None of those three are scripted anywhere, and all three are what the game is made of.
The polar exists, but as a measurement: put the boat at a heading in a steady breeze, hold it there, and integrate until it stops changing.
| TWS | no-go | beat angle | beat speed | beat VMG | best run | run VMG | top speed |
|---|---|---|---|---|---|---|---|
| 6 kn | 29° | 49° | 3.55 kn | 2.17 kn | 140° | 3.33 kn | 4.99 kn |
| 10 kn | 29° | 45° | 5.10 kn | 3.35 kn | 151° | 4.94 kn | 6.65 kn |
| 13 kn | 29° | 42° | 5.80 kn | 3.98 kn | 158° | 5.93 kn | 7.49 kn |
| 16 kn | 29° | 40° | 6.17 kn | 4.34 kn | 161° | 6.59 kn | 8.04 kn |
| 20 kn | 30° | 37° | 6.30 kn | 4.62 kn | 165° | 7.27 kn | 8.65 kn |
Every qualitative feature of a real keelboat polar is in there and none of it was put there: a no-go zone around thirty degrees, a beat angle that narrows from 49° to 37° as the breeze builds, top speed on a broad reach rather than a run, dead downwind slower than either, and an upwind VMG that flattens off in a breeze because past a point the crew cannot hold the boat up any more. The numbers land within about ten per cent of a real 35-footer, which is as close as I wanted to get without fitting to the answer.
What you can’t do with a measurement
One thing the relaxation would not give me: the edge of the no-go zone.
The obvious definition is “the angle at which the boat stops”, and that definition has no limit. Approach the true stalling angle from above and the boat does not settle at some small speed — it decays toward zero more and more slowly, so the number you get back is a report on how many seconds you happened to simulate for. Seventy seconds says 26°. A hundred and fifty says 29°. Three hundred says 30° and is still climbing. Neither is more correct.
So the no-go angle in this file is defined as the angle that still makes half the windward progress the best one does. That lands in the steep part of the curve where the answer stops moving after about a minute, and it happens to be the thing a sailor means anyway: not where the boat literally stops, but where pinching any further stops being sailing.
Six ways to sail a beat
scripts/measure.mjs races fixed steering policies round the same course in the
same wind — fifteen seeds each, medians reported. Every policy reads only what a
crew could see from the deck: the wind where the boat is, the bearing to the
mark, its own instruments, and a memory of what the wind has been doing. None of
them can see a shift coming. A policy that could see the future would only prove
that seeing the future is good.
Shifty day — ±19°, fourteen knots:
| policy | finished | lap | beat | run | tacks | vs. best VMG |
|---|---|---|---|---|---|---|
| Point at the mark | 0% | — | — | — | 0 | — |
| Pinch — 33° / 175° | 100% | 5:24 | 3:21 | 2:12 | 3 | +11 s |
| Foot for speed — 55° / 140° | 93% | 6:33 | 4:15 | 2:23 | 3 | +80 s |
| Best VMG, tack at laylines | 100% | 5:13 | 3:08 | 2:07 | 2 | 0 s |
| Best VMG, tack on 5° headers | 100% | 5:10 | 3:01 | 2:10 | 5 | −3 s |
| Best VMG, tack past half the day’s swing | 100% | 5:06 | 3:00 | 2:08 | 6 | −7 s |
The first row is the one worth sitting with. Pointing at the mark does not finish. Not slowly — at all, in zero out of fifteen runs, on every wind setting, forever. The boat noses up, the sails stop working, the rudder loses the flow it needs to steer with, and it sits there going sideways at a knot while the clock runs. It is the only failure in the table that is total, and it is the instinctive move.
The second surprise is the footing row: eighty seconds slower while sailing faster. At 55° off the wind in thirteen knots this boat does 6.67 knots against 5.80 at its proper angle — it heels more, it sounds better, the wake is bigger, and it is losing badly. Fifteen per cent more speed, and fifteen per cent less of it pointed where you want to go: 3.40 knots of VMG against 3.98. Pinching has the same shape and is gentler about it, 4.38 knots of boat speed for 3.43 of progress. The demo puts the VMG bar directly under the speed readout for exactly this reason. It is the only instrument that tells you going faster has just made you slower.
When the famous tactic is wrong
Every sailing book gives you the same rule for a beat: tack when the wind heads you. Sail the lifted tack, and the shifts do the work.
I built the shift-playing policy expecting to confirm it, and on the first run it lost. Not by a lot — three seconds — but it lost, repeatedly, at every threshold I tried. Being lifted more of the time was clearly working: the diagnostic said the policy spent 89% of the beat on the favoured tack against the baseline’s 57%. It was just slower anyway.
Which is the actual result, and it is not a bug. A tack costs a fixed number of metres. A shift pays a rate. So there is an amplitude below which the famous tactic is simply the wrong move, and it is not a matter of opinion:
| oscillation | never tack (beat) | lifted | play it (beat) | lifted | tacks | playing it costs |
|---|---|---|---|---|---|---|
| ±0° | 3:16 | 48% | 3:17 | 48% | 2 | +0 s |
| ±5° | 3:14 | 53% | 3:17 | 59% | 3 | +3 s |
| ±9° | 3:14 | 52% | 3:15 | 82% | 4 | +1 s |
| ±13° | 3:23 | 51% | 3:14 | 89% | 5 | −9 s |
| ±17° | 3:21 | 55% | 3:11 | 91% | 6 | −10 s |
| ±22° | 3:21 | 57% | 3:07 | 93% | 6 | −14 s |
The crossover is somewhere around eleven degrees. Below it, every tack you spend buys less lift than it costs in stopped boat, and the sailor who sits on one tack and goes to the layline beats the one working the shifts. Above it the sign flips and keeps flipping harder. The rule in the books is true; the books just never mention that it is a threshold, and the threshold is a property of your boat — a dinghy that tacks in two lengths has a much lower one than this five-tonne keelboat.
The second half of the same trade is how often the wind swings:
| swing period | never tack | play the shifts | tacks | lifted | playing it costs |
|---|---|---|---|---|---|
| 60 s | 3:16 | 3:37 | 11 | 76% | +21 s |
| 100 s | 3:17 | 3:20 | 7 | 68% | +3 s |
| 150 s | 3:23 | 3:14 | 5 | 89% | −9 s |
| 240 s | 3:18 | 3:07 | 5 | 86% | −11 s |
| 380 s | 3:10 | 3:07 | 3 | 86% | −3 s |
A fast swing is a trap: you can be lifted three-quarters of the time and still lose twenty-one seconds, because catching a 60-second oscillation means buying eleven tacks and eleven tacks is a quarter of a kilometre of windward distance. A slow swing pays for one tack and then keeps paying. And there is a peak rather than a slope — at 380 seconds the wind barely swings inside one beat, so there is less on offer to collect.
I went looking for leg length as the second variable and it is not one. Lengthen the beat and the gain and the tack count scale together; the ratio barely moves. It is the period that decides it.
The bug that was a lesson
The shift policy kept gaining eight seconds on the beat and giving eight back on the run, and the reason turned out to be that I had applied the upwind rule downwind.
Upwind you tack on a header. Downwind you gybe on a lift. The sign inverts, because running, a header swings the mark aft of your track and a lift swings it forward. Same instrument, same needle, opposite instruction. Applying the beat’s rule to the run cost almost exactly what playing the shifts properly gained, which is a tidy demonstration of how a tactical rule with its sign flipped is worse than having no rule at all.
Laylines, and being late to one
The layline is the line through the mark along your tack’s track angle: cross it and you have overstood, and every metre past it is distance sailed for nothing.
| tacked | beat | distance sailed, whole lap | tacks |
|---|---|---|---|
| on the layline | 3:16 | 1143 m | 2 |
| 25 m past it | 3:17 | 1160 m | 2 |
| 55 m past it | 3:27 | 1209 m | 2 |
| 95 m past it | 3:43 | 1291 m | 2 |
| 150 m past it | 4:07 | 1384 m | 4 |
A hundred and fifty metres of lateness on a four-hundred-metre beat costs fifty-one seconds — more than a quarter of the leg — and the reason is geometric rather than tactical: overstanding turns a leg you were sailing at 42° into one you finish at a reach, so the extra distance is not the 150 m you went too far, it is the 240 m the whole triangle grew by.
Two details in the code exist because of this. The layline is computed on the track angle rather than the steered one, since a boat making five degrees of leeway lays five degrees less than its compass claims, and most overstanding is the accumulated cost of forgetting that. And the downwind layline is the mirror of the upwind one, not the same one — running, you can always sail higher than your target angle to reach a mark and never lower, which is exactly the opposite of the constraint on a beat. Getting that sign wrong drew a gybing layline that could never be reached.
Trimming for speed, not for drive
The least intuitive line in the file is in the sail model. The trimmer searches for the angle of attack that maximises
drive − sideCost · side²
and not, as I first wrote it, the one that maximises drive. Sheeting harder
always buys more drive — the curve is still climbing when the sheet runs out —
but it buys it with side force, and side force is paid for underwater in
keel-induced drag that goes as its square. sideCost is handed to the rig by
the hull and is exactly the induced-drag penalty per newton² at the speed the
boat is doing now.
That one term is why the same sail is sheeted differently at four knots and at seven, and why a boat that is going slowly has to be eased to get moving rather than trimmed on harder. With the naive version the model over-sheeted, over-heeled and sailed about eight per cent slow upwind, in precisely the way a crew does when they are trying too hard.
Playing it
Arrows or A/D for the helm; space tacks or gybes for you. On a phone, drag anywhere and the boat steers at your finger. 1, 2, 3 switch between a steady day, an oscillating one and a properly shifty one; R restarts and leaves your last lap on the water as a ghost, which is the fastest way to see what you did differently.
Things worth reading off the screen:
- The red wedge is every heading unavailable to you, drawn from your own bow so it moves with the wind rather than sitting on the chart as an annotation. When the mark is inside it — which is most of the beat — that is the game.
- The water. Dark patches are pressure. The little ticks lie along the local wind, so on a shifty day you can watch a shift arrive across the surface before it reaches the boat, which is what people are doing when they appear to be staring at nothing.
- The shift box says LIFTED or HEADED relative to the day’s mean. Upwind, HEADED is your cue to tack — once it is big enough to be worth one.
- The VMG bar against the day’s target. Green is sailing the boat; amber is going somewhere fast.
Note that the run is the same problem mirrored: dead downwind is slow, so the leg home is two gybes rather than a straight line at the finish. You cannot point at either mark, at either end of the course.
Reuse
src/sail.mjs is headless and framework-free — no DOM, no canvas, no rendering.
rigForce(vA, awa, twaAbs, rig, sideCost)andhullForce(u, w, rudder, hull)→ boat-frame newtons. The two halves of the balance, usable separately.createBoat(x, y, hdg)/stepBoat(b, dt, {windDir, windSpeed, rudder})→ the whole boat plus a telemetry record. Takes a helm position and knows nothing about keyboards.buildPolar(tws)→ the measured table, cached by wind speed, carrying leeway and track angle per degree as well as speed.bestVmgAngle(tws, leg)for just the number.createWind({…})/windAt(wind, x, y, t)→ direction and speed at a point. A pure function of position and time, so a replay is reproducible from a seed.createCourse/createRace/stepRace→ the windward-leeward state machine.laylineSlack(b, mark, windDir, twaTrack, tack, leg)→ metres past the layline, negative if you cannot lay it. Evaluate it for the other tack and you have asked “can I tack and lay it?”.
Everything is metres, seconds and newtons. A different boat is a parameter
change: RIG and HULL are plain objects and every field has a unit on it.
Gotchas
- The keel’s lift vector has two possible rotations and one of them is silently wrong. Rotated the wrong way it amplifies leeway instead of resisting it, and the boat settles at about fifty degrees of sideslip while still reporting a plausible speed through the water — so the polar comes back populated, smooth, and nonsense. What caught it was that every upwind VMG in the table was near zero. Speed alone would never have shown it.
- Speed through the water is not progress. A boat stopped head to wind is
still moving at a knot, sideways. Any threshold in this code that wants to
know whether the boat is sailing has to look at the forward component or at
VMG, never at
hypot(u, w). - The polar is built once per scenario at the day’s base pressure, not per frame — a 180-angle relaxation is half a second and there is no room for that in a frame. It means in a puff you will genuinely beat the target on the HUD, which is correct and is why the bar is labelled with a wind speed rather than called a maximum.
- Cache the polar coarsely.
buildPolarrounds to the nearest quarter m/s. Round finer and a gusty scenario builds twenty tables. - The oscillation has to be averaged over more than one period. The crew’s sense of the mean wind uses a five-minute time constant. At seventy seconds against a 130-second swing the average follows the shift at a third of amplitude and 68° of lag, so “am I headed?” gets measured against a number that is itself mostly the header, and the policy tacks on noise. That was the first reason the shift-playing policy looked useless, before the real one.
demo/bundles its own copy ofsail.mjs— self-contained by contract (ADR-0002). Re-copy after editingsrc/.- The tables above are reproducible:
node scripts/measure.mjsprints every one of them, and--csvgives one row per run. It takes about two minutes.




