Skip to content
05 / 10Interaction7 min read

Pool

A pond you can tap, from two arrays and one line of arithmetic. The line is the wave equation written out longhand, and the thing that makes it look like water is not the height — it is the slope.

Not on the site yet

Not on the site yet. This is the version that will ship on the homepage, under the hero — a shallow band of water you can disturb on the way past. Everything below is the demo’s own source, not an excerpt of something already running.

Paused
Tap the surface. Then drag Damping down to 0.900 and tap again — the ripple dies before it reaches the wall.

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). Fix at one half and the two middle terms cancel exactly: 2·h against -2·h, gone. What is 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.

src/components/lab/demos/PoolDemo.tsx — the wave step
// ---- the wave step ----
// The average of the four neighbours minus the value two steps ago,
// damped. That IS the discrete wave equation: the second time
// derivative is the Laplacian, and `nxt` is holding the previous frame.
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;
Fifteen lines, four of which are the comment, and one of which is the simulation.

The buffers are then 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 is holding the frame before last. Both loops run from 1 to w - 2 and h - 2, so the outer ring of cells is never written and sits at zero forever. That ring is the wall, and it is 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 is the term that removes energy from the system, and it is 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 has lost most of itself before it reaches the edge, and you get a surface that flinches rather than one that carries.

The default of 0.982 is the value at which a drop crosses the box, reflects off the far wall, and comes back as something you can still see but would not describe as a second wave. That interval — one clear return trip — is the whole difference between water and jelly, and it is set by a single multiply.

ConstantDefault
damping0.982energy removed per step. 1.0 never settles, below 0.95 the ripple dies before it crosses the box
SCALE4the field runs at a quarter of the box in each axis — one sixteenth of the cells
drop strength340peak depression of a tap, falling linearly to zero at the rim
drop radius3 cellstwelve pixels of box at SCALE = 4. A one-cell impulse is a click, not a splash
base level232the paper grey a flat pond sits at, out of 255
slope gain1.7contrast on the lighting. Clips to white at a slope of about 13.5 and to black at about -136

A 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. That is one sixteenth of the cells to step and one sixteenth of the pixels to write, and the temptation is to call it a performance shortcut and apologise for it.

It is not a shortcut. A wave four pixels across is not a wave anyone can see — it is noise with a wavelength. Running the field at display resolution would cost sixteen times as much to produce a surface that reads as softer, because the detail it buys is below the scale at which the eye reads a ripple as a ripple. The upscale is doing real work: 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 that is easy to get wrong, 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 does not read a water surface by its elevation. It reads it by which way the surface is tilted, because tilt is what decides where the light goes.

src/components/lab/demos/PoolDemo.tsx — the shading pass
// ---- shading ----
// The height itself is nearly invisible; what the eye reads as water is
// the SLOPE. Lighting by the horizontal gradient is one subtraction and
// it is the whole difference between a grey blur and a surface.
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);
Line 102 is the entire difference between a grey blur and a surface.

One subtraction of the two horizontal neighbours, and that is 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 buys 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 is the right way round. It also means a fresh tap — neighbours a couple of hundred units apart — is pinned 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 is 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 is not a nicety: there is no dt anywhere in the update, so the wave speed and the damping are both per step. Drive it per frame and a 144 Hz display runs the pond 2.4× fast — same code, different water.

useSketch2D.ts
// ----- 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++);
  }
});
An accumulator drained in whole 60 Hz steps, a clamp so a background tab cannot stampede, and a resync so a pause never replays.
Canvas · Simulation ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E