A river crossing seen from a low camera: infantry wading through a pale translucent ford with the carved riverbed visible under them, deep blue water either side

Water you can wade through

Rendering & Graphics11 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

Written up later from the commit history. Dated to when the work landed.

A river used to end the conversation. However wide it was, it split the map in two for every ground unit on it, and the only answer was to build a dock, build a transport and ferry an army across four at a time.

Now some rivers have a ford in them. Infantry wade it, wargs charge through it, and a defender who knows where it is can stand on the far bank and make you pay for the crossing. Ships can enter it too, at half speed, which turns the shallows into a hazard a fleet routes around rather than a shortcut. Submarines cannot dive there at all, so a sub that blunders into the shallows is forced to the surface until it reaches deep water again. Docks and oil rigs can stand in it. Ordinary land buildings cannot, so a base still cannot creep across a river.

Two armies facing each other across a misty river at dawn, goblin warg riders on the far bank and a dwarf shield wall on the near one, with a broken stone bridge between them

The whole thing is one new terrain value. It exists because the map importer needed it. A later generation of RTS distinguishes impassable deep water from wadeable shallows in its terrain data, and our converter had been collapsing that distinction into a wall, which quietly rewrote the design of every map it touched. You can see the results on the maps page.

One value, appended, on purpose

Terrain is saved as a number per tile, so where a new value goes in the list decides whether every map on disk still means what it meant yesterday. It was appended last for exactly that reason, and every map in the library saved back byte-identically after the change.

That last clause is the check that matters, and it needs running rather than reasoning about, because "appended, so it must be fine" holds right up until the list turns out to have been sorted alphabetically two years ago. Round-trip your entire corpus of saved data through the new code and compare bytes:

// Prove an enum change did not alter the meaning of anything already on disk.
for (const file of readdirSync(MAP_DIR)) {
  const before = readFileSync(`${MAP_DIR}/${file}`);
  const after = serialise(deserialise(before)); // NEW enum on both sides
  if (!before.equals(after)) throw new Error(`meaning changed: ${file}`);
}

The version that catches real bugs runs in continuous integration over every list of saved constants in the project, not once by hand on the one you happen to be editing.

The cheap part is what shallow water reuses. Its base terrain is still water, so the 2D transition sprites and the 3D water surface both apply with no change and no new art. The 3D water already works out its colour, its foam and the depth of the bed from how far each point is from the nearest shore, so pinning shallow tiles near the shore end of that scale produces shore-style water in every biome for nothing.

The map generator grows the shallow band as its own step. It is guarded so a band can never bridge two landmasses that water was separating, and large bodies of water keep a deep spine down the middle, which is what stops a river quietly becoming a puddle you can walk across anywhere. Oceans get a wide beach. Standard maps get a single tile, so lakes and rivers survive as obstacles.

Goblin warg riders charging through ankle-deep swamp water under bare mossy trees, torches raised, spray thrown up around the wolves' legs

Amphibious terrain broke three things that had never been wrong

A tile that is walkable and water is a new shape, and three long-standing pieces of code turned out to depend on that shape not existing.

The shoreline pass marks dry land next to water as unwalkable. That is the beach rule, and it exists so units do not stand with their feet in the sea. Shallow water is water, so every ford flagged its own banks impassable and then flagged itself. The failure mode is the bad kind: no error, no warning, nothing in the log. The crossing simply does not work, and a narrow ford seals completely. Imported crossings are often one or two tiles wide, so this would have hit almost all of them. The pass now starts from dry land only, and only deep water makes a beach.

The second was buildability. Land placement asked whether a tile was walkable, and shallow water is walkable, so a base could have expanded straight across a river. That needed a genuinely separate answer rather than a tweak to the first one, so the map gained a second layer recording where land buildings may stand, kept in step as buildings go down. A dock placed in a ford now blocks both ground and naval movement through that tile, which is a real tactical option that nothing in the change was aiming for.

The third is the one that transfers. A cleanup pass in the map converter returns any stone tile sitting on water back to water, and it carries a hard rule: it must finish with zero left. Stone deposits are placed by snapping toward the nearest land, and that snap falls back to a search that asks whether a tile is walkable. A ford now answers yes. So stone aimed straight at the shallows, where it looks like land to every test the converter makes and where a quarry can still never be built.

The guard would not have caught any of it, because it only tested whether the base terrain was water, and a ford's base terrain is the new value. It would have walked past every one of those tiles and reported a clean sweep, which is worse than having no guard at all, because it converts a visible problem into a signed-off one.

Auditing the checks, not just the callers

The procedure that found all three is short enough to write down, and it applies to any state that is two existing things at once: a user who is both an admin and a guest, an order that is both refunded and shipped, a tile that is both walkable and water.

  1. Name the two existing conditions your new state now satisfies together. Here: walkable, and water.
  2. Find every place either one is asked. A search across the whole tree, not a memory. Include the hits in tools and tests.
  3. At each one, ask what the new state should answer, and expect the answer to differ per site. The point of the exercise is that there is no single right answer. Movement wants yes. Land building wants no. Beach generation wants no. Ship speed wants yes with a penalty.
  4. Where two sites want different answers, that is a second layer, not a parameter. Land placement stopped asking about walkability and got its own array. Deriving one from the other with a flag would have put the divergence inside a function two callers share, which is where it hides.
  5. Re-read every guard, because a guard asks a question too. This is the step that nearly shipped a bug.

Step five is the transferable half. Adding a state to a system means auditing the things that check the system as carefully as the things that use it, and the checks are the code that never gets searched, because it is already passing.

The foamiest water on the map was the calmest place on it

The visual side had a clean inversion in it. Wave height was already correct, falling to exactly zero on a shallow tile. But those waves are about 3 pixels on a 32 pixel tile and essentially invisible. What a player reads as rough water is the surface pattern: the foam blobs, the streaks and the wash ring. None of that was gated on depth. Worse, foam fades with distance from shore, so it peaks at the shore end, and fords carried a foam value around 0.92 to 0.98 against 0.35 in open water. The calmest water in the world was rendering as the roughest.

A storm-lit naval battle in deep water, a burning goblin raft on one side and a dwarf ironclad firing on the other, heavy swell and rain between them

The fix keys everything on a single shallowness weight rather than on distance from shore. Chop against a deep-water coastline is correct and looks right, and only the fordable shallows should go still.

Keying on one weight also solved a seam. At the shelf where a ford drops into deep water, the shore-distance value steps from the shallow cap of 0.30 to the deep floor of 0.85 across a single tile boundary. That one step was driving surface transparency, water colour and foam at once along the same axis-aligned line, so fixing any one of them still left a visible straight edge in the other two. All three now ride the same weight, perturbed by noise, so they change together over about a tile along an organic contour.

Two numbers encode a rule rather than a taste. Shallow water runs at 0.72 opacity at the edge and 0.82 at the shelf. Deep water stays at exactly 1.0, and it has to: the ground shader cuts a hole in the terrain under real water tiles so the water surface can own those pixels, and anything below full opacity there shows sky through the sea floor. The shallows keep their bed painted, so they, and only they, can afford to be see-through. That is what makes a ford read as ankle deep instead of as a paler patch of sea.

Making the canvas renderer reproduce a shader

The second half of the work started from a misdiagnosis. Adam raised two complaints about the 2D view of water: that it did not match the 3D view, and that it did not animate. They were one bug.

The 2D renderer was tiling the water texture, which the 3D view never shows at all, because the 3D ground removes itself under deep water. So 2D was drawing a texture that matched nothing, and because that texture is deliberately near-featureless, scrolling it was imperceptible.

What a player reads as 3D water is the ink contours the water shader draws, and those depend on world position and time only. No shore distance, no scene state. That makes them reproducible exactly rather than approximately, so the 2D water now generates the pattern at runtime instead of shipping art. No asset, no atlas, no upload to the CDN, about 30 ms once per session, and the tiles are white masks tinted per biome so one bake serves every biome.

One thing did not port directly. The noise the shader uses never repeats on a straight line at any scale, which is what makes it look natural, but a canvas pattern must repeat, and building one from that noise drew a visible grid across the sea. The 2D side got exactly-repeating noise instead, matched to the shader on the two statistics that decide the look: average gradient, which sets contour thickness, and the spread of values, which sets coverage. Checked offline against a JavaScript port of the shader's own noise, mean foam came out at 0.0279 against the shader's 0.0278. One subtle pulse in the shader's threshold was dropped rather than approximated, because it moves the contour by about 0.15 world pixels, a third of a screen pixel, an order of magnitude under the drift already there.

Measured after: 14.9% of pixels change over one second, taken as a pixel diff of two frames a second apart, the water pass costs 0.172 ms on average, and it never forces the ground to be redrawn. That last one matters because 2D water sits on top of the chunked ground described in the coverage field both renderers read, and animation that dirtied chunks would have cost far more than the animation itself.

A test scene built to be the hard case

Shallow water looks easiest where a shelf runs deep, then shallow, then land, because every value involved has both of its extremes on screen. So the test scene is the opposite: a generated map with an isolated pool of pure shallow water and a dry apron wide enough that no other water can touch it, then ground units on the bank, at the edge and mid-pool, plus a dock, a building whose entire footprint sits on water, and three boats. Every one of them was placed by the game's real placement rules rather than forced into position.

Building the degenerate case on purpose is the technique. A test scene assembled from the comfortable case tells you the code works where you already believed it did.

Terraces and water beds were built in the same week, described in making cliffs real terrain, and the crossings themselves come out of the converter in reading two decades-old map formats, which trusts one format's walkability data for shallow water and deliberately refuses to guess for the other, where light and dark water proved to be a rendering cue with no gameplay meaning across 1,789,952 tiles.

Questions

What is a ford in an RTS map?

A shallow crossing that ground units can walk through. It turns a river from a hard wall into a chokepoint you can contest, because attackers have a route that does not need a transport ship and defenders know exactly where it is.

Can ships sail through shallow water?

Yes, but slowly. Any hull over the shore band sails at half speed, so the shallows read as a hazard to route around rather than a free shortcut. Submarines cannot dive there at all, and a sub that enters is forced to the surface until it reaches deep water again.

Can you build in shallow water?

Docks, oil rigs and other coastal structures can stand in it. Ordinary land buildings cannot, which is enforced by a separate buildability layer rather than by the walkability test, so a base cannot expand across a river.

← All posts