back to everything

PolyFish

A whole underwater world running in your browser. The fish are low-poly. The dying is high-fidelity.

2026 Three.jsWebXRVerlet physicsGLSL shadersClaude Code

Okay. Here’s the thing nobody tells you about building a little fish tank on a computer: the fish will die. Not because you wrote a bug. Because you wrote it correctly. That tagline on the live site, “The PolyFish have died,” isn’t me being dramatic. It’s the honest result of a system doing exactly what I built it to do. We’ll get there. Let me back up about twelve years first.

A beer experiment that learned to swim

PolyFish started in 2014 as a Unity VR experiment, and it wasn’t about fish. It was about beer. The original was a population-curve visualization for beer fermentation: little organisms eat sugar, gain energy, reproduce, then die off when the sugar runs out. That’s the classic boom-and-bust curve of any closed ecosystem, and watching it happen in a headset was, I’ll admit, kind of mesmerizing. I demoed it at an Austin VR meetup in 2014, in the scrappy early days right after positional tracking showed up. Then it sat in a drawer for over a decade.

What pulled it back out was diving. The swarming, milling behavior of those organisms reminded me of scuba trips, so I reworked the whole thing into an ocean. The reason I finally did the remaster in 2026 is simple and a little sappy: my dad, Phil, narrated the original (he’s a voice-over pro). I wanted to convert the old Unity project to Three.js so he could open it in a browser and experience it without installing anything. Getting to collaborate with him on this was special.

The lifecycle never changed, by the way. Organisms find food, eat, gain energy, reproduce, food runs out, die-off. That exact loop from the beer days still drives PolyFish today.

Yes, the tagline is a spoiler. No, I'm not changing it. The fish are supposed to die.

Porting twelve-year-old C# into a browser

The old build was hand-coded Unity: C# systems, HLSL shaders, the component setup, a FixedUpdate loop, rigidbody physics. It topped out around 100 creatures before it chugged, and it only ran on desktop or VR with an install. The remaster is a JavaScript translation of all that, with a scene graph and requestAnimationFrame standing in for the component system and FixedUpdate, and custom GLSL replacing the HLSL.

Most of it wasn’t just syntax swapping. Unity’s FixedUpdate fired impulse forces at 50 Hz, and there’s no clean browser equivalent, so I re-modeled creature movement as continuous thrust with smoothed acceleration right in the render loop. A complete rethink of the force model. The old tuning numbers (fish speed 0.8, a 0.43-second engine burn, a metabolic clock of 2) all had to be re-walked so populations didn’t overshoot and explode the second I hit play.

I built this in spare time in under two weeks. That’s not a flex about me being fast, it’s a flex about the bench. AI was a tool on that bench: Codex inside Cursor at first, then Claude Code partway through. I held the creative vision, the game design, the art direction, and the AI did a lot of the implementation grind. Roughly 20,000 lines of JavaScript across 63 files came out the other end. I’ll be straight about the friction, because pretending it’s magic helps nobody: context-window limits were the single biggest headache, the code was sometimes confidently wrong (a divide-by-zero when no fish were alive, a NaN crawling through the physics), and the worst bugs were the “almost right” ones, the 90-percent-correct stuff that’s harder to fix than writing it yourself. The fish AI alone went through dozens of revisions before it moved the way I remembered. The trick that actually worked was describing the feeling, not the code. “The dolphins feel too robotic” got me further than any algorithm spec. For someone living with chronic pain and fatigue, getting to work at the level of direction instead of the level of grind turned out to matter more than I expected.

Three creatures, no kings

There are three species, tuned so none of them runs the table. Fish are skittish prey. Dolphins are smart predators that have to manage their air. Manatees are slow browsers. Movement uses that continuous-force model: each creature accelerates toward a target velocity instead of snapping, lerping at about 60ms per ten percent of top speed. Deceleration is deliberately slower than acceleration, so they coast. Physically wrong, feels great, gives everything weight. Dolphins accelerate the fastest, manatees lumber, and every creature gets a randomized phase offset on its engine burn so nothing ever syncs up.

The dolphin oxygen system is my favorite touch. They’re mammals, so they can hunt as long as they want but have to surface every 60 seconds or die. The tank drains slowly underwater and refills fast at the surface, with an urgent warning at 30 percent and a critical one at 10 percent. So you get this little tragedy on a loop: a dolphin corners a fish, its air alarm goes off, it bails for the surface, and by the time it’s back the fish have scattered. Dolphins feel mortal. Prey feel hopeful.

That's not a missed shot. That's a mammal remembering it needs to breathe.

Why the fish actually die

Here’s the payoff. The whole ecosystem is collision-driven, not scripted. Plants make food particles, fish eat the food, dolphins hunt the fish, manatees graze the plants, and dead creatures sink and become waste that seeds new plants. Spatial hashing keeps all those collision checks cheap across a hundred-plus creatures. Reproduction is just a meal counter hitting a threshold, capped per spawn, with a short cooldown so it can’t spam.

The important part: energy gain from food is zero. Eating doesn’t extend your life. Energy just drains at a constant rate, so a fish with 60 starting energy that drains at 60 a minute lives exactly 60 seconds. There are no hard population caps and no spawners. Everything alive came from reproduction. So the population genuinely runs away and crashes on its own. Too many fish strip the food, reproduction stalls, dolphins catch up, fish crash, dolphins starve, fish recover. It’s messier and less stable than a capped system, and that’s the point. It’s far more interesting. The PolyFish have died because the math says they must, eventually, every time.

No ceiling, no floor, no mercy. Just hunger doing the bookkeeping.

Kelp, and the physics rabbit hole

The kelp started life on a full rigid-body setup: Jolt ragdoll chains in a web worker. Sixty plants times five joint bodies is 300 physics bodies just for the plants, which was murder on mobile. So I tore that out and rebuilt kelp on plain-JavaScript Verlet integration (position-based dynamics), and it came out about ten times cheaper on phones. Verlet is a beautiful cheat: you only store each node’s current and previous position, and velocity is just the difference between the two. Each frame does inertia with damping, a little buoyancy that grows toward the tip, external forces like the current and creature bumps, then a constraint pass that snaps segments back to their rest length. It’s unconditionally stable: no spring explosions, no energy blowup. The original mesh had only 5 bones and you could see the kinks, so a procedural rig bumps it to 13 evenly spaced bones, and creatures swimming past inject impulses that ripple down the stalk.

Light has a language underwater

Rendering layers custom effects onto Three.js standard materials by injecting shader code at compile time, so I’m not rebuilding the renderer, just bolting effects onto the existing lighting. The caustics use Voronoi domain warping, and the breakthrough was painting them onto the creatures themselves, not just the seafloor, so the animals feel embedded in the lit water instead of passing through it. The god rays took two wrong turns first: one approach gave me thin, web-like patterns that looked like caustic intersections, fixed by inverting the Voronoi so cell centers glow into thick columns; another routed the scene through intermediate buffers and dimmed everything like sunglasses, fixed by a depth-only pre-pass and a purely additive overlay so the scene colors never get touched.

Instancing is where the performance lives. The 85 seeded creatures (60 fish, 15 dolphins, 10 manatees) collapse from 85 potential draw calls into 3, and the whole frame holds around 5 with zero per-frame memory allocation. That’s the rule: never do work the player can’t see, and never hand the garbage collector anything to clean up. That’s how it holds 60fps with 200-plus active entities.

Took two embarrassing wrong turns to get light this good. Worth it.

The camera directs itself

There’s no player camera. PolyFish runs an autonomous documentary director that watches the emergent behavior and tries to shoot it like a nature film. It’s three layers: a director running a five-phase narrative state machine, a cinematographer handling framing and smoothing, and a scout watching for dramatic moments like feedings and chases and scoring them. Eleven shot types, from high establishing orbits down to macro close-ups with shallow depth of field. The whole thing is about 2,500 lines of state-machine code, and it came together in a single session. I’ll be honest that it isn’t finished: the individual shots work, but the overall pacing still needs work. You can’t pre-plan a cut when your actors are unscripted.

Dad’s voice, and the quiet stuff

Audio runs through the Web Audio API on three channels: music low, narration up front, effects in the middle, all feeding a master. Three ambient tracks shuffle so you never hear the same one twice in a row, each fading in over two seconds so there’s no click. Four narration cues are timed against the simulation clock, and when Dad’s voice comes in the music ducks down to a fraction of its volume, then comes back. Creatures have spatial audio with proper HRTF panning that falls off with distance, and the listener follows the camera every frame. Phil narrating again, in a browser, no install required, is the entire reason this remaster exists.

That's my dad's voice. The whole twelve-year detour was just to get it back into a browser.

So that’s PolyFish: a beer-fermentation toy from 2014 that grew gills, an ecosystem honest enough to collapse on schedule, and a pile of physics and shader experiments held together with about two weeks of stubbornness. Go watch the fish boom and bust. They always do.

Open PolyFish ↗


back to everything