fuzzyworld

bubbles / files / PLAN.md

PLAN.md

8.9 KB · history · blame

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

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

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

Source
1# Bubbles — Multi-Level Plan
2
3## Overview
4
5Four changes are in scope:
6
71. **Sealed container + full 360° gravity** — foundational, affects all levels
82. **Level: Water glass** — current level, updated for sealed physics
93. **Level: Lava lamp** — metaball blobs, two densities, thermal cycling
104. **Level: Sand · Water · Air** — three-phase layered simulation
115. **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°.
20Full rotation means also using `beta` (front/back) to detect upside-down orientation.
21Output stays `gravity: Vector2` but now truly reflects 3D device orientation projected onto the screen plane:
22
23```
24gx = sin(gamma) * cos(beta) * 980
25gy = sin(beta) * 980 (negative = phone tilted face-down = gravity pulls "up")
26```
27
28When 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```
47src/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
58interface 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
82Single 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
88No 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
104Tilting moves blobs laterally. No interaction with `Water` — this level has no water.
105
106### Simulation approach: soft-body blobs
107
108Each blob is a `Vector2` position + velocity (not a `RigidBody`). Integration:
109
1101. **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.
1112. **Buoyancy**: `F_buoy = (fluidDensity − blobDensity) × volume × g`
1123. **Blob–blob repulsion**: when blobs of *any* type overlap, push apart (incompressibility)
1134. **Same-type soft attraction**: gentle pull between blobs of same type within 2× radius (creates the "merging" feel without actual merge/split logic)
1145. **Wall collision**: elastic bounce off all four walls
1156. **Damping**: high fluid drag (lava is viscous)
116
117### Rendering
118
119Metaballs 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
126Tap 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
136Three 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
143Reuse `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
157Tap 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
167A 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
192Tap 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
2001. ~~`TiltInput` — full 360° gravity (prerequisite for everything)~~ ✅
2012. ~~Sealed container in `Water`, `World`, `GlassRenderer`~~ ✅
2023. ~~`ILevel` interface + level-switching in `App`~~ ✅
2034. ~~`WaterLevel` — wrap current logic~~ ✅
2045. ~~`LavaLampLevel` — self-contained, no SWE dependency~~ ✅
2056. ~~`ShampooLevel` — extends SWE + merge/split~~ ✅
2067. ~~`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