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.tsxthat 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,
killsOrganismsflag, stink level, etc.) - Fallback: when the AI is unreachable, a deterministic local heuristic in
fallbackMaterial.tskeeps 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
isAliveflag, so germs hiding inside non-living matter (poop, food scraps) survived lava baths. Fixed by makingkillsOrganismsapply 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_EXPLOSIONaction 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
- lovable
- react
- tailwind
- three.js
- typescript
- vite
Log in or sign up for Devpost to join the conversation.