fuzzyworld

bubbles / commit

added some more levels and lots of ui changes

775e32f99a836867f007dbf98c77d34d781f2d34 · Pavel Safronov · 2026-04-09 16:39:42 -0700 · parents c48754da165c

added some more levels and lots of ui changes
 .github/copilot-instructions.md |   2 +
 PLAN.md                         | 216 ++++++++++++++++++++++++++++++
 index.html                      |  34 ++++-
 src/App.ts                      | 177 +++++++++++++++++--------
 src/input/TiltInput.ts          |  24 ++--
 src/levels/ILevel.ts            |  36 +++++
 src/levels/LavaLampLevel.ts     | 286 ++++++++++++++++++++++++++++++++++++++++
 src/levels/SandWaterLevel.ts    | 217 ++++++++++++++++++++++++++++++
 src/levels/ShampooLevel.ts      | 228 ++++++++++++++++++++++++++++++++
 src/levels/WaterLevel.ts        | 162 +++++++++++++++++++++++
 src/main.ts                     |  96 +++++++++++++-
 src/physics/Water.ts            |  86 +++++++-----
 src/physics/World.ts            |  97 +++++++-------
 src/rendering/GlassRenderer.ts  |  33 ++---
 src/rendering/WaterRenderer.ts  | 122 ++++++++++-------
 15 files changed, 1601 insertions(+), 215 deletions(-)

diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index e260674..6b1dbb4 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -34,6 +34,8 @@ scripts/        — Node.js tooling (sharp asset pipeline)
 - Prefer approaches that stay ≥ 30fps on a mid-range phone; profile before committing to a heavier model.
 - max 12 `RigidBody` objects by default; `World.addBody()` enforces this by dropping oldest non-permanent.
 - Fixed physics timestep: `1/60` s (set in `App.ts`).
+- Container is **sealed** — no spilling, volume is always conserved.
+- Gravity is a full 360° `Vector2` (device beta + gamma via `TiltInput`). Buoyancy, constraints, and `Water.getSurfaceY()` all work correctly when the phone is upside-down (`water.isFlipped`).
 
 ## Rendering Conventions
 - `Renderer` always draws in this order: clear → glass clip → water → icons → glass outline.
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..fd1a403
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,216 @@
+# Bubbles — Multi-Level Plan
+
+## Overview
+
+Four changes are in scope:
+
+1. **Sealed container + full 360° gravity** — foundational, affects all levels
+2. **Level: Water glass** — current level, updated for sealed physics
+3. **Level: Lava lamp** — metaball blobs, two densities, thermal cycling
+4. **Level: Sand · Water · Air** — three-phase layered simulation
+5. **Level: Shampoo bottle** — high-viscosity fluid with merging air bubbles
+
+---
+
+## 1. Sealed Container + Full 360° Gravity
+
+### What changes
+
+**`TiltInput`** — currently only uses `gamma` (left/right tilt) and clamps to ±88°.  
+Full rotation means also using `beta` (front/back) to detect upside-down orientation.  
+Output stays `gravity: Vector2` but now truly reflects 3D device orientation projected onto the screen plane:
+
+```
+gx = sin(gamma) * cos(beta) * 980
+gy = sin(beta)  * 980          (negative = phone tilted face-down = gravity pulls "up")
+```
+
+When the phone is fully vertical face-down, `gy ≈ −980` and liquid should rest at the top of the container.
+
+**`Water` (SWE)** — remove rim spill (no permanent volume loss). Add a top-wall clamp symmetrically to the floor clamp. The lateral body-force term already handles any sign of `lateralG`. Negative `verticalG` is the upside-down case — buoyancy reversal needs to happen in `World`, not `Water`.
+
+**`World`** — buoyancy force direction is currently hardcoded to oppose `g.y`. Must change to oppose the full `g` vector so objects float in the direction opposite to gravity. When upside-down, dense objects float and light objects sink.
+
+**`GlassRenderer`** — container is sealed: render top wall the same as side walls (no open top).
+
+**No other API surface changes** — `gravity: Vector2` stays the same shape.
+
+### Risk: low. The SWE floor/ceiling clamping is already symmetric; this is mostly removing the spill path and fixing TiltInput.
+
+---
+
+## 2. Level Architecture
+
+### New structure
+
+```
+src/levels/
+  ILevel.ts          — interface
+  WaterLevel.ts      — current water glass (sealed)
+  LavaLampLevel.ts
+  SandWaterLevel.ts
+  ShampooLevel.ts
+```
+
+### `ILevel` interface
+
+```ts
+interface ILevel {
+  /** Called once per physics tick. gravity is always the device gravity vector. */
+  step(dt: number, gravity: Vector2): void;
+  /** Called once per rAF frame. */
+  draw(ctx: CanvasRenderingContext2D, containerRect: DOMRect): void;
+  /** Called on touch/pointer event inside container. */
+  onTouch(localX: number, localY: number): void;
+  /** Called when container size changes. */
+  resize(w: number, h: number): void;
+  /** Reset to initial state. */
+  reset(): void;
+  readonly name: string;
+}
+```
+
+### `App.ts` changes
+
+- Holds an `ILevel` (starts with `WaterLevel`)
+- `nextLevel()` / `prevLevel()` cycle through the four levels
+- Swipe gesture (horizontal swipe on the glass) triggers level change
+- Level name shown briefly as a fade-in/out toast overlay (no persistent UI)
+
+### Level picker UI
+
+Single swipe left/right on the glass switches levels. A small dot-row indicator (4 dots, current dot filled) appears at the bottom of the glass for 2 seconds after a level change, then fades out. No separate level-select screen needed.
+
+---
+
+## 3. Level: Water Glass (updated)
+
+No new simulation. Changes from current:
+- Sealed (top wall, no spill)
+- Full 360° gravity
+- Touch spawns emoji objects (same as today)
+- QR body stays permanent
+
+---
+
+## 4. Level: Lava Lamp
+
+### Concept
+
+~10 blobs split between two "wax" types:
+- **Hot wax** (density 0.92, warm orange-red): rises when near the bottom heat source
+- **Cool wax** (density 1.08, deep red-purple): sinks when near the top
+
+Tilting moves blobs laterally. No interaction with `Water` — this level has no water.
+
+### Simulation approach: soft-body blobs
+
+Each blob is a `Vector2` position + velocity (not a `RigidBody`). Integration:
+
+1. **Thermal density**: each blob tracks a "temperature" scalar that drifts toward a target based on distance from the simulated heat source (always opposite gravity direction = "floor"). Hot → lower density → upward buoyancy. Cool → higher density → downward.
+2. **Buoyancy**: `F_buoy = (fluidDensity − blobDensity) × volume × g`
+3. **Blob–blob repulsion**: when blobs of *any* type overlap, push apart (incompressibility)
+4. **Same-type soft attraction**: gentle pull between blobs of same type within 2× radius (creates the "merging" feel without actual merge/split logic)
+5. **Wall collision**: elastic bounce off all four walls
+6. **Damping**: high fluid drag (lava is viscous)
+
+### Rendering
+
+Metaballs via Canvas 2D:
+- Draw each blob as an offscreen radial gradient: opaque center → transparent edge at ~1.5× radius
+- Use `ctx.filter = 'blur(Xpx)'` on an offscreen canvas, then `ctx.filter = 'contrast(Y)'` on the main canvas when compositing. This is the classic CSS metaball trick, runs fine on mobile.
+- Color: lerp between hot color and cool color based on blob temperature
+
+### Touch
+
+Tap creates a gentle gravity-perpendicular impulse in the blob nearest the touch point.
+
+### Risk: medium. Metaball filter trick needs profiling on mobile — blur radius must stay small (≤8px). No TypeScript complexity.
+
+---
+
+## 5. Level: Sand · Water · Air
+
+### Concept
+
+Three materials layered by density, reacting to tilt:
+- **Sand** (~40 particles, radius 4px, density 2.5) — settles at "bottom"
+- **Water** (SWE 1D, same as WaterLevel) — middle layer  
+- **Air bubble** (1–3 bodies, radius 20–35px, density 0.001) — floats at "top"
+
+### Simulation approach
+
+Reuse `Water` (SWE) + `World` (RigidBody). Sand and air are both `RigidBody` instances with extreme densities. The buoyancy code in `World` already handles them correctly:
+- Sand (density 2.5): sinks, rests on floor
+- Air bubble (density 0.001): strong upward buoyancy, presses against ceiling (sealed top wall)
+
+**Sand–sand collision**: existing `resolveBodyCollision` handles it. 40 particles = 780 pair checks per frame — acceptable.
+
+**Sand resting**: add static friction when a sand particle's speed < threshold and it's resting on the floor or another particle.
+
+**Air bubble rendering**: white/translucent circle with a bright specular highlight arc (not emoji — just canvas arcs).
+
+**Water rendering**: same SWE renderer, but water only fills the middle volume. The initial fill fraction accounts for the volume displaced by sand at the bottom.
+
+### Touch
+
+Tap disturbs water surface (same `disturb()` call) and scatters nearby sand particles via an impulse.
+
+### Risk: medium-high. 40 RigidBodies × pair collision = O(N²) = 780 pairs — should be fine. The tricky part is sand settling stably without jitter; static friction threshold needs tuning.
+
+---
+
+## 6. Level: Shampoo Bottle
+
+### Concept
+
+A sealed container full of thick, slow-moving fluid with 3–5 trapped air bubbles that:
+- Rise toward the "top" (opposite gravity)
+- Merge when they touch (become one larger bubble, conservation of volume)
+- Split when shaken hard (large velocity impulse → one bubble splits into two)
+
+### Simulation approach
+
+**Fluid**: reuse `Water` SWE but with:
+- High `K_DAMP` (≈ 5.0 instead of 1.5) → very slow wave propagation
+- Lower wave speed feel: reduce effective `G_SW` by 60% (shampoo is more viscous, slower pressure propagation)
+- Color: amber/pearlescent gradient
+
+**Bubbles**: `RigidBody` with density 0.001, but rendered as soft spheres (not emoji). Bubble–bubble interaction uses the same `resolveBodyCollision` but with high restitution.
+
+**Merge logic** (in `World` or a `ShampooLevel` post-step hook):
+- After collision resolution, if two bubbles overlap by > 60% of the smaller radius → merge
+- Remove both, spawn one bubble with `r_new = ∛(r₁³ + r₂³)` (volume conservation)
+
+**Split logic**:
+- If a bubble's speed exceeds a threshold (e.g., 400 px/s) → split into two with radii scaled by `∛(0.5)`
+
+**Rendering**: each bubble is a white circle with `globalAlpha ≈ 0.6`, a bright arc highlight at 10–2 o'clock, and a light shadow at 6 o'clock.
+
+### Touch
+
+Tap near a bubble gives it a strong impulse (may trigger split). Tap on fluid disturbs the SWE surface.
+
+### Risk: low-medium. Merge/split logic is the only novel code. Volume conservation via cube-root radius is straightforward.
+
+---
+
+## Implementation Order
+
+1. ~~`TiltInput` — full 360° gravity (prerequisite for everything)~~ ✅
+2. ~~Sealed container in `Water`, `World`, `GlassRenderer`~~ ✅
+3. ~~`ILevel` interface + level-switching in `App`~~ ✅
+4. ~~`WaterLevel` — wrap current logic~~ ✅
+5. ~~`LavaLampLevel` — self-contained, no SWE dependency~~ ✅
+6. ~~`ShampooLevel` — extends SWE + merge/split~~ ✅
+7. ~~`SandWaterLevel` — most complex, do last~~ ✅
+
+---
+
+## What stays the same
+
+- `Vector2`, `RigidBody`, `TiltInput` structure (only internals change)
+- `vite.config.ts`, `package.json`, CI — no changes
+- `main.ts` — minor: wire swipe gesture + level dots indicator
+- `WaterRenderer`, `GlassRenderer` — reusable by `WaterLevel` and `ShampooLevel`
+- All rendering is still Canvas 2D, one class per file, no barrel files
diff --git a/index.html b/index.html
index b1294de..3ed6807 100644
--- a/index.html
+++ b/index.html
@@ -183,6 +183,37 @@
             height: 20px;
             fill: currentColor;
         }
+
+        /* ── Level dot indicator ──────────────────────────────────────── */
+        #level-dots {
+            position: absolute;
+            bottom: 68px;
+            left: 50%;
+            transform: translateX(-50%);
+            display: flex;
+            gap: 8px;
+            align-items: center;
+            opacity: 0;
+            transition: opacity 0.3s;
+            pointer-events: none;
+        }
+
+        #level-dots.visible {
+            opacity: 1;
+        }
+
+        #level-dots .dot {
+            width: 6px;
+            height: 6px;
+            border-radius: 50%;
+            background: rgba(226, 232, 240, 0.25);
+            transition: background 0.2s, transform 0.2s;
+        }
+
+        #level-dots .dot.active {
+            background: rgba(56, 189, 248, 0.9);
+            transform: scale(1.4);
+        }
     </style>
 </head>
 
@@ -213,7 +244,8 @@
                     d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
             </svg>
         </button>
-        <p id="hint">tap to drop · tilt to slosh</p>
+        <div id="level-dots"></div>
+        <p id="hint">tap to drop · tilt to slosh · swipe to switch</p>
         <footer>
             <a href="https://gitlab.com/PavelSafronov/bubbles" target="_blank" rel="noopener">gitlab</a>
             &nbsp;·&nbsp;
diff --git a/src/App.ts b/src/App.ts
index 4f1a293..493f72a 100644
--- a/src/App.ts
+++ b/src/App.ts
@@ -1,59 +1,97 @@
-import { World } from './physics/World.js';
-import { Water } from './physics/Water.js';
-import { RigidBody } from './physics/RigidBody.js';
+import { ILevel } from './levels/ILevel.js';
+import { WaterLevel } from './levels/WaterLevel.js';
 import { Vector2 } from './physics/Vector2.js';
-import { Renderer } from './rendering/Renderer.js';
 import { TiltInput } from './input/TiltInput.js';
-import { TouchInput } from './input/TouchInput.js';
 
 const FIXED_STEP = 1 / 60; // 60 Hz physics
 
 export class App {
-    private readonly world: World;
-    private readonly renderer: Renderer;
     private readonly tilt: TiltInput;
-    private readonly touch: TouchInput;
-    private qrBody: import('./physics/RigidBody.js').RigidBody | undefined;
+    private readonly levels: ILevel[];
+    private _levelIndex = 0;
 
     private rafId = 0;
     private lastTime = 0;
     private accumulator = 0;
     private permissionRequested = false;
 
+    readonly canvas: HTMLCanvasElement;
+    private ctx: CanvasRenderingContext2D;
+
+    // Geometry — kept in sync with resize events
+    private glassX = 0;
+    private glassY = 0;
+    private glassW = 0;
+    private glassH = 0;
+
+    /** Callback fired when the level name changes (for UI dot updates). */
+    onLevelChange: ((index: number, total: number, name: string) => void) | undefined;
+
     constructor(canvas: HTMLCanvasElement) {
-        // Glass dimensions are set properly by Renderer.resize() via the initial
-        // call in its constructor, but we need placeholder values for World first.
-        const W = window.innerWidth;
-        const H = window.innerHeight;
-        const glassW = Math.min(W * 0.82, 420);
-        const glassH = H * 0.72;
-        const glassX = (W - glassW) / 2;
-        const glassY = (H - glassH) / 2 - H * 0.02;
+        this.canvas = canvas;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) throw new Error('Could not get 2D context');
+        this.ctx = ctx;
 
-        const water = new Water(80, glassW, glassH, 0.55);
-        this.world = new World(water, glassX, glassY);
+        this.computeGlassDims();
 
-        // Renderer sets up canvas sizing and overwrites world.glassX/Y on first resize
-        this.renderer = new Renderer(canvas, this.world);
+        // WaterLevel is the only level constructed immediately — others are lazy
+        // so they don't allocate until first visited.
+        const wl = new WaterLevel(
+            this.ctx, this.glassX, this.glassY, this.glassW, this.glassH,
+        );
+        this.levels = [wl];
 
         this.tilt = new TiltInput(canvas);
-        this.touch = new TouchInput(canvas, this.world, () => this.requestPermissionOnce());
+
+        window.addEventListener('resize', this.handleResize);
+        this.handleResize();
     }
 
-    /** Call after the QR canvas has been generated to spawn the QR body. */
+    // ── Public API ───────────────────────────────────────────────────────────
+
+    get currentLevel(): ILevel { return this.levels[this._levelIndex]!; }
+    get levelCount(): number { return this.levels.length; }
+    get levelIndex(): number { return this._levelIndex; }
+
+    /** Register all levels (called once after construction with lazy factories). */
+    setLevels(levels: ILevel[]): void {
+        (this.levels as ILevel[]).length = 0;
+        for (const l of levels) (this.levels as ILevel[]).push(l);
+        this._levelIndex = Math.min(this._levelIndex, this.levels.length - 1);
+    }
+
+    nextLevel(): void {
+        this._levelIndex = (this._levelIndex + 1) % this.levels.length;
+        this.onLevelChange?.(this._levelIndex, this.levels.length, this.currentLevel.name);
+    }
+
+    prevLevel(): void {
+        this._levelIndex = (this._levelIndex - 1 + this.levels.length) % this.levels.length;
+        this.onLevelChange?.(this._levelIndex, this.levels.length, this.currentLevel.name);
+    }
+
+    /** Called after the QR canvas has been generated. */
     spawnQRBody(qrTexture: HTMLCanvasElement): void {
-        const r = 40;
-        const x = this.world.glassX + this.world.glassW / 2;
-        const y = this.world.glassY + r + 4;
-        this.qrBody = new RigidBody({
-            position: new Vector2(x, y),
-            radius: r,
-            density: 0.5,
-            label: '',
-            texture: qrTexture,
-            permanent: true,
-        });
-        this.world.addBody(this.qrBody);
+        const wl = this.levels[0];
+        if (wl instanceof WaterLevel) wl.spawnQRBody(qrTexture);
+    }
+
+    /** Deliver a tap at canvas coordinates. Routed to the active level. */
+    touch(clientX: number, clientY: number): void {
+        const rect = this.canvas.getBoundingClientRect();
+        const canvasX = clientX - rect.left;
+        const canvasY = clientY - rect.top;
+        const localX = canvasX - this.glassX;
+        const localY = canvasY - this.glassY;
+        this.currentLevel.onTouch(localX, localY, this.tilt.gravity);
+    }
+
+    /** Request iOS device orientation permission (must be in user gesture). */
+    async requestPermissionOnce(): Promise<void> {
+        if (this.permissionRequested) return;
+        this.permissionRequested = true;
+        await this.tilt.requestPermission();
     }
 
     start(): void {
@@ -62,42 +100,75 @@ export class App {
     }
 
     reset(): void {
-        // Keep only the permanent QR body
-        this.world.bodies = this.world.bodies.filter(b => b.permanent);
-        this.world.water.resetFill(0.55);
+        this.currentLevel.reset();
     }
 
     stop(): void {
         cancelAnimationFrame(this.rafId);
-        this.renderer.destroy();
         this.tilt.destroy();
-        this.touch.destroy();
+        window.removeEventListener('resize', this.handleResize);
     }
 
+    // ── Private ──────────────────────────────────────────────────────────────
+
     private readonly loop = (now: number): void => {
-        const rawDt = Math.min((now - this.lastTime) / 1000, 0.1); // cap at 100ms
+        const rawDt = Math.min((now - this.lastTime) / 1000, 0.1);
         this.lastTime = now;
         this.accumulator += rawDt;
 
-        // Update world gravity from tilt
-        this.world.gravity = this.tilt.gravity;
+        const gravity = this.tilt.gravity;
 
-        // Fixed-step physics
         while (this.accumulator >= FIXED_STEP) {
-            this.world.step(FIXED_STEP);
+            this.currentLevel.step(FIXED_STEP, gravity);
             this.accumulator -= FIXED_STEP;
         }
 
-        // Render
-        this.renderer.update(rawDt);
-        this.renderer.draw();
-
+        this.renderFrame(rawDt);
         this.rafId = requestAnimationFrame(this.loop);
     };
 
-    private async requestPermissionOnce(): Promise<void> {
-        if (this.permissionRequested) return;
-        this.permissionRequested = true;
-        await this.tilt.requestPermission();
+    private renderFrame(_dt: number): void {
+        const dpr = window.devicePixelRatio || 1;
+        const W = this.canvas.width / dpr;
+        const H = this.canvas.height / dpr;
+
+        this.ctx.clearRect(0, 0, W, H);
+        this.ctx.fillStyle = '#0f172a';
+        this.ctx.fillRect(0, 0, W, H);
+
+        this.currentLevel.draw(
+            this.ctx, this.glassX, this.glassY, this.glassW, this.glassH,
+        );
+    }
+
+    private computeGlassDims(): void {
+        const W = window.innerWidth;
+        const H = window.innerHeight;
+        this.glassW = Math.min(W * 0.82, 420);
+        this.glassH = H * 0.72;
+        this.glassX = (W - this.glassW) / 2;
+        this.glassY = (H - this.glassH) / 2 - H * 0.02;
     }
+
+    private readonly handleResize = (): void => {
+        const dpr = window.devicePixelRatio || 1;
+        const W = window.innerWidth;
+        const H = window.innerHeight;
+
+        this.canvas.width = W * dpr;
+        this.canvas.height = H * dpr;
+        this.canvas.style.width = `${W}px`;
+        this.canvas.style.height = `${H}px`;
+        this.ctx.scale(dpr, dpr);
+
+        this.computeGlassDims();
+
+        for (const level of this.levels) {
+            if (level instanceof WaterLevel) {
+                level.setGlassOrigin(this.glassX, this.glassY);
+            }
+            level.resize(this.glassW, this.glassH);
+        }
+    };
 }
+
diff --git a/src/input/TiltInput.ts b/src/input/TiltInput.ts
index fcd509a..4200700 100644
--- a/src/input/TiltInput.ts
+++ b/src/input/TiltInput.ts
@@ -58,19 +58,23 @@ export class TiltInput {
     }
 
     private readonly handleOrientation = (e: DeviceOrientationEvent): void => {
-        if (e.gamma === null) return;
-        // gamma: left/right tilt (-90…+90) when phone is held upright in portrait.
-        // We derive BOTH gravity components from gamma so that:
-        //   gamma=0  → gravity=(0, 980)  — phone upright, water flat
-        //   gamma=30 → gravity=(490, 849) — tilted right, water shifts right
-        // Using beta for gravity.y was wrong: when upright beta≈90°, cos(90°)≈0
-        // which zeroed out vertical gravity entirely.
-        const gammaDeg = Math.min(88, Math.max(-88, e.gamma));
-        const gammaRad = (gammaDeg * Math.PI) / 180;
+        if (e.gamma === null || e.beta === null) return;
+        // Full 360° gravity using both gamma (left/right tilt, −90…+90) and beta
+        // (front/back tilt, −180…+180) projected onto the screen plane:
+        //
+        //   gx = sin(gamma) * 980
+        //   gy = sin(beta) * cos(gamma) * 980
+        //
+        // Upright portrait  (beta≈90, gamma=0): gx=0,   gy=+980  (normal down)
+        // Upside-down       (beta≈−90, gamma=0): gx=0,   gy=−980  (gravity up)
+        // Tilted right 30°  (beta≈90, gamma=30): gx≈490, gy≈849
+        // Flat face-up      (beta=0,   gamma=0): gx=0,   gy=0
+        const gammaRad = (e.gamma * Math.PI) / 180;
+        const betaRad = (e.beta * Math.PI) / 180;
 
         this._gravity = new Vector2(
             Math.sin(gammaRad) * 980,
-            Math.cos(gammaRad) * 980,
+            Math.sin(betaRad) * Math.cos(gammaRad) * 980,
         );
     };
 
diff --git a/src/levels/ILevel.ts b/src/levels/ILevel.ts
new file mode 100644
index 0000000..deac028
--- /dev/null
+++ b/src/levels/ILevel.ts
@@ -0,0 +1,36 @@
+import { Vector2 } from '../physics/Vector2.js';
+
+/**
+ * Interface every level must implement.
+ *
+ * The App game loop calls step() and draw() every tick/frame.
+ * Levels own all simulation state; App just drives the clock.
+ */
+export interface ILevel {
+    readonly name: string;
+
+    /** Advance simulation by dt seconds. gravity is the device gravity vector. */
+    step(dt: number, gravity: Vector2): void;
+
+    /** Render everything into ctx for this frame. */
+    draw(
+        ctx: CanvasRenderingContext2D,
+        /** Left edge X of glass interior, canvas px */
+        glassX: number,
+        /** Top edge Y of glass interior, canvas px */
+        glassY: number,
+        /** Glass interior width, canvas px */
+        glassW: number,
+        /** Glass interior height, canvas px */
+        glassH: number,
+    ): void;
+
+    /** Called when user taps inside the container. localX/Y are glass-space coords. */
+    onTouch(localX: number, localY: number, gravity: Vector2): void;
+
+    /** Called whenever the canvas / container is resized. */
+    resize(glassW: number, glassH: number): void;
+
+    /** Reset simulation to initial state. */
+    reset(): void;
+}
diff --git a/src/levels/LavaLampLevel.ts b/src/levels/LavaLampLevel.ts
new file mode 100644
index 0000000..23e7fba
--- /dev/null
+++ b/src/levels/LavaLampLevel.ts
@@ -0,0 +1,286 @@
+import { ILevel } from './ILevel.js';
+import { Vector2 } from '../physics/Vector2.js';
+import { GlassRenderer } from '../rendering/GlassRenderer.js';
+
+/** A single lava-lamp blob. */
+interface Blob {
+    x: number;
+    y: number;
+    vx: number;
+    vy: number;
+    radius: number;
+    /** 0 = cold (sinks), 1 = hot (rises) */
+    temp: number;
+    /** 0 = cool wax, 1 = hot wax */
+    type: 0 | 1;
+}
+
+const BLOB_COUNT = 10;
+const FLUID_DENSITY = 1.0;
+// Hot wax (type 1) at max heat: density 0.90 → rises
+// Cool wax (type 0) at max cool: density 1.10 → sinks
+// Density interpolated by temp:
+//   type 0: lerp(1.10, 0.96, temp)
+//   type 1: lerp(1.04, 0.90, temp)
+const THERMAL_RATE = 0.6;   // how fast temp changes (1/s)
+const DRAG = 4.0;   // linear drag coefficient
+const REPULSION = 18000; // blob–blob separation force scale
+const ATTRACTION = 800;   // same-type soft-attraction force scale
+const WALL_BOUNCE = 0.35;
+
+/**
+ * Lava lamp level.
+ * Two types of "wax" blobs circulate due to simulated thermal density changes.
+ * Rendered as metaballs using the CSS blur+contrast Canvas 2D trick.
+ */
+export class LavaLampLevel implements ILevel {
+    readonly name = 'Lava Lamp';
+
+    private blobs: Blob[] = [];
+    private glassRend: GlassRenderer;
+    /** Offscreen canvas for metaball compositing. */
+    private offscreen: OffscreenCanvas | HTMLCanvasElement;
+    private offCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+
+    constructor(
+        private readonly ctx: CanvasRenderingContext2D,
+        private glassX: number,
+        private glassY: number,
+        private glassW: number,
+        private glassH: number,
+    ) {
+        this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH);
+        const { off, offCtx } = this.makeOffscreen(glassW, glassH);
+        this.offscreen = off;
+        this.offCtx = offCtx;
+        this.initBlobs();
+    }
+
+    // ── ILevel ───────────────────────────────────────────────────────────────
+
+    step(dt: number, gravity: Vector2): void {
+        const W = this.glassW;
+        const H = this.glassH;
+        const gMag = gravity.magnitude();
+        // Gravity direction (unit vector pointing "down")
+        const gx = gMag > 0 ? gravity.x / gMag : 0;
+        const gy = gMag > 0 ? gravity.y / gMag : 1;
+
+        for (let i = 0; i < this.blobs.length; i++) {
+            const b = this.blobs[i]!;
+
+            // 1. Thermal update: type-1 heats near the gravity wall, type-0 cools
+            //    "near the gravity wall" = dot(pos_from_center, gDir) > 0
+            const cx = b.x - W / 2;
+            const cy = b.y - H / 2;
+            const wallProx = (cx * gx + cy * gy) / (H / 2); // -1 (anti-grav wall) … +1 (grav wall)
+
+            let targetTemp: number;
+            if (b.type === 1) {
+                // Hot wax heats up near the gravity wall (bottom when upright)
+                targetTemp = (wallProx + 1) / 2;
+            } else {
+                // Cool wax cools down near the anti-gravity wall (top when upright)
+                targetTemp = 1 - (wallProx + 1) / 2;
+            }
+            b.temp += (targetTemp - b.temp) * Math.min(1, dt * THERMAL_RATE);
+
+            // 2. Density → buoyancy
+            const density = b.type === 1
+                ? 1.04 - 0.14 * b.temp   // 1.04 → 0.90
+                : 1.10 - 0.14 * b.temp;  // 1.10 → 0.96
+            const buoyancyAcc = (FLUID_DENSITY - density) * gMag;
+            // Buoyancy opposes gravity direction
+            const bax = -gx * buoyancyAcc;
+            const bay = -gy * buoyancyAcc;
+
+            // 3. Body force from gravity
+            const fax = gx * gMag;
+            const fay = gy * gMag;
+
+            // 4. Net acceleration
+            let ax = fax + bax - DRAG * b.vx;
+            let ay = fay + bay - DRAG * b.vy;
+
+            // 5. Blob–blob forces
+            for (let j = 0; j < this.blobs.length; j++) {
+                if (i === j) continue;
+                const o = this.blobs[j]!;
+                const dx = b.x - o.x;
+                const dy = b.y - o.y;
+                const d2 = dx * dx + dy * dy + 0.01;
+                const minD = b.radius + o.radius;
+                // Repulsion: always push apart when close
+                if (d2 < (minD * 2) * (minD * 2)) {
+                    const d = Math.sqrt(d2);
+                    const f = REPULSION / (d2);
+                    ax += (dx / d) * f;
+                    ay += (dy / d) * f;
+                }
+                // Soft attraction: same type, within range
+                if (b.type === o.type && d2 < (minD * 5) * (minD * 5)) {
+                    const d = Math.sqrt(d2);
+                    const f = ATTRACTION / (d2 + 1);
+                    ax -= (dx / d) * f;
+                    ay -= (dy / d) * f;
+                }
+            }
+
+            b.vx += ax * dt;
+            b.vy += ay * dt;
+
+            b.x += b.vx * dt;
+            b.y += b.vy * dt;
+
+            // 6. Wall bounce (all four walls — sealed)
+            if (b.x - b.radius < 0) {
+                b.x = b.radius;
+                b.vx = Math.abs(b.vx) * WALL_BOUNCE;
+            }
+            if (b.x + b.radius > W) {
+                b.x = W - b.radius;
+                b.vx = -Math.abs(b.vx) * WALL_BOUNCE;
+            }
+            if (b.y - b.radius < 0) {
+                b.y = b.radius;
+                b.vy = Math.abs(b.vy) * WALL_BOUNCE;
+            }
+            if (b.y + b.radius > H) {
+                b.y = H - b.radius;
+                b.vy = -Math.abs(b.vy) * WALL_BOUNCE;
+            }
+        }
+    }
+
+    draw(
+        ctx: CanvasRenderingContext2D,
+        glassX: number,
+        glassY: number,
+        glassW: number,
+        glassH: number,
+    ): void {
+        // Render metaballs into offscreen canvas, then composite with blur+contrast
+        const off = this.offCtx;
+        const W = glassW;
+        const H = glassH;
+
+        off.clearRect(0, 0, W, H);
+
+        // Dark background on offscreen (contrast trick needs opaque BG)
+        off.fillStyle = '#1a0a1a';
+        off.fillRect(0, 0, W, H);
+
+        // Draw each blob as a radial gradient
+        for (const b of this.blobs) {
+            const hot = b.type === 1 ? b.temp : (1 - b.temp);
+            const r = b.radius * 1.8; // gradient extends beyond physics radius
+            const grad = off.createRadialGradient(b.x, b.y, 0, b.x, b.y, r);
+
+            // Color: interpolate between cool (deep red-purple) and hot (orange-red)
+            const cCool = [120, 20, 60];
+            const cHot = [255, 80, 20];
+            const cr = Math.round(cCool[0]! + (cHot[0]! - cCool[0]!) * hot);
+            const cg = Math.round(cCool[1]! + (cHot[1]! - cCool[1]!) * hot);
+            const cb = Math.round(cCool[2]! + (cHot[2]! - cCool[2]!) * hot);
+
+            grad.addColorStop(0, `rgba(${cr},${cg},${cb},1)`);
+            grad.addColorStop(0.5, `rgba(${cr},${cg},${cb},0.7)`);
+            grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
+            off.fillStyle = grad;
+            off.beginPath();
+            off.arc(b.x, b.y, r, 0, Math.PI * 2);
+            off.fill();
+        }
+
+        // Clip to glass, composite with blur + contrast for metaball merge effect
+        this.glassRend.beginClip();
+
+        ctx.save();
+        // blur: blends blob edges; contrast: sharpens merged boundary
+        ctx.filter = 'blur(12px) contrast(18)';
+        ctx.drawImage(
+            this.offscreen as CanvasImageSource,
+            glassX, glassY, W, H,
+            glassX, glassY, W, H,
+        );
+        ctx.filter = 'none';
+        ctx.restore();
+
+        // Warm amber glow fill for background fluid (lamp oil)
+        ctx.fillStyle = 'rgba(40, 15, 5, 0.65)';
+        ctx.fillRect(glassX, glassY, W, H);
+
+        this.glassRend.endClip();
+        this.glassRend.draw();
+    }
+
+    onTouch(localX: number, localY: number, _gravity: Vector2): void {
+        // Find nearest blob and give it an impulse outward from the tap
+        let nearest: Blob | undefined;
+        let nearDist = Infinity;
+        for (const b of this.blobs) {
+            const d = Math.hypot(b.x - localX, b.y - localY);
+            if (d < nearDist) { nearDist = d; nearest = b; }
+        }
+        if (nearest) {
+            const dx = nearest.x - localX;
+            const dy = nearest.y - localY;
+            const d = Math.hypot(dx, dy) + 1;
+            nearest.vx += (dx / d) * 200;
+            nearest.vy += (dy / d) * 200;
+        }
+    }
+
+    resize(glassW: number, glassH: number): void {
+        const scaleX = glassW / this.glassW;
+        const scaleY = glassH / this.glassH;
+        for (const b of this.blobs) {
+            b.x *= scaleX;
+            b.y *= scaleY;
+        }
+        this.glassW = glassW;
+        this.glassH = glassH;
+        this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH);
+        const { off, offCtx } = this.makeOffscreen(glassW, glassH);
+        this.offscreen = off;
+        this.offCtx = offCtx;
+    }
+
+    reset(): void {
+        this.initBlobs();
+    }
+
+    // ── Private ──────────────────────────────────────────────────────────────
+
+    private initBlobs(): void {
+        const W = this.glassW;
+        const H = this.glassH;
+        this.blobs = [];
+        for (let i = 0; i < BLOB_COUNT; i++) {
+            const type = (i % 2) as 0 | 1;
+            this.blobs.push({
+                x: W * (0.2 + Math.random() * 0.6),
+                y: H * (0.2 + Math.random() * 0.6),
+                vx: (Math.random() - 0.5) * 20,
+                vy: (Math.random() - 0.5) * 20,
+                radius: 22 + Math.random() * 14,
+                temp: type === 1 ? 0.8 : 0.2,
+                type,
+            });
+        }
+    }
+
+    private makeOffscreen(w: number, h: number): {
+        off: OffscreenCanvas | HTMLCanvasElement;
+        offCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+    } {
+        if (typeof OffscreenCanvas !== 'undefined') {
+            const off = new OffscreenCanvas(w, h);
+            return { off, offCtx: off.getContext('2d')! };
+        }
+        const off = document.createElement('canvas');
+        off.width = w;
+        off.height = h;
+        return { off, offCtx: off.getContext('2d')! };
+    }
+}
diff --git a/src/levels/SandWaterLevel.ts b/src/levels/SandWaterLevel.ts
new file mode 100644
index 0000000..d27d5bb
--- /dev/null
+++ b/src/levels/SandWaterLevel.ts
@@ -0,0 +1,217 @@
+import { ILevel } from './ILevel.js';
+import { Vector2 } from '../physics/Vector2.js';
+import { Water } from '../physics/Water.js';
+import { World } from '../physics/World.js';
+import { RigidBody } from '../physics/RigidBody.js';
+import { WaterRenderer } from '../rendering/WaterRenderer.js';
+import { GlassRenderer } from '../rendering/GlassRenderer.js';
+
+const SAND_COUNT = 38;
+const SAND_RADIUS = 5;
+const SAND_DENSITY = 2.5;
+const AIR_COUNT = 2;
+const AIR_DENSITY = 0.001;
+const AIR_RADIUS = 24;
+// Sand particles that move slower than this for several ticks are considered at rest
+const REST_SPEED = 8;    // px/s
+const STATIC_FRIC = 0.85; // extra velocity damping when resting
+
+/**
+ * Three-phase level: sand at the bottom, water in the middle, air bubbles at the top.
+ * Sand and air are RigidBodies; water is the SWE simulation.
+ */
+export class SandWaterLevel implements ILevel {
+    readonly name = 'Sand & Water';
+
+    private water: Water;
+    private world: World;
+    private waterRend: WaterRenderer;
+    private glassRend: GlassRenderer;
+
+    constructor(
+        private readonly ctx: CanvasRenderingContext2D,
+        private glassX: number,
+        private glassY: number,
+        glassW: number,
+        glassH: number,
+    ) {
+        // Water starts at 50%, accounting for sand volume at the bottom
+        this.water = new Water(80, glassW, glassH, 0.50);
+        this.world = new World(this.water, glassX, glassY, SAND_COUNT + AIR_COUNT + 2);
+        this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY);
+        this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH);
+        this.spawnParticles();
+    }
+
+    // ── ILevel ───────────────────────────────────────────────────────────────
+
+    step(dt: number, gravity: Vector2): void {
+        this.world.gravity = gravity;
+        this.world.step(dt);
+        this.applySandFriction();
+    }
+
+    draw(
+        ctx: CanvasRenderingContext2D,
+        _glassX: number,
+        _glassY: number,
+        _glassW: number,
+        _glassH: number,
+    ): void {
+        this.glassRend.beginClip();
+
+        // Sand drawn first (bottom layer, behind water)
+        this.drawSand(ctx);
+
+        // Air drawn before water (behind water surface)
+        this.drawAir(ctx);
+
+        // Water
+        this.waterRend.draw();
+
+        this.glassRend.endClip();
+        this.glassRend.draw();
+    }
+
+    onTouch(localX: number, localY: number, _gravity: Vector2): void {
+        const wx = this.glassX + localX;
+        const wy = this.glassY + localY;
+        // Disturb water surface
+        this.water.disturb(localX, 40, 200);
+        // Scatter nearby sand particles
+        for (const body of this.world.bodies) {
+            if (body.density < 1.0) continue; // skip air bubbles
+            const d = Math.hypot(body.position.x - wx, body.position.y - wy);
+            if (d < 60) {
+                const dx = body.position.x - wx;
+                const dy = body.position.y - wy;
+                const len = Math.hypot(dx, dy) + 1;
+                body.velocity.x += (dx / len) * 300;
+                body.velocity.y += (dy / len) * 300;
+            }
+        }
+    }
+
+    resize(glassW: number, glassH: number): void {
+        this.water.resize(glassW, glassH);
+        this.waterRend.glassX = this.glassX;
+        this.waterRend.glassY = this.glassY;
+        this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH);
+    }
+
+    reset(): void {
+        this.world.bodies = [];
+        this.water.resetFill(0.50);
+        this.spawnParticles();
+    }
+
+    // ── Private ──────────────────────────────────────────────────────────────
+
+    private spawnParticles(): void {
+        const gx = this.world.glassX;
+        const gy = this.world.glassY;
+        const gw = this.world.glassW;
+        const gh = this.world.glassH;
+
+        // Sand: pack into lower ~20% of glass
+        const sandZoneH = gh * 0.20;
+        const cols = Math.floor(gw / (SAND_RADIUS * 2 + 2));
+        for (let i = 0; i < SAND_COUNT; i++) {
+            const col = i % cols;
+            const row = Math.floor(i / cols);
+            const x = gx + SAND_RADIUS + col * (SAND_RADIUS * 2 + 2) + (Math.random() - 0.5) * 3;
+            const y = gy + gh - SAND_RADIUS - row * (SAND_RADIUS * 2 + 1);
+            if (y - SAND_RADIUS < gy + gh - sandZoneH * 1.5) {
+                // Toss remaining in random positions
+                const body = new RigidBody({
+                    position: new Vector2(
+                        gx + SAND_RADIUS + Math.random() * (gw - SAND_RADIUS * 2),
+                        gy + gh - SAND_RADIUS - Math.random() * sandZoneH,
+                    ),
+                    radius: SAND_RADIUS,
+                    density: SAND_DENSITY,
+                    label: '',
+                });
+                this.world.addBody(body);
+                continue;
+            }
+            const body = new RigidBody({
+                position: new Vector2(x, y),
+                radius: SAND_RADIUS,
+                density: SAND_DENSITY,
+                label: '',
+            });
+            this.world.addBody(body);
+        }
+
+        // Air bubbles: spawn near the top
+        for (let i = 0; i < AIR_COUNT; i++) {
+            const x = gx + AIR_RADIUS + Math.random() * (gw - AIR_RADIUS * 2);
+            const y = gy + AIR_RADIUS + Math.random() * gh * 0.25;
+            this.world.addBody(new RigidBody({
+                position: new Vector2(x, y),
+                radius: AIR_RADIUS,
+                density: AIR_DENSITY,
+                label: '',
+            }));
+        }
+    }
+
+    /** Extra static friction to prevent sand jitter when settled. */
+    private applySandFriction(): void {
+        for (const body of this.world.bodies) {
+            if (body.density < 1.0) continue; // air bodies — skip
+            const speed = body.velocity.magnitude();
+            if (speed < REST_SPEED) {
+                body.velocity.x *= STATIC_FRIC;
+                body.velocity.y *= STATIC_FRIC;
+            }
+        }
+    }
+
+    private drawSand(ctx: CanvasRenderingContext2D): void {
+        for (const body of this.world.bodies) {
+            if (body.density < 1.0) continue; // air — not sand
+            const { x, y } = body.position;
+            const r = body.radius;
+
+            ctx.save();
+            ctx.beginPath();
+            ctx.arc(x, y, r, 0, Math.PI * 2);
+            // Sandy color — slightly varied per body id
+            const hue = 38 + (body.id % 8) - 4;
+            const lit = 52 + (body.id % 10) - 5;
+            ctx.fillStyle = `hsl(${hue}, 60%, ${lit}%)`;
+            ctx.fill();
+            ctx.restore();
+        }
+    }
+
+    private drawAir(ctx: CanvasRenderingContext2D): void {
+        for (const body of this.world.bodies) {
+            if (body.density >= 1.0) continue; // sand — not air
+            const { x, y } = body.position;
+            const r = body.radius;
+
+            ctx.save();
+            ctx.beginPath();
+            ctx.arc(x, y, r, 0, Math.PI * 2);
+            const grad = ctx.createRadialGradient(
+                x - r * 0.3, y - r * 0.3, r * 0.1,
+                x, y, r,
+            );
+            grad.addColorStop(0, 'rgba(255,255,255,0.80)');
+            grad.addColorStop(0.5, 'rgba(200,230,255,0.30)');
+            grad.addColorStop(1, 'rgba(150,200,255,0.05)');
+            ctx.fillStyle = grad;
+            ctx.fill();
+            // Highlight arc
+            ctx.beginPath();
+            ctx.arc(x, y, r * 0.6, Math.PI * 1.2, Math.PI * 1.8);
+            ctx.strokeStyle = 'rgba(255,255,255,0.6)';
+            ctx.lineWidth = r * 0.1;
+            ctx.stroke();
+            ctx.restore();
+        }
+    }
+}
diff --git a/src/levels/ShampooLevel.ts b/src/levels/ShampooLevel.ts
new file mode 100644
index 0000000..bb1949a
--- /dev/null
+++ b/src/levels/ShampooLevel.ts
@@ -0,0 +1,228 @@
+import { ILevel } from './ILevel.js';
+import { Vector2 } from '../physics/Vector2.js';
+import { Water } from '../physics/Water.js';
+import { World } from '../physics/World.js';
+import { RigidBody } from '../physics/RigidBody.js';
+import { GlassRenderer } from '../rendering/GlassRenderer.js';
+import { WaterRenderer } from '../rendering/WaterRenderer.js';
+
+const INITIAL_BUBBLES = 4;
+const BUBBLE_DENSITY = 0.001; // extremely light — strong upward buoyancy
+const MIN_BUBBLE_R = 14;
+const MAX_BUBBLE_R = 36;
+// Merge when overlap > this fraction of smaller radius
+const MERGE_THRESHOLD = 0.55;
+// Split when speed exceeds this (px/s)
+const SPLIT_SPEED = 380;
+
+/**
+ * Shampoo bottle level.
+ * High-viscosity amber fluid with air bubbles that merge and split.
+ */
+export class ShampooLevel implements ILevel {
+    readonly name = 'Shampoo';
+
+    private water: Water;
+    private world: World;
+    private waterRend: WaterRenderer;
+    private glassRend: GlassRenderer;
+    private shimmerOffset = 0;
+
+    constructor(
+        private readonly ctx: CanvasRenderingContext2D,
+        private glassX: number,
+        private glassY: number,
+        glassW: number,
+        glassH: number,
+    ) {
+        // Shampoo: same SWE but viscous feel — high damping, slower pressure
+        this.water = new Water(80, glassW, glassH, 0.88, 400, 5.0);
+        this.world = new World(this.water, glassX, glassY);
+        this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY);
+        this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH);
+        this.spawnInitialBubbles();
+    }
+
+    // ── ILevel ───────────────────────────────────────────────────────────────
+
+    step(dt: number, gravity: Vector2): void {
+        this.world.gravity = gravity;
+        this.world.step(dt);
+        this.shimmerOffset = (this.shimmerOffset + dt * 0.3) % (Math.PI * 2);
+        this.mergeSplitBubbles();
+    }
+
+    draw(
+        ctx: CanvasRenderingContext2D,
+        glassX: number,
+        glassY: number,
+        _glassW: number,
+        _glassH: number,
+    ): void {
+        this.glassRend.beginClip();
+
+        // Bubbles behind fluid
+        this.drawBubbles(ctx);
+
+        // Amber shampoo fluid
+        this.waterRend.drawTinted(
+            'rgba(255, 170, 30, 0.78)',
+            'rgba(180, 90,  10, 0.90)',
+            'rgba(100, 40,   5, 0.96)',
+        );
+
+        this.glassRend.endClip();
+        this.glassRend.draw();
+    }
+
+    onTouch(localX: number, localY: number, _gravity: Vector2): void {
+        const gx = this.world.glassX;
+        const gy = this.world.glassY;
+        const wx = gx + localX;
+        const wy = gy + localY;
+        // Give nearby bubbles a strong impulse (may trigger split)
+        for (const body of this.world.bodies) {
+            const d = Math.hypot(body.position.x - wx, body.position.y - wy);
+            if (d < body.radius * 3) {
+                const dx = body.position.x - wx;
+                const dy = body.position.y - wy;
+                const len = Math.hypot(dx, dy) + 1;
+                body.velocity.x += (dx / len) * 500;
+                body.velocity.y += (dy / len) * 500;
+            }
+        }
+        // Also disturb fluid surface
+        this.water.disturb(localX, 30, 150);
+    }
+
+    resize(glassW: number, glassH: number): void {
+        this.water.resize(glassW, glassH);
+        this.waterRend.glassX = this.glassX;
+        this.waterRend.glassY = this.glassY;
+        this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH);
+    }
+
+    reset(): void {
+        this.world.bodies = [];
+        this.water.resetFill(0.88);
+        this.spawnInitialBubbles();
+    }
+
+    // ── Private ──────────────────────────────────────────────────────────────
+
+    private spawnInitialBubbles(): void {
+        const gx = this.world.glassX;
+        const gy = this.world.glassY;
+        const gw = this.world.glassW;
+        const gh = this.world.glassH;
+        for (let i = 0; i < INITIAL_BUBBLES; i++) {
+            const r = MIN_BUBBLE_R + Math.random() * (MAX_BUBBLE_R - MIN_BUBBLE_R);
+            const x = gx + r + Math.random() * (gw - r * 2);
+            const y = gy + gh * 0.3 + Math.random() * gh * 0.5;
+            this.world.addBody(new RigidBody({
+                position: new Vector2(x, y),
+                radius: r,
+                density: BUBBLE_DENSITY,
+                label: '',
+            }));
+        }
+    }
+
+    private mergeSplitBubbles(): void {
+        const bodies = this.world.bodies;
+
+        // Split fast bubbles first
+        const toAdd: RigidBody[] = [];
+        const toRemove = new Set<number>();
+        for (const body of bodies) {
+            if (toRemove.has(body.id)) continue;
+            const speed = body.velocity.magnitude();
+            if (speed > SPLIT_SPEED && body.radius > MIN_BUBBLE_R * 1.5) {
+                toRemove.add(body.id);
+                const rNew = body.radius / Math.cbrt(2);
+                const perp = new Vector2(-body.velocity.y, body.velocity.x).scale(
+                    1 / (speed + 0.001),
+                );
+                for (const sign of [-1, 1]) {
+                    const pos = body.position.add(perp.scale(sign * rNew * 0.8));
+                    const b = new RigidBody({
+                        position: pos, radius: rNew,
+                        density: BUBBLE_DENSITY, label: '',
+                    });
+                    b.velocity.x = body.velocity.x * 0.6;
+                    b.velocity.y = body.velocity.y * 0.6;
+                    toAdd.push(b);
+                }
+            }
+        }
+
+        // Merge overlapping bubbles
+        for (let i = 0; i < bodies.length; i++) {
+            const a = bodies[i]!;
+            if (toRemove.has(a.id)) continue;
+            for (let j = i + 1; j < bodies.length; j++) {
+                const b = bodies[j]!;
+                if (toRemove.has(b.id)) continue;
+                const dist = a.position.sub(b.position).magnitude();
+                const minR = Math.min(a.radius, b.radius);
+                if (dist < a.radius + b.radius - minR * MERGE_THRESHOLD) {
+                    // Volume-conserving merge: r = ∛(r₁³ + r₂³)
+                    const rNew = Math.cbrt(a.radius ** 3 + b.radius ** 3);
+                    if (rNew > MAX_BUBBLE_R * 1.5) continue; // cap size
+                    toRemove.add(a.id);
+                    toRemove.add(b.id);
+                    const totalMass = a.mass + b.mass;
+                    const merged = new RigidBody({
+                        position: a.position.scale(a.mass / totalMass).add(
+                            b.position.scale(b.mass / totalMass),
+                        ),
+                        radius: rNew,
+                        density: BUBBLE_DENSITY,
+                        label: '',
+                    });
+                    merged.velocity.x = (a.velocity.x * a.mass + b.velocity.x * b.mass) / totalMass;
+                    merged.velocity.y = (a.velocity.y * a.mass + b.velocity.y * b.mass) / totalMass;
+                    toAdd.push(merged);
+                    break;
+                }
+            }
+        }
+
+        if (toRemove.size > 0) {
+            this.world.bodies = bodies.filter(b => !toRemove.has(b.id));
+        }
+        for (const b of toAdd) this.world.addBody(b);
+
+        // If all bubbles are gone, spawn a fresh one
+        if (this.world.bodies.length === 0) this.spawnInitialBubbles();
+    }
+
+    private drawBubbles(ctx: CanvasRenderingContext2D): void {
+        for (const body of this.world.bodies) {
+            const { x, y } = body.position;
+            const r = body.radius;
+
+            // Main translucent white sphere
+            ctx.save();
+            ctx.beginPath();
+            ctx.arc(x, y, r, 0, Math.PI * 2);
+            const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r);
+            grad.addColorStop(0, 'rgba(255,255,255,0.85)');
+            grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
+            grad.addColorStop(1, 'rgba(255,255,255,0.05)');
+            ctx.fillStyle = grad;
+            ctx.fill();
+
+            // Specular highlight arc (10–2 o'clock)
+            ctx.beginPath();
+            ctx.arc(x, y, r * 0.65, Math.PI * 1.2, Math.PI * 1.8);
+            ctx.strokeStyle = 'rgba(255,255,255,0.7)';
+            ctx.lineWidth = r * 0.12;
+            ctx.stroke();
+            ctx.restore();
+        }
+    }
+}
+
+
+
diff --git a/src/levels/WaterLevel.ts b/src/levels/WaterLevel.ts
new file mode 100644
index 0000000..dc7a9fd
--- /dev/null
+++ b/src/levels/WaterLevel.ts
@@ -0,0 +1,162 @@
+import { ILevel } from './ILevel.js';
+import { Vector2 } from '../physics/Vector2.js';
+import { Water } from '../physics/Water.js';
+import { World } from '../physics/World.js';
+import { RigidBody } from '../physics/RigidBody.js';
+import { WaterRenderer } from '../rendering/WaterRenderer.js';
+import { GlassRenderer } from '../rendering/GlassRenderer.js';
+import { IconRenderer } from '../rendering/IconRenderer.js';
+import { ICONS } from '../icons/registry.js';
+
+/**
+ * The original water-glass level.
+ * Sealed container, full 360° gravity, emoji objects that float/sink.
+ */
+export class WaterLevel implements ILevel {
+    readonly name = 'Water';
+
+    private water: Water;
+    private world: World;
+    private waterRend: WaterRenderer;
+    private glassRend: GlassRenderer;
+    private iconRend: IconRenderer;
+
+    private shimmerOffset = 0;
+    private refractOffset = 0;
+
+    constructor(
+        private readonly ctx: CanvasRenderingContext2D,
+        glassX: number,
+        glassY: number,
+        glassW: number,
+        glassH: number,
+        qrTexture?: HTMLCanvasElement,
+    ) {
+        this.water = new Water(80, glassW, glassH, 0.55);
+        this.world = new World(this.water, glassX, glassY);
+
+        this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY);
+        this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH);
+        this.iconRend = new IconRenderer(ctx, this.water, glassX, glassY);
+
+        if (qrTexture) this.spawnQRBody(qrTexture);
+    }
+
+    // ── ILevel ──────────────────────────────────────────────────────────────
+
+    step(dt: number, gravity: Vector2): void {
+        this.world.gravity = gravity;
+        this.world.step(dt);
+        this.shimmerOffset = (this.shimmerOffset + dt * 0.4) % (Math.PI * 2);
+        this.refractOffset = (this.refractOffset + dt * 1.2) % (Math.PI * 2);
+    }
+
+    draw(
+        ctx: CanvasRenderingContext2D,
+        glassX: number,
+        glassY: number,
+        _glassW: number,
+        _glassH: number,
+    ): void {
+        this.glassRend.beginClip();
+
+        // Icons behind water
+        this.iconRend.draw(this.world.bodies, (x) => this.getRefractionDx(x));
+
+        // Water
+        this.waterRend.draw();
+
+        // Textured bodies (QR card) on top of water
+        for (const body of this.world.bodies) {
+            if (!body.texture) continue;
+            const s = body.radius * 2;
+            ctx.save();
+            ctx.translate(body.position.x, body.position.y);
+            ctx.rotate(body.angle);
+            ctx.fillStyle = '#ffffff';
+            const corner = 4;
+            ctx.beginPath();
+            ctx.roundRect(-s / 2, -s / 2, s, s, corner);
+            ctx.fill();
+            const pad = s * 0.06;
+            ctx.drawImage(body.texture, -s / 2 + pad, -s / 2 + pad, s - pad * 2, s - pad * 2);
+            ctx.restore();
+        }
+
+        this.glassRend.endClip();
+        this.glassRend.draw();
+    }
+
+    onTouch(localX: number, localY: number, _gravity: Vector2): void {
+        const glassX = this.world.glassX;
+        const glassY = this.world.glassY;
+        const gw = this.world.glassW;
+        const gh = this.world.glassH;
+
+        const icon = ICONS[Math.floor(Math.random() * ICONS.length)]!;
+        const r = icon.radius;
+
+        const spawnX = Math.max(glassX + r, Math.min(glassX + gw - r, glassX + localX));
+        const spawnY = Math.max(glassY + r, Math.min(glassY + gh - r, glassY + localY));
+
+        const body = new RigidBody({
+            position: new Vector2(spawnX, spawnY),
+            radius: r,
+            density: icon.density,
+            label: icon.label,
+        });
+        body.velocity.x = (Math.random() - 0.5) * 80;
+        body.angularVelocity = (Math.random() - 0.5) * 4;
+        this.world.addBody(body);
+    }
+
+    resize(glassW: number, glassH: number): void {
+        const glassX = this.world.glassX;
+        const glassY = this.world.glassY;
+        this.water.resize(glassW, glassH);
+        this.waterRend.glassX = glassX;
+        this.waterRend.glassY = glassY;
+        this.iconRend.glassX = glassX;
+        this.iconRend.glassY = glassY;
+        this.glassRend = new GlassRenderer(this.ctx, glassX, glassY, glassW, glassH);
+    }
+
+    /** Update glassX/Y when the window resizes. Called by App before resize(). */
+    setGlassOrigin(glassX: number, glassY: number): void {
+        this.world.glassX = glassX;
+        this.world.glassY = glassY;
+        this.waterRend.glassX = glassX;
+        this.waterRend.glassY = glassY;
+        this.iconRend.glassX = glassX;
+        this.iconRend.glassY = glassY;
+    }
+
+    reset(): void {
+        this.world.bodies = this.world.bodies.filter(b => b.permanent);
+        this.water.resetFill(0.55);
+    }
+
+    // ── Public helpers ───────────────────────────────────────────────────────
+
+    /** Expose World so App can spawn/access permanent bodies (e.g. QR). */
+    get worldRef(): World { return this.world; }
+
+    spawnQRBody(qrTexture: HTMLCanvasElement): void {
+        const r = 40;
+        const x = this.world.glassX + this.world.glassW / 2;
+        const y = this.world.glassY + r + 4;
+        const body = new RigidBody({
+            position: new Vector2(x, y),
+            radius: r,
+            density: 0.5,
+            label: '',
+            texture: qrTexture,
+            permanent: true,
+        });
+        this.world.addBody(body);
+    }
+
+    private getRefractionDx(canvasX: number): number {
+        return Math.sin(this.refractOffset + canvasX * 0.04) * 3;
+    }
+}
diff --git a/src/main.ts b/src/main.ts
index c289bbb..7890562 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,8 @@
 import { App } from './App.js';
+import { WaterLevel } from './levels/WaterLevel.js';
+import { LavaLampLevel } from './levels/LavaLampLevel.js';
+import { ShampooLevel } from './levels/ShampooLevel.js';
+import { SandWaterLevel } from './levels/SandWaterLevel.js';
 import QRCode from 'qrcode';
 
 const canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
@@ -30,26 +34,43 @@ async function enterFullscreen(): Promise<void> {
     await tryLockOrientation();
 }
 
-// Re-apply lock if fullscreen change drops it
 document.addEventListener('fullscreenchange', () => {
     if (document.fullscreenElement && !orientationLocked) {
         void tryLockOrientation();
     }
 });
 
-// Re-apply lock if OS overrides it mid-session
 screen.orientation.addEventListener('change', () => {
     if (orientationLocked) void tryLockOrientation();
 });
 
-// ── Start button ──────────────────────────────────────────────────────────
+// ── App + levels ──────────────────────────────────────────────────────────
 
 const app = new App(canvas);
-const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement;
 
-resetBtn.addEventListener('click', () => app.reset());
+// Build all levels sharing the same ctx/glass geometry from app
+// (App exposes canvas; levels read glass dims on construction via glassX/Y/W/H)
+const ctx = app.canvas.getContext('2d')!;
+
+function makeAllLevels(): void {
+    const W = window.innerWidth;
+    const H = window.innerHeight;
+    const glassW = Math.min(W * 0.82, 420);
+    const glassH = H * 0.72;
+    const glassX = (W - glassW) / 2;
+    const glassY = (H - glassH) / 2 - H * 0.02;
+
+    app.setLevels([
+        new WaterLevel(ctx, glassX, glassY, glassW, glassH),
+        new LavaLampLevel(ctx, glassX, glassY, glassW, glassH),
+        new ShampooLevel(ctx, glassX, glassY, glassW, glassH),
+        new SandWaterLevel(ctx, glassX, glassY, glassW, glassH),
+    ]);
+}
+makeAllLevels();
+
+// ── QR body ───────────────────────────────────────────────────────────────
 
-// ── QR body — generate once and spawn into the glass ──────────────────────
 void (async () => {
     const qrCanvas = document.createElement('canvas');
     await QRCode.toCanvas(qrCanvas, window.location.href, {
@@ -60,9 +81,72 @@ void (async () => {
     app.spawnQRBody(qrCanvas);
 })();
 
+// ── Reset button ──────────────────────────────────────────────────────────
+
+const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement;
+resetBtn.addEventListener('click', () => app.reset());
+
+// ── Level dot indicator ───────────────────────────────────────────────────
+
+const dotsEl = document.getElementById('level-dots') as HTMLDivElement;
+let dotsTimeout = 0;
+
+function updateDots(index: number, total: number): void {
+    dotsEl.innerHTML = '';
+    for (let i = 0; i < total; i++) {
+        const dot = document.createElement('span');
+        dot.className = 'dot' + (i === index ? ' active' : '');
+        dotsEl.appendChild(dot);
+    }
+    dotsEl.classList.add('visible');
+    clearTimeout(dotsTimeout);
+    dotsTimeout = window.setTimeout(() => dotsEl.classList.remove('visible'), 2000);
+}
+
+app.onLevelChange = (index, total) => updateDots(index, total);
+
+// ── Touch: tap to drop, swipe to switch level ─────────────────────────────
+
+let pointerStartX = 0;
+let pointerStartY = 0;
+let pointerMoved = false;
+
+canvas.addEventListener('pointerdown', async (e: PointerEvent) => {
+    pointerStartX = e.clientX;
+    pointerStartY = e.clientY;
+    pointerMoved = false;
+    await app.requestPermissionOnce();
+});
+
+canvas.addEventListener('pointermove', (e: PointerEvent) => {
+    if (Math.abs(e.clientX - pointerStartX) > 8 || Math.abs(e.clientY - pointerStartY) > 8) {
+        pointerMoved = true;
+    }
+});
+
+canvas.addEventListener('pointerup', (e: PointerEvent) => {
+    const dx = e.clientX - pointerStartX;
+    const dy = e.clientY - pointerStartY;
+    const dist = Math.sqrt(dx * dx + dy * dy);
+
+    if (dist > 40 && Math.abs(dx) > Math.abs(dy) * 1.5) {
+        // Horizontal swipe
+        if (dx < 0) app.nextLevel(); else app.prevLevel();
+        updateDots(app.levelIndex, app.levelCount);
+    } else if (!pointerMoved || dist < 12) {
+        // Tap
+        app.touch(e.clientX, e.clientY);
+    }
+    pointerMoved = false;
+});
+
+// ── Start button ──────────────────────────────────────────────────────────
+
 startBtn.addEventListener('click', async () => {
     await enterFullscreen();
     startScreen.classList.add('hidden');
+    updateDots(app.levelIndex, app.levelCount);
     app.start();
 }, { once: true });
 
+
diff --git a/src/physics/Water.ts b/src/physics/Water.ts
index 5e341c6..cc0cab1 100644
--- a/src/physics/Water.ts
+++ b/src/physics/Water.ts
@@ -3,16 +3,19 @@
  *
  * Grid layout:
  *   h[i]  column height at cell centre   i = 0 … N−1
+ *            measured from the "gravity wall" (the wall gravity pulls toward).
  *   u[j]  horizontal velocity at face    j = 0 … N
- *          u[0] = u[N] = 0  (solid walls)
+ *          u[0] = u[N] = 0  (solid walls, sealed container)
  *
- * Governing equations (linearised around mean depth H̄):
- *   ∂h/∂t + H̄ · ∂u/∂x = 0          (mass continuity, nonlinear flux used)
+ * Governing equations:
+ *   ∂h/∂t + H̄ · ∂u/∂x = 0          (mass continuity, nonlinear flux)
  *   ∂u/∂t = −g_sw · ∂h/∂x + aₓ     (depth-averaged momentum)
  *
- * Wave speed  c = √(g_sw · H̄).
- * With g_sw = 980 and H̄ ≈ 150 px  →  c ≈ 383 px/s,
- * giving a natural slosh period  T ≈ 2W/c ≈ 1.6 s for a 300 px-wide glass.
+ * Full 360° support: when the device is flipped upside-down (verticalG < 0),
+ * h[i] is reinterpreted as measuring from the ceiling rather than the floor.
+ * getSurfaceY() accounts for this so callers need no special-casing.
+ *
+ * The container is sealed — no spilling. Volume is conserved permanently.
  */
 export class Water {
     /** Column heights (cell centres). */
@@ -22,19 +25,28 @@ export class Water {
     /** Scratch buffer for continuity update — avoids in-place aliasing errors. */
     private readonly hNext: Float32Array;
 
-    /** Conserved water volume [px²]. Decreases permanently on rim spill. */
+    /** Conserved water volume [px²]. Never changes — sealed container. */
     private volume: number;
+    /** Whether gravity currently points toward the ceiling (phone upside-down). */
+    private _isFlipped = false;
+    private _lastVerticalSign = 1;
 
-    private static readonly G_SW = 980;  // shallow-water gravity [px/s²]
-    private static readonly K_DAMP = 1.5;  // linear velocity damping coefficient [1/s]
-    private static readonly CFL = 0.85; // target CFL number (< 1 required)
+    private readonly gSW: number;
+    private readonly kDamp: number;
+    private static readonly CFL = 0.85;
 
     constructor(
         public readonly columns: number = 80,
         public glassW: number = 300,
         public glassH: number = 500,
         fillFrac: number = 0.55,
+        /** Override wave gravity (default 980). Lower = slower/more viscous feel. */
+        gSW: number = 980,
+        /** Override damping coefficient (default 1.5). Higher = more viscous. */
+        kDamp: number = 1.5,
     ) {
+        this.gSW = gSW;
+        this.kDamp = kDamp;
         const h0 = glassH * fillFrac;
         this.h = new Float32Array(columns).fill(h0);
         this.hNext = new Float32Array(columns);
@@ -43,30 +55,38 @@ export class Water {
     }
 
     get columnWidth(): number { return this.glassW / this.columns; }
-
     get averageHeight(): number { return this.volume / this.glassW; }
+    /** True when gravity points toward the ceiling (phone held upside-down). */
+    get isFlipped(): boolean { return this._isFlipped; }
+
+    step(dt: number, lateralG: number, verticalG: number): void {
+        // ── Detect gravity direction flip (phone turned upside-down) ────────
+        const vSign = verticalG >= 0 ? 1 : -1;
+        if (vSign !== this._lastVerticalSign) {
+            this._lastVerticalSign = vSign;
+            this._isFlipped = !this._isFlipped;
+            const h0 = this.volume / this.glassW;
+            this.h.fill(h0);
+            this.u.fill(0);
+        }
 
-    step(dt: number, lateralG: number, _verticalG: number): void {
         const N = this.columns;
         const dx = this.glassW / N;
-        const g = Water.G_SW;
 
         // ── Adaptive sub-steps: keep CFL = dt_sub/dx * c_max < CFL_target ────
         let maxH = 1;
         for (let i = 0; i < N; i++) if (this.h[i]! > maxH) maxH = this.h[i]!;
-        const cMax = Math.sqrt(g * maxH);
+        const cMax = Math.sqrt(this.gSW * maxH);
         const subSteps = Math.max(1, Math.ceil(dt * cMax / (dx * Water.CFL)));
         const dtS = dt / subSteps;
-        const dampFactor = Math.exp(-Water.K_DAMP * dtS);
-
-        let spilled = 0;
+        const dampFactor = Math.exp(-this.kDamp * dtS);
 
         for (let s = 0; s < subSteps; s++) {
             // ── 1. Momentum: pressure gradient + tilt body-force + damping ────
             for (let j = 1; j < N; j++) {
                 this.u[j] = (
                     this.u[j]!
-                    - g * (dtS / dx) * (this.h[j]! - this.h[j - 1]!)
+                    - this.gSW * (dtS / dx) * (this.h[j]! - this.h[j - 1]!)
                     + lateralG * dtS
                 ) * dampFactor;
             }
@@ -76,32 +96,22 @@ export class Water {
 
             // ── 2. Continuity: upwind nonlinear flux into scratch buffer ──────
             for (let i = 0; i < N; i++) {
-                // Face j = i+1 (right face of cell i)
                 const uR = this.u[i + 1]!;
                 const hR = uR >= 0 ? this.h[i]! : (i + 1 < N ? this.h[i + 1]! : this.h[i]!);
-                // Face j = i   (left face of cell i)
                 const uL = this.u[i]!;
                 const hL = uL <= 0 ? this.h[i]! : (i > 0 ? this.h[i - 1]! : this.h[i]!);
                 this.hNext[i] = this.h[i]! - (dtS / dx) * (uR * hR - uL * hL);
             }
-            // Swap h ← hNext
             this.h.set(this.hNext);
 
-            // ── 3. Clamp: floor and rim ────────────────────────────────────────
+            // ── 3. Clamp: gravity wall and opposite wall (sealed) ─────────────
             for (let i = 0; i < N; i++) {
-                if (this.h[i]! < 0) {
-                    this.h[i] = 0;
-                } else if (this.h[i]! > this.glassH) {
-                    spilled += (this.h[i]! - this.glassH) * dx;
-                    this.h[i] = this.glassH;
-                }
+                if (this.h[i]! < 0) this.h[i] = 0;
+                else if (this.h[i]! > this.glassH) this.h[i] = this.glassH;
             }
         }
 
-        // Permanently reduce stored volume by whatever spilled over the rim
-        this.volume = Math.max(0, this.volume - spilled);
-
-        // ── 4. Volume correction: recover any transient floor-clamp loss ──────
+        // ── 4. Volume correction (sealed — no spill, only clamp rounding) ────
         let sum = 0;
         for (let i = 0; i < N; i++) sum += this.h[i]!;
         const actualVol = sum * dx;
@@ -113,9 +123,19 @@ export class Water {
         }
     }
 
+    /**
+     * Y coordinate (in glass space: 0 = top, glassH = bottom) of the
+     * water–air interface at horizontal position x.
+     *
+     * When not flipped: surface is above the water (water fills below).
+     * When flipped:     surface is below the water (water fills above).
+     * Callers can use `water.isFlipped` to know which side the water is on.
+     */
     getSurfaceY(x: number): number {
         const col = Math.max(0, Math.min(this.columns - 1, Math.floor(x / this.columnWidth)));
-        return this.glassH - this.h[col]!;
+        // isFlipped: h[col] measures from ceiling → surface is that far from top
+        // normal:    h[col] measures from floor   → surface is (glassH − h) from top
+        return this._isFlipped ? this.h[col]! : this.glassH - this.h[col]!;
     }
 
     /**
diff --git a/src/physics/World.ts b/src/physics/World.ts
index 47ac49b..b7b2a35 100644
--- a/src/physics/World.ts
+++ b/src/physics/World.ts
@@ -3,32 +3,31 @@ import { Water } from './Water.js';
 import { RigidBody } from './RigidBody.js';
 
 const WATER_DENSITY = 1.0;
-const RESTITUTION = 0.35; // bounciness for wall/floor collisions
-const FRICTION = 0.72; // tangential damping on wall bounce
+const RESTITUTION = 0.35;
+const FRICTION = 0.72;
 const BODY_RESTITUTION = 0.4;
-const MAX_BODIES = 12;
 const ANGULAR_DAMPING = 0.96;
 
 export class World {
     bodies: RigidBody[] = [];
-    /** Gravity vector (px/s²). +y is DOWN. Magnitude ~980 (≈ real 9.8 m/s² at 100px/m). */
     gravity: Vector2 = new Vector2(0, 980);
+    readonly maxBodies: number;
 
     constructor(
         public water: Water,
-        /** Left edge X of glass interior in canvas space */
         public glassX: number,
-        /** Top edge Y of glass interior in canvas space */
         public glassY: number,
-    ) { }
+        maxBodies = 12,
+    ) {
+        this.maxBodies = maxBodies;
+    }
 
     get glassW(): number { return this.water.glassW; }
     get glassH(): number { return this.water.glassH; }
 
     addBody(body: RigidBody): void {
         this.bodies.push(body);
-        if (this.bodies.length > MAX_BODIES) {
-            // Evict the oldest non-permanent body
+        if (this.bodies.length > this.maxBodies) {
             const idx = this.bodies.findIndex(b => !b.permanent);
             if (idx !== -1) this.bodies.splice(idx, 1);
         }
@@ -37,14 +36,15 @@ export class World {
     step(dt: number): void {
         const g = this.gravity;
 
-        // Advance water simulation
-        this.water.step(dt, g.x, Math.abs(g.y));
+        // Advance water simulation (signed verticalG so Water can detect flips)
+        this.water.step(dt, g.x, g.y);
 
         for (const body of this.bodies) {
             // --- Buoyancy ---
             const submergedFrac = this.submergedFraction(body);
             const bodyArea = Math.PI * body.radius * body.radius;
-            const buoyancyMag = WATER_DENSITY * Math.abs(g.y) * bodyArea * submergedFrac;
+            // Buoyancy magnitude uses |g| so it works at any orientation
+            const buoyancyMag = WATER_DENSITY * g.magnitude() * bodyArea * submergedFrac;
 
             // Net force = gravity - buoyancy (both along Y axis primarily)
             const gravityForce = g.scale(body.mass);
@@ -75,38 +75,35 @@ export class World {
             this.constrainToGlass(body);
         }
 
-        // --- Body-body collisions ---
+        // Body-body collisions
         for (let i = 0; i < this.bodies.length; i++) {
             for (let j = i + 1; j < this.bodies.length; j++) {
                 this.resolveBodyCollision(this.bodies[i]!, this.bodies[j]!);
             }
         }
-
-        // Remove non-permanent bodies that have fully escaped above the glass rim
-        this.bodies = this.bodies.filter(b =>
-            b.permanent || (b.position.y + b.radius) > this.glassY
-        );
+        // Sealed container — no escape culling.
     }
 
     private submergedFraction(body: RigidBody): number {
-        // Body position is in canvas space; glass interior origin is (glassX, glassY)
         const localX = body.position.x - this.glassX;
         const localY = body.position.y - this.glassY;
 
         const r = body.radius;
-        const centerDepthFromFloor = this.glassH - localY; // how far above floor center is
         const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX)));
-        // surfaceY is distance from top of glass → convert to distance from floor
-        const surfaceHeightFromFloor = this.glassH - surfaceY;
 
-        // Distance from center to surface (positive = below surface)
-        const d = surfaceHeightFromFloor - centerDepthFromFloor;
+        // d > 0  → body center is on the water side of the surface → submerged
+        // d < 0  → body center is on the air side
+        //
+        // Normal:  water is BELOW surfaceY → submerged when localY > surfaceY
+        // Flipped: water is ABOVE surfaceY → submerged when localY < surfaceY
+        const d = this.water.isFlipped
+            ? surfaceY - localY   // positive when center is above surface (in water)
+            : localY - surfaceY; // positive when center is below surface (in water)
 
-        if (d >= r) return 1.0; // fully submerged
-        if (d <= -r) return 0.0; // fully above water
+        if (d >= r) return 1.0;
+        if (d <= -r) return 0.0;
 
-        // Partial submersion: circular segment area / total area
-        const h = d + r; // submerged depth (0 … 2r)
+        const h = d + r;
         const segmentArea = r * r * Math.acos((r - h) / r) - (r - h) * Math.sqrt(2 * r * h - h * h);
         return segmentArea / (Math.PI * r * r);
     }
@@ -116,22 +113,25 @@ export class World {
         let lx = body.position.x - this.glassX;
         let ly = body.position.y - this.glassY;
 
-        // Only enforce walls below the rim (ly >= 0); above the rim bodies can fly free
-        if (ly + r > 0) {
-            // Left wall
-            if (lx - r < 0) {
-                lx = r;
-                body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION;
-                body.velocity.y *= FRICTION;
-                body.angularVelocity += body.velocity.y * 0.01;
-            }
-            // Right wall
-            if (lx + r > this.glassW) {
-                lx = this.glassW - r;
-                body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION;
-                body.velocity.y *= FRICTION;
-                body.angularVelocity -= body.velocity.y * 0.01;
-            }
+        // Left wall
+        if (lx - r < 0) {
+            lx = r;
+            body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION;
+            body.velocity.y *= FRICTION;
+            body.angularVelocity += body.velocity.y * 0.01;
+        }
+        // Right wall
+        if (lx + r > this.glassW) {
+            lx = this.glassW - r;
+            body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION;
+            body.velocity.y *= FRICTION;
+            body.angularVelocity -= body.velocity.y * 0.01;
+        }
+        // Ceiling (sealed top)
+        if (ly - r < 0) {
+            ly = r;
+            body.velocity.y = Math.abs(body.velocity.y) * RESTITUTION;
+            body.velocity.x *= FRICTION;
         }
         // Floor
         if (ly + r > this.glassH) {
@@ -149,14 +149,13 @@ export class World {
         const localY = body.position.y - this.glassY;
         const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX)));
 
-        // Only disturb when the body center is within one radius of the surface
         const distToSurface = Math.abs(localY - surfaceY);
         if (distToSurface > body.radius * 1.5) return;
 
-        // Scale impulse by how fast the body is crossing the surface (Y component)
-        // and how much of it is in the surface zone
-        const velY = body.velocity.y;
-        if (Math.abs(velY) < 10) return; // ignore very slow drift
+        // Positive velY means moving toward gravity wall; negate when flipped so
+        // the disturbance always represents an impact on the water surface.
+        const velY = this.water.isFlipped ? -body.velocity.y : body.velocity.y;
+        if (Math.abs(velY) < 10) return;
 
         this.water.disturb(localX, body.radius, velY);
     }
diff --git a/src/rendering/GlassRenderer.ts b/src/rendering/GlassRenderer.ts
index ff6c945..e3a1da2 100644
--- a/src/rendering/GlassRenderer.ts
+++ b/src/rendering/GlassRenderer.ts
@@ -31,10 +31,10 @@ export class GlassRenderer {
         this.ctx.restore();
     }
 
-    /** Draw the glass cup outline over everything. */
+    /** Draw the sealed glass container outline over everything. */
     draw(): void {
         const ctx = this.ctx;
-        const { x, y, w, h, wallThick: wt, rimOverhang: ro } = this;
+        const { x, y, w, h, wallThick: wt } = this;
 
         ctx.save();
 
@@ -42,29 +42,24 @@ export class GlassRenderer {
         ctx.fillStyle = 'rgba(186, 230, 253, 0.07)';
         ctx.fillRect(x, y, w, h);
 
-        // Left wall
+        // Walls and top/bottom (sealed rectangle)
         ctx.fillStyle = 'rgba(186, 230, 253, 0.55)';
-        ctx.fillRect(x - wt, y - ro, wt, h + ro);
-
+        // Left wall (full height including top+bottom overlap)
+        ctx.fillRect(x - wt, y - wt, wt, h + wt * 2);
         // Right wall
-        ctx.fillRect(x + w, y - ro, wt, h + ro);
-
+        ctx.fillRect(x + w, y - wt, wt, h + wt * 2);
+        // Top (sealed)
+        ctx.fillRect(x - wt, y - wt, w + wt * 2, wt);
         // Bottom
         ctx.fillRect(x - wt, y + h, w + wt * 2, wt);
 
-        // Inner highlight on left wall edge
+        // Inner highlight — left edge
         ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
-        ctx.fillRect(x - wt, y - ro, 3, h + ro);
-
-        // Inner highlight on right wall edge
-        ctx.fillRect(x + w + wt - 3, y - ro, 3, h + ro);
-
-        // Rim top glow
-        const rimGrad = ctx.createLinearGradient(x - wt, y - ro, x - wt, y);
-        rimGrad.addColorStop(0, 'rgba(255,255,255,0.32)');
-        rimGrad.addColorStop(1, 'rgba(255,255,255,0.0)');
-        ctx.fillStyle = rimGrad;
-        ctx.fillRect(x - wt, y - ro, w + wt * 2, ro + 4);
+        ctx.fillRect(x - wt, y - wt, 3, h + wt * 2);
+        // Inner highlight — right edge
+        ctx.fillRect(x + w + wt - 3, y - wt, 3, h + wt * 2);
+        // Inner highlight — top edge
+        ctx.fillRect(x - wt, y - wt, w + wt * 2, 3);
 
         ctx.restore();
     }
diff --git a/src/rendering/WaterRenderer.ts b/src/rendering/WaterRenderer.ts
index e6845f1..4d6dacc 100644
--- a/src/rendering/WaterRenderer.ts
+++ b/src/rendering/WaterRenderer.ts
@@ -21,47 +21,30 @@ export class WaterRenderer {
     draw(): void {
         const ctx = this.ctx;
         const w = this.water;
-        const n = w.columns;
-        const colW = w.columnWidth;
         const gx = this.glassX;
         const gy = this.glassY;
         const gh = w.glassH;
+        const flipped = w.isFlipped;
 
-        // Build wave polygon path (top surface + four corners closing the polygon)
-        ctx.beginPath();
-        // Start at bottom-left
-        ctx.moveTo(gx, gy + gh);
-
-        for (let i = 0; i < n; i++) {
-            const x = gx + i * colW + colW / 2;
-            const sy = gy + w.getSurfaceY(i * colW + colW / 2);
-
-            if (i === 0) {
-                ctx.lineTo(gx, gy + w.getSurfaceY(0));
-                ctx.lineTo(x, sy);
-            } else {
-                // Smooth bezier between columns
-                const prevX = gx + (i - 1) * colW + colW / 2;
-                const prevSY = gy + w.getSurfaceY((i - 1) * colW + colW / 2);
-                const cpX = (prevX + x) / 2;
-                ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy);
-            }
-        }
+        // far edge = the solid wall on the opposite side from the wave surface
+        const far = flipped ? gy : gy + gh;
 
-        // Close to bottom-right then bottom-left
-        ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1));
-        ctx.lineTo(gx + w.glassW, gy + gh);
-        ctx.closePath();
+        // ── Main fill ────────────────────────────────────────────────────────
+        ctx.beginPath();
+        this.buildWaterPolygon(ctx, far);
 
-        // --- Main fill gradient ---
-        const grad = ctx.createLinearGradient(gx, gy, gx, gy + gh);
-        grad.addColorStop(0.0, 'rgba(56, 189, 248, 0.72)');   // surface — a light sky blue
-        grad.addColorStop(0.5, 'rgba(14, 116, 144, 0.85)');   // mid
-        grad.addColorStop(1.0, 'rgba(8,  51,  68, 0.95)');    // deep
+        // Gradient: light near the wave surface (where light refracts), dark away from it
+        const [gradY0, gradY1] = flipped
+            ? [gy + gh, gy]   // flipped: surface at bottom of water → light there
+            : [gy, gy + gh];
+        const grad = ctx.createLinearGradient(gx, gradY0, gx, gradY1);
+        grad.addColorStop(0.0, 'rgba(56, 189, 248, 0.72)');
+        grad.addColorStop(0.5, 'rgba(14, 116, 144, 0.85)');
+        grad.addColorStop(1.0, 'rgba(8,  51,  68, 0.95)');
         ctx.fillStyle = grad;
         ctx.fill();
 
-        // --- Shimmer band (animated diagonal highlight) ---
+        // ── Shimmer band ─────────────────────────────────────────────────────
         const shimmerY = gy + gh * 0.25 + Math.sin(this.shimmerOffset) * gh * 0.12;
         const shimGrad = ctx.createLinearGradient(gx, shimmerY - 30, gx, shimmerY + 30);
         shimGrad.addColorStop(0, 'rgba(255,255,255,0.0)');
@@ -70,13 +53,34 @@ export class WaterRenderer {
 
         ctx.save();
         ctx.beginPath();
-        // Re-clip to the same wave shape so shimmer stays inside water
-        ctx.moveTo(gx, gy + gh);
+        this.buildWaterPolygon(ctx, far);
+        ctx.clip();
+        ctx.fillStyle = shimGrad;
+        ctx.fillRect(gx, shimmerY - 30, w.glassW, 60);
+        ctx.restore();
+
+        // ── Surface highlight line ────────────────────────────────────────────
+        ctx.beginPath();
+        this.buildSurfaceLine(ctx);
+        ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)';
+        ctx.lineWidth = 2;
+        ctx.stroke();
+    }
+
+    /** Build the closed water polygon. `far` is the Y of the opposite wall. */
+    private buildWaterPolygon(ctx: CanvasRenderingContext2D, far: number): void {
+        const w = this.water;
+        const n = w.columns;
+        const colW = w.columnWidth;
+        const gx = this.glassX;
+        const gy = this.glassY;
+
+        ctx.moveTo(gx, far);
+        ctx.lineTo(gx, gy + w.getSurfaceY(0));
         for (let i = 0; i < n; i++) {
             const x = gx + i * colW + colW / 2;
             const sy = gy + w.getSurfaceY(i * colW + colW / 2);
             if (i === 0) {
-                ctx.lineTo(gx, gy + w.getSurfaceY(0));
                 ctx.lineTo(x, sy);
             } else {
                 const prevX = gx + (i - 1) * colW + colW / 2;
@@ -86,21 +90,23 @@ export class WaterRenderer {
             }
         }
         ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1));
-        ctx.lineTo(gx + w.glassW, gy + gh);
+        ctx.lineTo(gx + w.glassW, far);
         ctx.closePath();
-        ctx.clip();
+    }
 
-        ctx.fillStyle = shimGrad;
-        ctx.fillRect(gx, shimmerY - 30, w.glassW, 60);
-        ctx.restore();
+    /** Draw just the wave surface line (used for the highlight stroke). */
+    private buildSurfaceLine(ctx: CanvasRenderingContext2D): void {
+        const w = this.water;
+        const n = w.columns;
+        const colW = w.columnWidth;
+        const gx = this.glassX;
+        const gy = this.glassY;
 
-        // --- Surface highlight line ---
-        ctx.beginPath();
+        ctx.moveTo(gx, gy + w.getSurfaceY(0));
         for (let i = 0; i < n; i++) {
             const x = gx + i * colW + colW / 2;
             const sy = gy + w.getSurfaceY(i * colW + colW / 2);
             if (i === 0) {
-                ctx.moveTo(gx, gy + w.getSurfaceY(0));
                 ctx.lineTo(x, sy);
             } else {
                 const prevX = gx + (i - 1) * colW + colW / 2;
@@ -110,7 +116,35 @@ export class WaterRenderer {
             }
         }
         ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1));
-        ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)';
+    }
+
+    /**
+     * Like draw() but with custom gradient colors. Used by ShampooLevel.
+     * top/mid/bot are CSS color strings for the fill gradient.
+     */
+    drawTinted(top: string, mid: string, bot: string): void {
+        const ctx = this.ctx;
+        const w = this.water;
+        const gx = this.glassX;
+        const gy = this.glassY;
+        const gh = w.glassH;
+        const flipped = w.isFlipped;
+        const far = flipped ? gy : gy + gh;
+        const [gradY0, gradY1] = flipped ? [gy + gh, gy] : [gy, gy + gh];
+
+        ctx.beginPath();
+        this.buildWaterPolygon(ctx, far);
+        const grad = ctx.createLinearGradient(gx, gradY0, gx, gradY1);
+        grad.addColorStop(0.0, top);
+        grad.addColorStop(0.5, mid);
+        grad.addColorStop(1.0, bot);
+        ctx.fillStyle = grad;
+        ctx.fill();
+
+        // Surface highlight
+        ctx.beginPath();
+        this.buildSurfaceLine(ctx);
+        ctx.strokeStyle = 'rgba(255, 210, 100, 0.5)';
         ctx.lineWidth = 2;
         ctx.stroke();
     }