workshop private

← all creations

Undertow

games · created 2026-09-08

You are out of your depth in the gap between the breakers, and the gap is moving offshore faster than you can swim. Six policies measured against the same channel; every one that points at the beach drowns, and the fastest way to the sand is sideways.

physicssimulationcanvasgame-feel

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:

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 headbar 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:

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:

policy0.60.81.01.21.41.6 m/s
swim in, hard0%0%0%0%0%0%
swim in, paced0%0%0%0%0%0%
45° to the beach100%100%100%100%100%100%
across, then in100%100%100%100%100%100%
float, then in0%0%0%7%7%20%
float, then across100%100%100%100%100%100%
across, rest on the bar, then in100%100%100%100%100%100%

Median seconds, and stamina left on arrival:

policy1.2 m/s channelleft
across, then in103 s30%
across, rest on the bar, then in116 s52%
45° to the beach122 s18%
float, then across174 s10%
float, then in204 s12%
swim in, at any pacenever

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 driftmade itmedian
with the drift100%99 s
against it0%

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.

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