The lake
this site · /pond- side project
- 2026
- WebGL2
- WebRTC
- Web Workers
- Solo
The lake at the top of this site is about 5,400 lines of WebGL2 and TypeScript. The water is a simulation, the light is physics done cheaply, the land is painted in code, and the fish, ducks, frogs and heron read the same terrain, so nothing swims onto the beach.
It is also the most expensive thing on the page, so it goes last: the text paints first, it never blocks input, and it costs nothing when you can't see it.
Full screen you can sit down and fish it, and the rod can be your phone: scan the code on the lake, point the phone, and lift it when a fish bites. That half is WebRTC, two independent pairing servers and a little orientation maths.



- Type
- side project
- Role
- Solo
- When
- 2026
- Stack
- WebGL2, GLSL, Web Workers, OffscreenCanvas, WebRTC, Cloudflare Workers, Durable Objects, Device sensors, TypeScript, React
- Links
- Full screenThe rod
What I built
Water that is simulated
A damped wave equation on the GPU, stepped at a fixed 60 Hz, stopped by the shore.
How it worksLight, cheaply
Caustics from how refracted triangles bunch up, absorption by depth, and glints of the sun.
Terrain painted off the main thread
A worker paints it on an OffscreenCanvas and hands it back without copying.
How it worksA pond that is alive
Fish, ducks, frogs and a six-state heron that read the terrain and push waves into the water.
Your phone as the rod
A QR code pairs a phone over WebRTC, and the way it points steers the hook.
How it worksNever blank, never in the way
A blurred picture first, paused off screen, one still frame under reduced motion.
The sound synthesis and two of the toys elsewhere on the site are third-party packages.
How it fits together
Water that is simulated, not drawn
Each frame, the height of the water steps forward on the GPU: a damped wave equation on half-float textures that swap every step, at a fixed 60 Hz whatever the display runs at.
Sampling eight neighbours rather than four keeps a ring round as it spreads, and the shore stops it dead.
hero/lake/shaders.ts · the simulation step
float l = texture(u_state, uv - vec2(u_px.x, 0.0)).r;float r = texture(u_state, uv + vec2(u_px.x, 0.0)).r;float d = texture(u_state, uv - vec2(0.0, u_px.y)).r;float u = texture(u_state, uv + vec2(0.0, u_px.y)).r;float a = texture(u_state, uv + vec2(-u_px.x, -u_px.y)).r;float b = texture(u_state, uv + vec2(u_px.x, -u_px.y)).r;float c = texture(u_state, uv + vec2(-u_px.x, u_px.y)).r;float e = texture(u_state, uv + vec2(u_px.x, u_px.y)).r;float lap = (4.0 * (l + r + d + u) + (a + b + c + e) - 20.0 * h) / 6.0;v = (v + u_c2 * lap) * u_damp;h += v;// Land holds no water, so waves stop dead at the shore.float keep = smoothstep(0.2, 0.65, texture(u_info, st).r);h *= keep;v *= keep;Painted off the main thread
Painting the terrain takes the better part of a second on a laptop and several on a phone. A worker does it on an OffscreenCanvas, and the layers come back as bitmaps whose memory is handed over rather than copied.
hero/lake/paint.worker.ts
const bitmap = (c: Picture) => (c as OffscreenCanvas).transferToImageBitmap();self.onmessage = (event: MessageEvent<PaintRequest>) => { const { id, height } = event.data; const painted = paint(height); const layers: Layers = { ...painted, bed: bitmap(painted.bed), ground: bitmap(painted.ground), canopy: bitmap(painted.canopy), edge: bitmap(painted.edge), }; const { field, info } = layers; self.postMessage({ id, layers }, { transfer: [ layers.bed, layers.ground, layers.canopy, layers.edge, info.data.buffer, field.water.buffer, field.depth.buffer, field.canopy.buffer, ], });};Your phone as the rod
Sit down to fish and the lake shows a QR code and six letters. The phone that scans them becomes the rod: the way it points steers the hook, a bite tugs in your hand, and lifting it sharply brings the fish out. The page is the game; the phone only sends where it points and when it was lifted, sixty times a second.
Pairing is the part that can fail, so there are two servers that can do it and they share nothing: our own Cloudflare Worker, one hibernating Durable Object per room, and PeerJS's free public one. The page opens the room on both. The phone tries ours, and brings PeerJS in as well if ours is slow, unreachable or has no page in that room. Whichever answers carries the game, and if it drops the other takes over.
Once either has introduced them the messages go straight between the devices over a WebRTC data channel, usually a few milliseconds apart, with no server in the path: the aim on an unordered channel that never resends, since a late sample is worthless, and everything else in order. Where a network will not allow a direct path the relay keeps carrying them itself, a little slower but never stuck. A socket can die without closing, so both sides send a heartbeat Cloudflare answers without waking the room, and one phone holds a room at a time.
cast/link/link.ts · whichever answers
/* The link carrying the game: the one already doing so while it can, else the first with the other device on it. */const pick = () => { if (active?.state.peer) return active; active = links.find((l) => l.state.peer) ?? null; return active;};/* ... published on every change: */// The phone: a relay that cannot help brings// PeerJS in at once.if ( RELAY_URL && role === 'rod' && !a && links.length === 1 && (links[0].state.failed || links[0].state.missing)) { withPeer();}/* ... and at the start: */if (RELAY_URL) { links.push(createRelayLink(RELAY_URL, transport())); if (role === 'host') withPeer(); else fallbackTimer = window.setTimeout( () => !pick() && withPeer(), HEAD_START, );} else { links.push(createPeerLink(transport()));}Where a phone is pointing
A phone does not report where it points, and the one angle that looks like it (beta) stops meaning anything once the phone is rolled. So the aim is read from the whole orientation: the three angles are turned into the direction of the phone's top edge in the world, or of its back for a phone held up like a screen, and the yaw and pitch of that direction become how far out and how far to the side the hook goes. Rolling the phone then does not move the aim, and which way it is held is decided once, when you point at the screen to set the centre.
A lift is read from the same direction rising fast, rather than from the rotation rates, because Safari and Chrome do not name those axes the same way. And while the phone is swinging the aim holds where it was a moment before, so the lift itself does not drag the hook off the fish it was lifted for. What is left is smoothed with a one euro filter: steady when the hand is still, quick when it moves.
None of it is required. With no sensors to read, refused, not on https, or simply absent, the lake on the phone becomes a trackpad: the float follows your finger and a tap lifts.
cast/sense.ts · the aim, from the whole orientation
export function pose( alpha: number, beta: number, gamma: number, hold: Hold,): Pose { const ca = Math.cos(alpha * RAD); const sa = Math.sin(alpha * RAD); const cb = Math.cos(beta * RAD); const sb = Math.sin(beta * RAD); const cg = Math.cos(gamma * RAD); const sg = Math.sin(gamma * RAD); // The phone's top edge in the world (east, north, // up), and its back. const p = hold === 'top' ? [-sa * cb, ca * cb, sb] : [-(ca * sg + sa * sb * cg), -(sa * sg - ca * sb * cg), -cb * cg]; const n = Math.hypot(p[0], p[1], p[2]) || 1; return { yaw: Math.atan2(p[1], p[0]) * DEG, pitch: Math.asin(clamp(p[2] / n, -1, 1)) * DEG, };}Next. Publishing LCP, INP and frame time on a mid-range phone.
