Undertow v0.5.1: A Divergence-Free Ocean, and Three Force Models That Died
By RavenIron Games
Valheim’s ocean has weather but no water. Waves answer the wind, storms raise them, a hull takes damage in a seaway — and nothing moves. A karve at half sail on a fixed heading lands exactly where geometry says, every time, anywhere on the map.
Undertow gives the sea its own motion. Here is what that took.
1. The Sea Is A Stream Function
The naive way to build currents is to place them: draw some gyres, add a coastal band, tune until it looks right. That produces water with sources and sinks — places where flow appears out of nothing or vanishes into it — and a boat crossing one behaves like the sea has a bug.
Real water is divergence-free. So instead of placing flow, Undertow defines a scalar field over the ocean and takes its perpendicular gradient:
// open ocean: the perpendicular gradient of the stream function
float season = SeasonRotationDegrees * (float)DEG2RAD * NormaliseSeason(seasonIndex);
StreamGradient(x, z, seed, season, out float u, out float v);
float magnitude = SeasonMagnitude[NormaliseSeasonIndex(seasonIndex)]
* (1f + s.TideAmplitude * tideSin);
u *= magnitude;
v *= magnitude;
Rotating a gradient ninety degrees makes the result divergence-free by construction — it isn’t a property we test for, it’s one the maths cannot violate. Gyres, races between close islands, and dead water behind a headland all fall out of one mechanism instead of being authored individually.
The shore then steers it. Undertow samples the seabed four times around the point, takes the height gradient, and bends the flow along the coast with a slight push toward it — which is why you don’t doze at the tiller with the coast downwind. The season rotates the whole field; the tide scales its magnitude and reverses the coastal stream.
2. Zero Network Traffic, By Construction
The field is a pure function of the world seed, the position, the world clock and the season. No state, no replication, no authority.
That means two players a thousand metres apart compute the same water and never have to agree about it. There is no sync message to drop, no drift to reconcile, no host to be authoritative. It was verified the only way that claim can be: a dedicated server and a connected client independently produced a byte-identical transect of the same seed’s ocean.
3. Entrainment, Not Drag — And Why Two Models Died First
This is the part that took three attempts, and every one was killed by a measurement rather than by review.
Attempt one: drag toward the water. Force proportional to (water - hull), so a boat asymptotically takes up the speed of the water it sits in. Physically respectable, and wrong here — because vanilla already models hull-water resistance through m_damping, m_dampingForward and m_dampingSideway, computed against the hull’s absolute velocity, i.e. assuming the water is still. A second drag term against the hull’s full velocity double-counts it. A karve making 6 m/s through water moving at 0.3 would receive 0.6 * (0.3 - 6) = -3.4 m/s² — the sea as a brake on every boat under way. Unphysical, and it would read to a player as the mod breaking sailing.
Attempt two: calibrate a push against vanilla’s damping so the two balance at the water’s speed. That requires knowing the hull’s damping coefficient — and m_dampingForward is a serialized Unity field that every boat prefab overrides. The class default read off the decompile is 0.01, which applies to no actual boat: a VikingShip measured ~0.0053 effective and settled at 1.38× the water’s speed instead of 1.0. A raft, a karve and a longship would each land on a different multiple. No single constant can be right.
Attempt three, and the one that shipped: saturation. Fade the push out as the hull’s speed along the current approaches the water’s own. The equilibrium is then set directly, by construction, without knowing anything about how a given hull damps:
// Full push at rest, fading to nothing as the hull's speed ALONG THE CURRENT
// reaches the water's own. CLAMPED TO [0,1] — the anti-braking guarantee: the
// result can never oppose the current, so a boat under sail is never slowed,
// it merely stops being helped.
float head = 1f - (hullAlongCurrent / waterSpeed);
if (head > 1f) head = 1f;
else if (head < 0f) head = 0f;
float k = strength * dt * head;
// NEVER HAND OVER MORE THAN THE WATER'S OWN SPEED IN ONE TICK. Without this, a
// long frame — a lag spike, a loading hitch, a breakpoint — multiplies coupling
// by a large dt and launches the hull.
if (k > 1f) k = 1f;
Two clamps, two guarantees. The [0,1] on head means the current can never become a brake; a hull driving upstream gets capped at a full push rather than an amplified one. The clamp on k bounds a single tick by the thing it is modelling, so a frame hitch can’t fling a longship across the map.
The proof is that hull independence is now measurable: a karve and a longship drifting in the same water settled at 0.86 and 0.96 against a target of 1.0 — two hulls with different damping constants converging on the same answer, which is the entire claim.
4. The Season It Could Not Read
One bug worth recording because it wasn’t ours, and because the fix deliberately went elsewhere.
The current field takes a season as input. Undertow doesn’t run a season clock — that would be a second competing clock, which the house rules forbid — so it asks Ragnarok’s Wrath, by reflection, through a soft bridge.
It got Spring. Always. On every client, forever.
RW’s SeasonSystem.Current was assigned only inside Tick(), which RW gates on the simulation authority. On a dedicated server that meant no client ever computed a season, and since drift is computed by whichever peer owns a hull — a player’s machine, never the server’s — the seasonal term was inert everywhere it mattered. Nothing looked broken: every client agreed with every other client, so boats never desynced. They just all agreed on the wrong season.
The fix belonged in RW, not here. Ragnarok’s Wrath 0.25.0 added a season broadcast on a ten-second cadence, and Undertow changed nothing at all — WrathBridge reads the same property it always read and simply started getting a true answer. Against an older RW it still reads Spring, which is exactly today’s behaviour, so there’s no version floor to enforce.
Currents, tides, storm surge, flotsam and drifting swimmers are all live and dedicated-server verified. Full details on the Undertow project page.