Stinky Water Simulator — A Virtual Chaos Lab

Inspiration

We've all had that childhood urge: "What happens if I mix everything in the fridge into one cup?" , but parents (rightfully) said no. Stinky Water Simulator is the safe, infinite, consequence-free version of that impulse. Toss lava, bleach, expired milk, a goldfish, or literally anything you can type into a virtual bottle and watch an AI dream up the reaction in real time. No cleanup. No poison. No fire department.

What it does

You drop ingredients into a 3D bottle and the simulator models:

  • Liquid color, viscosity, and stink level that shift with every addition
  • Gas bubbles, steam plumes, and solid particles rendered in WebGL
  • Living organisms (germs, mold, mystery creatures) that grow, reproduce, or die
  • Pressure buildup, push it to 100 and the bottle explodes, awarding you a personalized Certificate of Stupidity
  • Realistic-ish chemistry: lava sterilizes germs, bleach kills mold, acid melts solids, heat boils everything

How we built it

  • Frontend: React + Vite + TypeScript + Tailwind, with a custom design system in HSL semantic tokens
  • 3D scene: react-three-fiber for the bottle, liquid shader, bubbles, and organism sprites — with drag-to-rotate and scroll-to-zoom
  • State engine: a reducer-based simulation in src/state/simulation.tsx that ticks every frame, applying decay, sterilization, reproduction, and pressure rules
  • AI reactions: a Lovable Cloud edge function calls Gemini via the Lovable AI Gateway to analyze each material and return structured JSON (color delta, temperature delta, killsOrganisms flag, stink level, etc.)
  • Fallback: when the AI is unreachable, a deterministic local heuristic in fallbackMaterial.ts keeps the lab running

What we learned

  • AI as a physics engine is surprisingly fun. Instead of hardcoding 10,000 reactions, we let an LLM imagine them and constrained the output with a strict JSON schema so the simulation stays stable.
  • Reducers scale better than we expected. All the chaos (sterilization, decay, reproduction, pressure) lives in pure functions, which made debugging the germ-population bug trivial once we found it.
  • Realism is a spectrum. We had to keep tightening the rules, e.g. germs in organic waste should die when lava is added without making the lab feel like a chemistry textbook.

Challenges we faced

  • Germs that wouldn't die. Sterilization logic was gated on an isAlive flag, so germs hiding inside non-living matter (poop, food scraps) survived lava baths. Fixed by making killsOrganisms apply universally and adding hostile-environment decay:

$$ P_{t+1} = \begin{cases} 0 & \text{if } T > 200°C \text{ or pH} \notin [2,12] \ 0.92 \cdot P_t & \text{if } T > 60°C \text{ or pH} \notin [4,10] \ P_t \cdot (1 + r) & \text{otherwise} \end{cases} $$

  • The Certificate only popped once. The explosion state never reset, so a second run to pressure 100 did nothing. We added a DISMISS_EXPLOSION action that vents pressure back to 80 and re-arms the modal.

  • Making the bottle feel alive without melting low-end GPUs, solved by capping particle counts and reusing geometry instances.

// The heart of the chaos: one reducer, infinite reactions
case "ADD": {
  const next = blendMaterial(state, action.material);
  if (action.material.killsOrganisms) {
    next.organisms = next.organisms.map(o => ({ ...o, population: 0, trend: "dead" }));
  }
  return recomputePressure(next);
}

What's next

  • Multiplayer bottles (race to explode)
  • Shareable concoction recipes with permalinks
  • A "Hall of Fame" for the most cursed combinations

Built With

Share this project:

Updates