Bubbles — Multi-Level Plan
Overview
Four changes are in scope:
- Sealed container + full 360° gravity — foundational, affects all levels
- Level: Water glass — current level, updated for sealed physics
- Level: Lava lamp — metaball blobs, two densities, thermal cycling
- Level: Sand · Water · Air — three-phase layered simulation
- 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
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 withWaterLevel) 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:
- 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.
- Buoyancy:
F_buoy = (fluidDensity − blobDensity) × volume × g - Blob–blob repulsion: when blobs of any type overlap, push apart (incompressibility)
- Same-type soft attraction: gentle pull between blobs of same type within 2× radius (creates the "merging" feel without actual merge/split logic)
- Wall collision: elastic bounce off all four walls
- 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
- ~~
TiltInput— full 360° gravity (prerequisite for everything)~~ ✅ - ~~Sealed container in
Water,World,GlassRenderer~~ ✅ - ~~
ILevelinterface + level-switching inApp~~ ✅ - ~~
WaterLevel— wrap current logic~~ ✅ - ~~
LavaLampLevel— self-contained, no SWE dependency~~ ✅ - ~~
ShampooLevel— extends SWE + merge/split~~ ✅ - ~~
SandWaterLevel— most complex, do last~~ ✅
What stays the same
Vector2,RigidBody,TiltInputstructure (only internals change)vite.config.ts,package.json, CI — no changesmain.ts— minor: wire swipe gesture + level dots indicatorWaterRenderer,GlassRenderer— reusable byWaterLevelandShampooLevel- All rendering is still Canvas 2D, one class per file, no barrel files