775e32f9 2026-04-09 Pavel Safronov | 1 | # Bubbles — Multi-Level Plan |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | Four changes are in scope: |
| 6 | |
| 7 | 1. **Sealed container + full 360° gravity** — foundational, affects all levels |
| 8 | 2. **Level: Water glass** — current level, updated for sealed physics |
| 9 | 3. **Level: Lava lamp** — metaball blobs, two densities, thermal cycling |
| 10 | 4. **Level: Sand · Water · Air** — three-phase layered simulation |
| 11 | 5. **Level: Shampoo bottle** — high-viscosity fluid with merging air bubbles |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## 1. Sealed Container + Full 360° Gravity |
| 16 | |
| 17 | ### What changes |
| 18 | |
| 19 | **`TiltInput`** — currently only uses `gamma` (left/right tilt) and clamps to ±88°. |
| 20 | Full rotation means also using `beta` (front/back) to detect upside-down orientation. |
| 21 | Output stays `gravity: Vector2` but now truly reflects 3D device orientation projected onto the screen plane: |
| 22 | |
| 23 | ``` |
| 24 | gx = sin(gamma) * cos(beta) * 980 |
| 25 | gy = sin(beta) * 980 (negative = phone tilted face-down = gravity pulls "up") |
| 26 | ``` |
| 27 | |
| 28 | When the phone is fully vertical face-down, `gy ≈ −980` and liquid should rest at the top of the container. |
| 29 | |
| 30 | **`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`. |
| 31 | |
| 32 | **`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. |
| 33 | |
| 34 | **`GlassRenderer`** — container is sealed: render top wall the same as side walls (no open top). |
| 35 | |
| 36 | **No other API surface changes** — `gravity: Vector2` stays the same shape. |
| 37 | |
| 38 | ### Risk: low. The SWE floor/ceiling clamping is already symmetric; this is mostly removing the spill path and fixing TiltInput. |
| 39 | |
| 40 | --- |
| 41 | |
| 42 | ## 2. Level Architecture |
| 43 | |
| 44 | ### New structure |
| 45 | |
| 46 | ``` |
| 47 | src/levels/ |
| 48 | ILevel.ts — interface |
| 49 | WaterLevel.ts — current water glass (sealed) |
| 50 | LavaLampLevel.ts |
| 51 | SandWaterLevel.ts |
| 52 | ShampooLevel.ts |
| 53 | ``` |
| 54 | |
| 55 | ### `ILevel` interface |
| 56 | |
| 57 | ```ts |
| 58 | interface ILevel { |
| 59 | /** Called once per physics tick. gravity is always the device gravity vector. */ |
| 60 | step(dt: number, gravity: Vector2): void; |
| 61 | /** Called once per rAF frame. */ |
| 62 | draw(ctx: CanvasRenderingContext2D, containerRect: DOMRect): void; |
| 63 | /** Called on touch/pointer event inside container. */ |
| 64 | onTouch(localX: number, localY: number): void; |
| 65 | /** Called when container size changes. */ |
| 66 | resize(w: number, h: number): void; |
| 67 | /** Reset to initial state. */ |
| 68 | reset(): void; |
| 69 | readonly name: string; |
| 70 | } |
| 71 | ``` |
| 72 | |
| 73 | ### `App.ts` changes |
| 74 | |
| 75 | - Holds an `ILevel` (starts with `WaterLevel`) |
| 76 | - `nextLevel()` / `prevLevel()` cycle through the four levels |
| 77 | - Swipe gesture (horizontal swipe on the glass) triggers level change |
| 78 | - Level name shown briefly as a fade-in/out toast overlay (no persistent UI) |
| 79 | |
| 80 | ### Level picker UI |
| 81 | |
| 82 | 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. |
| 83 | |
| 84 | --- |
| 85 | |
| 86 | ## 3. Level: Water Glass (updated) |
| 87 | |
| 88 | No new simulation. Changes from current: |
| 89 | - Sealed (top wall, no spill) |
| 90 | - Full 360° gravity |
| 91 | - Touch spawns emoji objects (same as today) |
| 92 | - QR body stays permanent |
| 93 | |
| 94 | --- |
| 95 | |
| 96 | ## 4. Level: Lava Lamp |
| 97 | |
| 98 | ### Concept |
| 99 | |
| 100 | ~10 blobs split between two "wax" types: |
| 101 | - **Hot wax** (density 0.92, warm orange-red): rises when near the bottom heat source |
| 102 | - **Cool wax** (density 1.08, deep red-purple): sinks when near the top |
| 103 | |
| 104 | Tilting moves blobs laterally. No interaction with `Water` — this level has no water. |
| 105 | |
| 106 | ### Simulation approach: soft-body blobs |
| 107 | |
| 108 | Each blob is a `Vector2` position + velocity (not a `RigidBody`). Integration: |
| 109 | |
| 110 | 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. |
| 111 | 2. **Buoyancy**: `F_buoy = (fluidDensity − blobDensity) × volume × g` |
| 112 | 3. **Blob–blob repulsion**: when blobs of *any* type overlap, push apart (incompressibility) |
| 113 | 4. **Same-type soft attraction**: gentle pull between blobs of same type within 2× radius (creates the "merging" feel without actual merge/split logic) |
| 114 | 5. **Wall collision**: elastic bounce off all four walls |
| 115 | 6. **Damping**: high fluid drag (lava is viscous) |
| 116 | |
| 117 | ### Rendering |
| 118 | |
| 119 | Metaballs via Canvas 2D: |
| 120 | - Draw each blob as an offscreen radial gradient: opaque center → transparent edge at ~1.5× radius |
| 121 | - 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. |
| 122 | - Color: lerp between hot color and cool color based on blob temperature |
| 123 | |
| 124 | ### Touch |
| 125 | |
| 126 | Tap creates a gentle gravity-perpendicular impulse in the blob nearest the touch point. |
| 127 | |
| 128 | ### Risk: medium. Metaball filter trick needs profiling on mobile — blur radius must stay small (≤8px). No TypeScript complexity. |
| 129 | |
| 130 | --- |
| 131 | |
| 132 | ## 5. Level: Sand · Water · Air |
| 133 | |
| 134 | ### Concept |
| 135 | |
| 136 | Three materials layered by density, reacting to tilt: |
| 137 | - **Sand** (~40 particles, radius 4px, density 2.5) — settles at "bottom" |
| 138 | - **Water** (SWE 1D, same as WaterLevel) — middle layer |
| 139 | - **Air bubble** (1–3 bodies, radius 20–35px, density 0.001) — floats at "top" |
| 140 | |
| 141 | ### Simulation approach |
| 142 | |
| 143 | Reuse `Water` (SWE) + `World` (RigidBody). Sand and air are both `RigidBody` instances with extreme densities. The buoyancy code in `World` already handles them correctly: |
| 144 | - Sand (density 2.5): sinks, rests on floor |
| 145 | - Air bubble (density 0.001): strong upward buoyancy, presses against ceiling (sealed top wall) |
| 146 | |
| 147 | **Sand–sand collision**: existing `resolveBodyCollision` handles it. 40 particles = 780 pair checks per frame — acceptable. |
| 148 | |
| 149 | **Sand resting**: add static friction when a sand particle's speed < threshold and it's resting on the floor or another particle. |
| 150 | |
| 151 | **Air bubble rendering**: white/translucent circle with a bright specular highlight arc (not emoji — just canvas arcs). |
| 152 | |
| 153 | **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. |
| 154 | |
| 155 | ### Touch |
| 156 | |
| 157 | Tap disturbs water surface (same `disturb()` call) and scatters nearby sand particles via an impulse. |
| 158 | |
| 159 | ### 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. |
| 160 | |
| 161 | --- |
| 162 | |
| 163 | ## 6. Level: Shampoo Bottle |
| 164 | |
| 165 | ### Concept |
| 166 | |
| 167 | A sealed container full of thick, slow-moving fluid with 3–5 trapped air bubbles that: |
| 168 | - Rise toward the "top" (opposite gravity) |
| 169 | - Merge when they touch (become one larger bubble, conservation of volume) |
| 170 | - Split when shaken hard (large velocity impulse → one bubble splits into two) |
| 171 | |
| 172 | ### Simulation approach |
| 173 | |
| 174 | **Fluid**: reuse `Water` SWE but with: |
| 175 | - High `K_DAMP` (≈ 5.0 instead of 1.5) → very slow wave propagation |
| 176 | - Lower wave speed feel: reduce effective `G_SW` by 60% (shampoo is more viscous, slower pressure propagation) |
| 177 | - Color: amber/pearlescent gradient |
| 178 | |
| 179 | **Bubbles**: `RigidBody` with density 0.001, but rendered as soft spheres (not emoji). Bubble–bubble interaction uses the same `resolveBodyCollision` but with high restitution. |
| 180 | |
| 181 | **Merge logic** (in `World` or a `ShampooLevel` post-step hook): |
| 182 | - After collision resolution, if two bubbles overlap by > 60% of the smaller radius → merge |
| 183 | - Remove both, spawn one bubble with `r_new = ∛(r₁³ + r₂³)` (volume conservation) |
| 184 | |
| 185 | **Split logic**: |
| 186 | - If a bubble's speed exceeds a threshold (e.g., 400 px/s) → split into two with radii scaled by `∛(0.5)` |
| 187 | |
| 188 | **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. |
| 189 | |
| 190 | ### Touch |
| 191 | |
| 192 | Tap near a bubble gives it a strong impulse (may trigger split). Tap on fluid disturbs the SWE surface. |
| 193 | |
| 194 | ### Risk: low-medium. Merge/split logic is the only novel code. Volume conservation via cube-root radius is straightforward. |
| 195 | |
| 196 | --- |
| 197 | |
| 198 | ## Implementation Order |
| 199 | |
| 200 | 1. ~~`TiltInput` — full 360° gravity (prerequisite for everything)~~ ✅ |
| 201 | 2. ~~Sealed container in `Water`, `World`, `GlassRenderer`~~ ✅ |
| 202 | 3. ~~`ILevel` interface + level-switching in `App`~~ ✅ |
| 203 | 4. ~~`WaterLevel` — wrap current logic~~ ✅ |
| 204 | 5. ~~`LavaLampLevel` — self-contained, no SWE dependency~~ ✅ |
| 205 | 6. ~~`ShampooLevel` — extends SWE + merge/split~~ ✅ |
| 206 | 7. ~~`SandWaterLevel` — most complex, do last~~ ✅ |
| 207 | |
| 208 | --- |
| 209 | |
| 210 | ## What stays the same |
| 211 | |
| 212 | - `Vector2`, `RigidBody`, `TiltInput` structure (only internals change) |
| 213 | - `vite.config.ts`, `package.json`, CI — no changes |
| 214 | - `main.ts` — minor: wire swipe gesture + level dots indicator |
| 215 | - `WaterRenderer`, `GlassRenderer` — reusable by `WaterLevel` and `ShampooLevel` |
| 216 | - All rendering is still Canvas 2D, one class per file, no barrel files |