The beach is forty metres away and you are not going to reach it, because the water you are in is going the other way at about a metre a second and your best swim is 0.95. Everything else in the game follows from that one comparison, which is why the HUD puts the two numbers on the same scale and nothing else in the same colour.
Rip currents are the deadliest ordinary thing on a surf beach; in the US figures they drown more people than sharks, lightning and hurricanes put together. Not because they are strong, since a metre a second is a stroll, but because the correct response sits about ninety degrees away from the one every instinct offers. So this is a game with one lesson in it, and the lesson is a direction.
The water is one stream function
The tempting way to build this is to draw the jet: paint an arrow field, make the arrows longer in the channel, blend it out at the edges. That gives you a rip that comes from nowhere and goes nowhere, and it quietly gets the interesting part wrong, because a rip is a return flow. Waves break over a sandbar and shove water shoreward. The water piles up in the trough behind the bar, runs along it looking for a way out, finds the gap the last month of draining scoured through the bar, and leaves through it fast. Every feature is a consequence of that circuit closing.
So src/rip.mjs never writes down a velocity. It writes down a stream
function ψ and takes
u = ∂ψ/∂y v = −∂ψ/∂x
which is divergence-free identically — ∂²ψ/∂x∂y − ∂²ψ/∂y∂x is zero whatever ψ you chose. No corner of the field can invent water or swallow it. The soak run checks it numerically and gets |∇·u| ≈ 5 × 10⁻⁴ s⁻¹, which is the finite difference’s error and not the field’s.
ψ factors into three things doing three jobs:
- h(x), the longshore shape: a tanh that falls steeply through the channel
plus a linear term that makes the whole thing periodic. Where it falls
steeply,
v = −S·h′is a fast offshore jet; everywhere else the same term is a slow, broad onshore drift. One function, both halves of the circuit, balanced by construction. Because it is periodic, rips come in sets — the next channel is 130 m away, which is what stops “swim sideways” from being a free action. - S(y), the offshore profile: zero at the waterline, which is the beach as a boundary condition and the reason nothing ever flows through the sand. Peaks at 46 m, dies past the head.
- P(t), the pulse: rips surge as wave sets arrive, so the channel is 26% faster and slower on a 23-second cycle. You can wait for the lull.
Add three more drifting modes of a second ψ for the eddies — still exactly divergence-free, because any ψ is — and the channel axis stops being a perfect rail you can sit on.
The seabed is separate and simple: a plane slope, a bar standing proud of it, cut through by the channel, minus the trough scoured out behind it. Going offshore that reads shallows → trough over your head → bar you can stand on → deep water. Which means standing up is not the same thing as being out.
What the waves are for
The demo draws the wave field from the same depth map, and it is not decoration. Wave celerity in shallow water is √(gd), so the phase grid is the integral of the local wavenumber going offshore, and two things fall out of that for free:
- Crests bend. They slow down over the bars and run ahead through the deeper channel, so the wave lines bow forward at the gap. That is real refraction, and it is why energy focuses on the bars.
- The gap. Waves break where the shoaling height passes about 0.78 × depth. Over the bar, always; through the channel, never. So the surf line is a row of white bands with a calm slot punched through it.
That slot is the whole tell. The thing that looks like the safe place to swim — flat, quiet, no waves knocking you over — is the drain. It is genuinely why people walk into them.
Six policies, one channel
scripts/measure.mjs runs fixed policies from fifteen starting points spread
across the channel, at six channel speeds. Every policy reads only what a
swimmer could actually perceive — how deep it feels, which way the water is
dragging, how tired they are — never the field parameters. The swimmer tops out
at 0.95 m/s and has about two minutes of continuous swimming in them.
Made it to standing depth on the beach, % of starts:
| policy | 0.6 | 0.8 | 1.0 | 1.2 | 1.4 | 1.6 m/s |
|---|---|---|---|---|---|---|
| swim in, hard | 0% | 0% | 0% | 0% | 0% | 0% |
| swim in, paced | 0% | 0% | 0% | 0% | 0% | 0% |
| 45° to the beach | 100% | 100% | 100% | 100% | 100% | 100% |
| across, then in | 100% | 100% | 100% | 100% | 100% | 100% |
| float, then in | 0% | 0% | 0% | 7% | 7% | 20% |
| float, then across | 100% | 100% | 100% | 100% | 100% | 100% |
| across, rest on the bar, then in | 100% | 100% | 100% | 100% | 100% | 100% |
Median seconds, and stamina left on arrival:
| policy | 1.2 m/s channel | left |
|---|---|---|
| across, then in | 103 s | 30% |
| across, rest on the bar, then in | 116 s | 52% |
| 45° to the beach | 122 s | 18% |
| float, then across | 174 s | 10% |
| float, then in | 204 s | 12% |
| swim in, at any pace | never | — |
Four things in there are worth more than the ranking.
The failure is not strength, it is direction. Swimming at the beach fails at 0.6 m/s — well under the swimmer’s top speed — as completely as it fails at 1.6. It is not close at any setting, and the paced version fails as hard as the sprint. Meanwhile everything with a sideways component works everywhere, and going from the weakest channel in the table to the strongest costs the across-then-in policy sixteen seconds — 86 s to 102 s. Nearly the whole outcome is decided by the angle, not the speed.
Getting out early is much cheaper than getting out late. Going across immediately beats floating out first and then going across by seventy seconds and twenty points of stamina in a 1.2 m/s channel, and it is ahead on both counts at every speed in the table. Floating is the survivable option, not the efficient one.
Which is the honest reading of the float row. Public advice — don’t fight it, float, wave for help — is about not drowning, and in this model it does exactly that: floating in the channel never kills you, because the cost of treading is less than what you recover. What it does not do is get you in. The float-then-swim-straight-in policy fails not because floating was wrong but because the head of the rip is 90 m out and the swim home from there is longer than an ordinary swimmer has in them. Float then go across is 100%. The direction lesson does not go away just because you waited.
And the bar is the only free rest in the surf zone. Standing costs nothing and recovers three times as fast, so the policy that stops on it arrives twenty points better off than the one that grinds straight through, having taken thirteen seconds longer. Rest is faster than not resting, over a two-minute swim.
The third scenario adds a longshore current, and it makes the two ways out of a channel completely different transactions:
| crossing a 1.2 m/s channel under a 0.3 m/s longshore drift | made it | median |
|---|---|---|
| with the drift | 100% | 99 s |
| against it | 0% | — |
Same distance, same swimmer, opposite sign. Which way the water is already sliding is the second thing to read after the gap in the breakers, and the game draws it as an arrow off your own body because it is the one thing you genuinely cannot perceive from inside it.
Why you drown
Stamina costs the cube of effort, because drag power does: sprinting at 0.95 costs about four times what cruising at 0.59 does, which is the arithmetic that makes fighting a rip so much worse than it feels like it should be. But cubed cost alone was not enough to model the actual failure — an exhausted swimmer would simply stop swimming, float, recover, and never be in danger.
So the model takes the choice away. Below a quarter stamina you start losing the ability to rest: a floor rises under your effort until, at empty, you are thrashing at 0.45 whatever you asked for, and thrashing costs more than resting returns. That is the death spiral, and it is deliberately the part of the safety advice that sounds like folklore until you try to build the alternative: people do not drown because they are tired, they drown because past a point they cannot make themselves stop trying. Which also means the run is decided well before it ends. By the time the HUD turns red you have already spent the swim.
The one hard-won exception: you do not drown standing up. Running out on the bar strands you on it. That was a bug first — the demo cheerfully drowned a swimmer standing in a metre of water — and fixing it is what turned the bar into a real feature of the map rather than scenery.
Playing it
Arrows or WASD to swim, shift to sprint, let go to float. Or drag anywhere: the swimmer heads for your finger and pulls harder the further away it is, which is the whole control scheme on a phone. 1, 2, 3 switch conditions, R restarts, and your previous attempt stays on screen as a ghost track — the fastest way to see what you did differently is to look at the shape of the line.
Read the surf before you read the HUD. The gap in the white is the channel, the dotted line is where an average adult gets their feet down, and there are two of those lines: one on the bar, one at the beach, with water over your head in between.
Reuse
src/rip.mjs is headless and framework-free — no DOM, no canvas, no rendering.
createBeach(opts)→ a beach;scenarioBeach(key)for the three presets.flowAt(beach, x, y, t)→{u, v}in m/s. Divergence-free everywhere, for any parameters, by construction.depthAt,celerityAt,waveAt→ the seabed, wave speed, and a 0..1 breaking mask.waveAtis what the renderer’s whitewater is; nothing about the physics depends on it.canStand(beach, x, y)(feet down) andisAshore(feet down, and shoreward of the trough) — the two are deliberately not the same predicate.createSwimmer(x, y)/stepSwimmer(beach, s, dt, {dx, dy, effort})— the whole swimmer, including the panic floor. It takes a direction and an effort and knows nothing about keyboards.
Everything is metres and seconds and no number in the file is unitless except
effort. A different beach is a parameter change: spacing, channelW, jet,
jetY, drift, barY, troughDepth.
Gotchas
- Separable ψ pins the sign of everything at once. Wanting the offshore jet at the channel forces h′ negative there, which forces h positive up-coast of it, which forces the feeder currents to converge on the channel through the trough and diverge from it offshore. You do not get to choose those independently; the first decision makes the rest. That is the good news — it is also why an earlier draft with the sign flipped had the feeders running the wrong way and no way to fix it locally.
- The channel is the calmest water on the map, and the game has to let it look that way. Anything that highlighted the danger — a red overlay, arrows in the channel — would have destroyed the only thing worth teaching. The drift arrow is drawn from the swimmer, not on the water, so you learn to read the surf instead of the annotation.
- The trough is why standing up is two things. With a plain slope the bar was standable straight through to the sand, and the game collapsed into “leave the channel, win”. The trough is the second act, and the feeder current in it runs back toward the channel — so crossing it means angling away from the rip you just left, not turning square to the beach.
- The wave phase grid is precomputed over one longshore period, because the field is periodic and one period is the whole coast. Everything drawn per frame is a bilinear sample of that, which is what keeps a full-screen water raster inside a frame.
demo/bundles its own copy ofrip.mjs— self-contained by contract (ADR-0002). Re-copy after editingsrc/.- The numbers above are reproducible:
node scripts/measure.mjsprints every table in this page, and--csvgives one row per run.


