The lake, full size
ScrollPinch to look closerDrag to wanderTap anything
Fishing
How it works
Steer the hook to a fish and wait for a bite, then yank it out: move and click with a mouse, drag and tap with a finger, or point your phone and lift it sharply. Five kinds swim here. Arrows and the space bar work too.
- One lake, a real camera. This is the same painted lake, not a new scene. The camera drops to the fisherman, turns the way he faces and tips up into his view: every pixel’s ray is followed down to the water on the GPU, and the creatures are drawn through the same perspective, each as large as its distance makes it.
- The lake’s own fish. The fish you watched from above are the ones you catch, with a few more let into the water in front of the jetty. They come to look at the lure, and one takes it for a second: that is the bite, and a yank then always lands it. A yank at nothing sends them off.
- Something else on the hook. Now and then a fish comes up with something about me: what I do off-screen, what I made before any of this was work, the work I am proudest of, the character I like and why, what I am listening to right now, and once in a long while how I got my job. Each goes in your creel, which this browser keeps for next time.
- Your phone as the rod. Scan the QR code or type the six letters on your phone, point it at the water and lift it when something bites. One phone to a lake at a time; how the two find each other and talk is the next part.
The rod
Two links, one rod
Your phone becomes the rod with no app. The short version: a WebSocket introduces your phone to this page, then the two talk directly over WebRTC, and where a network won’t let them, the relay carries the messages itself. It pairs in a couple of seconds. In detail:
- 01
WebSocket, to meet
Your phone and this page can’t find each other on their own. Both open a WebSocket to a Cloudflare Durable Object, one per room, which introduces them; PeerJS’s public server is tried alongside after 2.4 s, in case ours is down, and the phone takes whichever answers.
- 02
WebRTC, to play
Once introduced they talk directly over a WebRTC data channel: the aim 60 times a second on an unordered channel that never resends, because a late sample is worthless, and bites and catches on an ordered one.
- 03
When there’s no direct path
Some networks won’t allow one, so the relay keeps carrying the messages itself. Both sides send a heartbeat every 10 s, answered without waking the room, a socket silent for 25 s is treated as gone, and a phone from another site is refused at the door.
- 04
Where a phone is pointing
The aim comes from the phone’s whole orientation, so it works held like a remote or upright and roll drops out; a lift is the aim rising fast. Safari and Chrome name their rotation axes differently, so each is read its own way, and a one euro filter smooths the jitter without adding lag.
Pick up the rod and fish with your phone, or read how the lake is built.
Colophon
How it’s made
The lake is not a picture or a video. It is painted in code and simulated in WebGL2, right in your browser, and most of the work went into keeping it smooth.
- Real water. A damped wave equation runs on the GPU on half-float textures, so a tap spreads real ripples that reflect off the shore and fade.
- Light on the lake floor. Caustics are worked out from how much each refracted triangle of the surface grows or shrinks, for whatever part you are looking at, so they stay sharp as you zoom.
- A painted world. Every tree, reed and lily pad is drawn by a script, and the creatures (fish, ducks, a heron) push their wakes back into the water simulation.
- Sounds made on the spot. Every splash, croak and quack is synthesised with Web Audio. There are no sound files.
Kept smooth
None of that is allowed to make the page wait, or to cost anything when you aren’t looking. Each of these was measured on a production build, with back-to-back runs.
- Painted off the main thread. The terrain is painted in a Web Worker on an OffscreenCanvas and handed over as bitmaps. Painted on the main thread, it froze the page for half a second on a laptop and for seconds on a phone.
- Never blank. A blurred preview smaller than a kilobyte, inlined in the HTML, holds the lake’s place, and the live lake fades in over it in one move. Nothing below shifts when it arrives.
- Asleep when unseen. The loop stops when the lake is off screen or the tab is hidden, and a phone draws every other frame while nothing moves, which halves its cost.
- Pixel budgets. The full lake caps its pixel density at 1.5x and its width at 1,700 px, and the shaders compile while the painting happens, not after it.
- Made once a visit. The painting and the GPU context are kept for the visit, so coming back here or to the home page is one short frame: returning from this page went from frames of 135 to 380 ms to 50 to 85 ms.
- The page first. The home page now paints in 460 ms, from 1,656, with the lake on it.
The whole build, with the shaders, the worker and the code: how the lake is built.