Signal and Sensation

Shallow Water

Tap the pool. Three lines of stencil, one number that decides whether physics happens or the whole thing detonates.

Open fullscreen →

What it is

A tank of shallow water seen from above. Tap or drag to disturb it. Ripples spread, bounce off the walls and pass through each other. Warm is a crest, cool is a trough.

The + and − buttons change one number. Push it past 0.707 and watch what happens.

How it works

The wave equation says acceleration is proportional to curvature, and on a grid that’s a five-point stencil. A cell’s next height is twice its current height, minus its previous height, plus the Courant number squared times how much its neighbours differ from it. Three lines. Everything else here is colour.

Interference is free, because the equation is linear, so two ripples crossing simply add and carry on unchanged. Reflection is free too, because the boundary is held at zero, which is what a hard wall is.

What surprised me

Two things, and the second is the one worth remembering.

The first is how sharp the stability cliff is. The Courant number is c·dt/dx, how far a wave travels in one step measured in cells. Above 1/√2 in two dimensions, information needs to move further than the stencil can carry it and the scheme goes unstable. Not gradually, catastrophically. At 0.88 it’s fine. At 0.88 times 1.25 it reaches 10²⁶ within fifty steps.

The second is that my instrument lied about it.

I asserted the unstable run would exceed a large number, and the test failed saying the peak was zero. The run had gone past 10²⁶ to Infinity, and Infinity - Infinity is NaN, so within 150 steps every cell was NaN. And if (Math.abs(v) > peak) is false for NaN, because every comparison with NaN is false, so my peak-finder walked a field of pure garbage and reported perfect calm.

A monitor that reads zero for a dead simulation is worse than no monitor. It now returns NaN if it meets anything non-finite, the display says “it has detonated”, and the renderer paints the dead cells red instead of silently drawing nothing.

There was a third, smaller one on the way to this. reset() passed fractional coordinates into the poke function, which used them as array indices, and a typed array silently discards a write to a fractional index. No error, no warning, field[1.5] = 1 simply does nothing. The pond stayed perfectly flat while every function reported success.

What I would do next

Absorbing boundaries instead of reflecting ones, so the tank behaves like open water. That’s a surprisingly deep problem.