A pond is two arrays, one line of arithmetic, and a decision about what to shade.
Water on the web is usually bought rather than built. A normal map scrolling under a fragment shader, or a video loop with a mask over it. Both look like water from a distance, and neither responds to being touched, which is the only property of a pond anyone actually wants on a page.
The alternative is small enough to fit in a paragraph. Keep a height field (one float per cell) in two buffers, advance it with the discrete wave equation, and light it by the difference between neighbouring heights. No physics library, no GPU, no per-particle anything. The cost is a pair of Float32Arrays and two nested loops.
One line of arithmetic
The wave equation says the second derivative of height with respect to time equals the Laplacian. Discretise both sides and you get h(next) = 2·h - h(prev) + c²·(sum of four neighbours - 4·h). Now fix c² at one half and something nice happens: the two middle terms cancel exactly, 2·h against -2·h, gone. What's left is half the neighbour sum minus the value from two steps ago, which is why the cell's own current height appears nowhere in the update.
// the wave step: half the neighbour sum minus the frame before last,
// damped. nxt holds the frame before last until the swap below
for (let y = 1; y < h - 1; y++) {
const row = y * w;
for (let x = 1; x < w - 1; x++) {
const i = row + x;
nxt[i] =
((cur[i - 1] + cur[i + 1] + cur[i - w] + cur[i + w]) / 2 - nxt[i]) * p.damping;
}
}
const swap = cur;
cur = nxt;
nxt = swap;The buffers then get swapped rather than copied. Three assignments exchanging two references, instead of moving every cell in the field once a frame. The naming is the only confusing part: nxt is being read as the past and written as the future in the same statement, because after the swap it's holding the frame before last. Both loops run from 1 to w - 2 and h - 2, so the outer ring of cells never gets written and sits at zero forever. That ring is the wall, and it's why this behaves like a tray of water rather than an open sea.
Damping is the whole of the feel
The damping factor multiplies the entire update, so it applies to the wave rather than to the surface. It's the term that removes energy from the system, and it's the only term that does. At 1.0 exactly, the scheme is lossless and a tap rings until you close the tab, which is why the slider stops at 0.999. Below about 0.95 the ripple loses most of itself before it reaches the edge, and you get a surface that flinches rather than one that carries.
How did I land on 0.982? By tapping and watching, mostly. It's the value where a drop crosses the box, reflects off the far wall, and comes back as something you can still see but wouldn't call a second wave. One clear return trip. That interval turns out to be the whole difference between water and jelly, and it's set by a single multiply.
1.0 never settles, below 0.95 the ripple dies before it crosses the boxSCALE = 4. A one-cell impulse is a click, not a splashA quarter of the resolution, on purpose
The field is built at W / 4 by H / 4, written into an ImageData of that size on an offscreen canvas, and drawn back up to the full box with smoothing on. One sixteenth of the cells to step, one sixteenth of the pixels to write. The temptation is to call that a performance shortcut and apologise for it.
It isn't a shortcut. A wave four pixels across is not a wave anyone can see, it's noise with a wavelength. Running the field at display resolution costs sixteen times as much to produce a surface that reads as softer, because the detail it buys sits below the scale at which the eye reads a ripple as a ripple. The upscale is doing real work too: bilinear interpolation between quarter-resolution cells is a free smoothing pass on a field that wants one.
The height is nearly invisible
This is the part I got wrong first, because the obvious move is to shade by the quantity you just computed. Map height to brightness and you get a grey blur that pulses. Technically a correct visualisation of the field, and not remotely water. The eye doesn't read a water surface by its elevation. It reads it by which way the surface is tilted, because tilt decides where the light goes.
// shading: the eye reads slope, not height.
// one subtraction is the whole lighting model
const d = img.data;
for (let y = 1; y < h - 1; y++) {
const row = y * w;
for (let x = 1; x < w - 1; x++) {
const i = row + x;
const slope = cur[i - 1] - cur[i + 1];
const v = Math.max(0, Math.min(255, 232 + slope * 1.7));
const o = i * 4;
d[o] = v;
d[o + 1] = v * 0.985;
d[o + 2] = v * 0.955;
d[o + 3] = 255;
}
}
octx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(off, 0, 0, W, H);One subtraction of the two horizontal neighbours, and that's the lighting model. Only the horizontal component is taken, which puts the light flat on one side. A real normal would need the vertical difference too, and on a field this shallow it would buy a second axis of shading for a second subtraction. The ramp is deliberately lopsided: 232 + slope × 1.7 reaches 255 at a slope of about 13.5 but needs about -136 to reach zero, so highlights blow out long before shadows crush. On a pale surface that's the right way round. It also means a fresh tap, with neighbours a couple of hundred units apart, pins to both ends of the ramp for its first few frames and only becomes tonal as the energy spreads.
It does not own a frame loop
No demo on this site starts its own requestAnimationFrame. There's a master tick, owned by the experience provider, and the harness every 2D sketch here is built on registers a callback against it and drains it in whole 60 Hz steps. For this sketch that isn't a nicety. There's no dt anywhere in the update, so the wave speed and the damping are both per step. Drive it per frame instead and a 144 Hz display runs the pond 2.4× fast. Same code, different water.
// ----- the master tick, in whole 60 Hz steps -----
const STEP = 1000 / 60;
let last = -1; // -1 → resync to the next timestamp, so a pause never replays
let acc = 0;
const unTick = registerTick((s: TickState) => {
if (!runningRef.current) {
last = -1;
return;
}
if (last < 0) last = s.t;
acc += Math.min(s.t - last, 100); // a background-tab gap can't stampede
last = s.t;
while (acc >= STEP) {
acc -= STEP;
draw(frame++);
}
});