diff --git a/demos/lane-g.html b/demos/lane-g.html
new file mode 100644
index 0000000..0965234
--- /dev/null
+++ b/demos/lane-g.html
@@ -0,0 +1,26 @@
+
+
+
BLOBBO lane g demo
+
+
+
+
+ BLOBBO — Lane G: zones · puddles · MEGA/MINI
+ WASD move · Space jump
+ FAUTO hold-forward (hands-free, ≥40% by route)
+ 1teleport RED start · 2BLUE river
+ 3PURPLE/MEGA fork · 4PINK/MINI tunnel
+ Xfull clean · Ccleanse 50%
+ Rrespawn · Pstate to console · HHUD
+
+
+
+
diff --git a/src/course/zones.ts b/src/course/zones.ts
new file mode 100644
index 0000000..8d35180
--- /dev/null
+++ b/src/course/zones.ts
@@ -0,0 +1,219 @@
+/**
+ * Colour zones (Lane G) — laid ON the existing greybox (course spans z 30 → -70,
+ * see greybox.ts). Two jobs:
+ *
+ * 1. PAINT BY ROUTE. Each stretch of track gets a single-colour puddle run so a
+ * racer picks up ONE dominant colour there instead of confetti. That matters
+ * because the buff threshold is 20% of a single colour (coverage-math.ts) —
+ * three colours diluting each other never crossed it (observed live). One
+ * colour per zone makes buffs actually activate.
+ *
+ * 2. THE MEGA/MINI TEACHING FORK (machine district, z -37..-58). Paint = size =
+ * route:
+ * LEFT lane (x<0): PURPLE puddle → MEGA (grow + heavy) → trips a pressure
+ * plate → SpringBoot yeet. The course teaches paint=weight.
+ * RIGHT lane (x>3): PINK puddle → MINI (shrink) → fits a deliberately LOW
+ * tunnel (pink roof slab at y≈1.0) that shortcuts toward
+ * the finish. A normal/MEGA blob bonks the roof.
+ *
+ * Zone → colour → buff intent (GDD §6):
+ * RED ramp approach BURN speed helps the climb
+ * GREEN shelves + slope GRIP helps the slope / future gravity flips
+ * BLUE milk river SLICK waterproof — your paint survives the water
+ * PURPLE left fork MEGA heavy → pressure-plate route
+ * PINK right fork MINI small → tunnel shortcut
+ *
+ * Cannon placement decision: installZones does NOT instantiate PaintCannons. It
+ * RETURNS `cannonConfigs` (plain data) so integration builds them through the one
+ * cannon-staging path it already owns in game.ts (target=blob.mesh, muzzle tuning,
+ * etc.), avoiding double-placement. Puddles are the guaranteed paint; cannons are
+ * bonus pressure, so ignoring the configs still satisfies the ≥40% route rule.
+ */
+import * as THREE from 'three'
+import type { Blob, PaintColor, World } from '../contracts'
+import { PALETTE } from '../contracts'
+import type { PaintSkin } from '../paint/skin'
+import { installPuddles, type PaintPuddle, type PaintPuddleConfig } from '../paint/puddles'
+import { createPressurePlate, createSpringBoot, type Vec3 } from '../machine'
+
+/** A cannon integration should stage for a zone (fires that zone's colour). */
+export interface ZoneCannonConfig {
+ color: PaintColor
+ position: Vec3
+ /** Fires on `machine:signal {id}` matching this (Lane C wiring). */
+ triggerId: string
+ /** Suggested seconds between auto-fires. */
+ interval: number
+}
+
+export interface TunnelInfo {
+ /** World Y of the roof's underside — only blobs shorter than this fit. */
+ roofUndersideY: number
+ center: Vec3
+ /** Roof slab half-extents [x, y, z]. */
+ half: Vec3
+}
+
+export interface ZonesHandle {
+ puddles: PaintPuddle[]
+ cannonConfigs: ZoneCannonConfig[]
+ /** Left-fork pressure plate: trips at baseMass*1.25 (needs a MEGA blob). */
+ plateId: string
+ plateThreshold: number
+ bootSignal: string
+ tunnel: TunnelInfo
+ totalStamps(): number
+}
+
+// ---------------------------------------------------------------------------
+// Puddle layout — absolute course coordinates (see greybox.ts surface heights).
+// base floor top y≈0 · shelves top y≈3.5 · river top y≈0.12 · machine top y≈0.24
+// Centre-line strips (x≈0) coat a hold-forward run; fork strips sit off-centre.
+// ---------------------------------------------------------------------------
+const PUDDLE_LAYOUT: PaintPuddleConfig[] = [
+ // RED zone (ramp approach, z≈22..8) — wide centre strip off the plateau + onto
+ // the ramp base. Generous so BURN reliably activates before the climb.
+ { color: 'red', position: [0, 0.05, 18.5], size: [9, 9], splatRadius: 0.34 },
+ { color: 'red', position: [0, 1.1, 12], size: [8, 6], splatRadius: 0.34 },
+
+ // GREEN zone (shelves + slope, z≈8..-24) — both blue shelves are flat & always
+ // rolled, so GRIP activates there; a strip on the slope tops it up.
+ { color: 'green', position: [0, 3.55, 6], size: [11, 7], splatRadius: 0.34 },
+ { color: 'green', position: [0, 3.55, -6], size: [11, 7], splatRadius: 0.34 },
+ { color: 'green', position: [0, 1.5, -15.5], size: [9, 6], splatRadius: 0.34 },
+
+ // BLUE zone (milk river, z≈-24..-37) — long centre strip across the crossing so
+ // SLICK/waterproof is up before you hit the water.
+ { color: 'blue', position: [0, 0.15, -30], size: [11, 13], splatRadius: 0.36 },
+
+ // PURPLE/PINK fork (machine district, z≈-37..-58) on the machine-area floor.
+ // Strips sit at the district ENTRANCE so the blob is fully MEGA/MINI BEFORE it
+ // reaches the plate / tunnel further in.
+ { color: 'purple', position: [-6, 0.27, -40], size: [6, 7], splatRadius: 0.34 },
+ { color: 'pink', position: [6.5, 0.27, -40], size: [6, 7], splatRadius: 0.34 },
+]
+
+// Fork geometry constants (obstacles sit PAST the entrance strips).
+const PLATE_POS: Vec3 = [-6, 0.24, -47]
+const BOOT_POS: Vec3 = [-6, 0.2, -49.5]
+const PLATE_ID = 'zone-plate-purple'
+const BOOT_SIGNAL = 'zone-boot-purple'
+
+// Roof underside = 1.27 - 0.15 = 1.12. On the machine floor (top y≈0.24) a blob
+// rests with its belly down, so its top ≈ 0.24 + 2·(r·size):
+// normal (size 1.0) → top ≈ 1.24 → BONK
+// ≥35% pink (≤0.846) → top ≤ 1.086 → CLEARS
+// full MINI (0.62) → top ≈ 0.86 → easy clearance
+const TUNNEL_CENTER: Vec3 = [6.5, 1.27, -51]
+const TUNNEL_HALF: Vec3 = [3.5, 0.15, 7] // spans z -58..-44
+
+/**
+ * Build the colour zones + the MEGA/MINI fork onto the live greybox and wire the
+ * puddle paint system. Integration calls this once, after the blob/skin exist.
+ */
+export function installZones(world: World, blob: Blob, skin: PaintSkin): ZonesHandle {
+ const { scene, physics, rapier } = world
+
+ // ---- puddles (guaranteed paint by route) ----
+ const puddlesHandle = installPuddles(world, blob, skin, PUDDLE_LAYOUT)
+
+ // ---- LEFT fork: PURPLE → MEGA → pressure plate → boot yeet ----
+ // Clean blob mass is ~1; a MEGA (purple) blob is heavy enough to cross *1.25.
+ const baseMass = blob.body.mass()
+ const plateThreshold = baseMass * 1.25
+ createPressurePlate(world, {
+ id: PLATE_ID,
+ position: PLATE_POS,
+ massThreshold: plateThreshold,
+ emits: BOOT_SIGNAL,
+ size: [4, 4],
+ })
+ createSpringBoot(world, {
+ id: 'zone-boot',
+ position: BOOT_POS,
+ impulse: baseMass * 20, // sized for a heavy MEGA blob to carry toward the finish
+ direction: [0, 1, -0.7], // up + forward (-Z)
+ onSignal: BOOT_SIGNAL,
+ strikeSize: [2.6, 1.4, 3],
+ })
+
+ // ---- RIGHT fork: PINK → MINI → LOW TUNNEL shortcut ----
+ buildTunnel(scene, physics, rapier)
+
+ // ---- cannons (returned as data for integration to stage) ----
+ const cannonConfigs: ZoneCannonConfig[] = [
+ // RED crossfire: the existing red position + a mirror across the lane.
+ { color: 'red', position: [-9, 2.5, 14], triggerId: 'cannon-red', interval: 2.2 },
+ { color: 'red', position: [9, 2.5, 14], triggerId: 'cannon-red-2', interval: 2.2 },
+ // GREEN over the shelves.
+ { color: 'green', position: [9, 4.8, -6], triggerId: 'cannon-green', interval: 2.2 },
+ // BLUE over the river.
+ { color: 'blue', position: [-9, 2.0, -30], triggerId: 'cannon-blue', interval: 2.4 },
+ // Fork colours fire on their own lanes.
+ { color: 'purple', position: [-11, 2.0, -43], triggerId: 'cannon-purple', interval: 2.6 },
+ { color: 'pink', position: [12, 2.0, -43], triggerId: 'cannon-pink', interval: 2.6 },
+ ]
+
+ return {
+ puddles: puddlesHandle.puddles,
+ cannonConfigs,
+ plateId: PLATE_ID,
+ plateThreshold,
+ bootSignal: BOOT_SIGNAL,
+ tunnel: { roofUndersideY: TUNNEL_CENTER[1] - TUNNEL_HALF[1], center: TUNNEL_CENTER, half: TUNNEL_HALF },
+ totalStamps: puddlesHandle.totalStamps,
+ }
+}
+
+/**
+ * The pink low tunnel: a visibly-low pink roof slab (collider) over the right
+ * lane, on stubby posts. Only a MINI blob clears its underside; taller blobs bonk
+ * and must take another lane. Posts are cosmetic (no lane-blocking colliders).
+ */
+function buildTunnel(
+ scene: THREE.Scene,
+ physics: World['physics'],
+ rapier: World['rapier'],
+): void {
+ const [cx, cy, cz] = TUNNEL_CENTER
+ const [hx, hy, hz] = TUNNEL_HALF
+
+ const roofMat = new THREE.MeshStandardMaterial({
+ color: PALETTE.pink,
+ roughness: 0.4,
+ metalness: 0.1,
+ transparent: true,
+ opacity: 0.82,
+ emissive: PALETTE.pink,
+ emissiveIntensity: 0.18,
+ })
+ const roof = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), roofMat)
+ roof.position.set(cx, cy, cz)
+ roof.castShadow = true
+ roof.receiveShadow = true
+ scene.add(roof)
+ // Solid roof collider — this is what bonks a too-tall blob.
+ physics.createCollider(
+ rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz),
+ )
+
+ // "MIND YOUR HEAD" clearance bar hanging just under the lip so the low ceiling
+ // reads before you reach it (visual only).
+ const barMat = new THREE.MeshStandardMaterial({
+ color: PALETTE.pink, emissive: PALETTE.pink, emissiveIntensity: 0.4, roughness: 0.5,
+ })
+ const bar = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, 0.06, 0.12), barMat)
+ bar.position.set(cx, cy - hy - 0.02, cz + hz) // entrance lip
+ scene.add(bar)
+
+ // cosmetic corner posts (no colliders — they don't block the lane)
+ const postMat = new THREE.MeshStandardMaterial({ color: '#c94f86', roughness: 0.6 })
+ for (const sx of [-1, 1]) {
+ for (const sz of [-1, 1]) {
+ const post = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, cy, 10), postMat)
+ post.position.set(cx + sx * hx, cy * 0.5, cz + sz * hz)
+ post.castShadow = true
+ scene.add(post)
+ }
+ }
+}
diff --git a/src/demo/lane-g.ts b/src/demo/lane-g.ts
new file mode 100644
index 0000000..cc334e6
--- /dev/null
+++ b/src/demo/lane-g.ts
@@ -0,0 +1,203 @@
+/**
+ * Lane G demo — colour zones, paint puddles, and the MEGA/MINI scale fork.
+ *
+ * Builds the REAL greybox + blob + controller, then lays Lane G on top via
+ * installZones (puddles + purple/pink fork + low tunnel). Drive through and watch
+ * coverage climb by ROUTE — no aiming, no cannon luck. The HUD reads live coverage
+ * / modifiers, whether the MEGA pressure plate has tripped, and your size so you
+ * can see MEGA grow and MINI shrink.
+ *
+ * The three things this demo proves (acceptance):
+ * 1. A hold-forward run (press F) ends ≥40% painted with a buff active — puddles
+ * guarantee it. The console STATE line reports total% + active buffs.
+ * 2. PURPLE puddle → MEGA (grow + heavy) → trips the left pressure plate → boot
+ * yeet. A clean blob rolls over the plate without tripping it.
+ * 3. PINK puddle → MINI (shrink) → fits the low pink tunnel on the right. A
+ * normal/clean blob bonks the roof and can't pass.
+ *
+ * CONTROLS
+ * WASD / Arrows move (camera-relative) Space jump
+ * DEBUG KEYS
+ * F toggle AUTO hold-forward (hands-free straight run through the zones)
+ * 1 teleport to RED start 2 teleport to the BLUE river
+ * 3 teleport to the PURPLE (left/MEGA) fork mouth
+ * 4 teleport to the PINK (right/MINI) tunnel mouth
+ * X full cleanse (go clean — then try the tunnel/plate to see the difference)
+ * C cleanse 50%
+ * R respawn at start (also cleanses)
+ * P print a STATE snapshot to the console
+ * H toggle the HUD
+ * A STATE line prints once/second: pos, coverage %s, size/mass, active buffs, plate.
+ */
+import * as THREE from 'three'
+import { createWorld } from '../world'
+import { buildGreybox } from '../course/greybox'
+import { createBlob } from '../blob/createBlob'
+import { createBlobController } from '../blob/controller'
+import { createBlobFeel } from '../blob/feel'
+import { createFollowCamera } from '../blob/camera'
+import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint, COLOR_ORDER } from '../paint'
+import { installZones } from '../course/zones'
+
+const world = await createWorld(document.getElementById('app')!)
+const course = buildGreybox(world)
+
+// ---- blob + Lane A layers (controller/feel/camera) ----
+const blob = createBlob(world, { position: course.spawn })
+blob.paint = new PaintSkin(blob.mesh, { size: 256 })
+world.blob = blob
+const skin = blob.paint as PaintSkin
+
+world.addSystem(createBlobController(world, blob))
+const feel = createBlobFeel(blob)
+world.events.on('blob:jumped', () => feel.onJump())
+world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact))
+world.onFrame((dt) => feel.update(dt))
+const camera = createFollowCamera(world, blob)
+world.onFrame((dt) => camera.update(dt))
+
+// ---- buffs + inbound paint protocol ----
+world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
+installPaint(world, { mesh: blob.mesh, paint: skin })
+
+// ---- LANE G: zones (puddles) + purple/pink fork + low tunnel ----
+const zones = installZones(world, blob, skin)
+
+// ---- stage the returned cannon configs as bonus pressure (integration's job in
+// the real game; done here so the demo shows cannons + puddles together) ----
+for (const c of zones.cannonConfigs) {
+ world.addSystem(new PaintCannon({
+ world,
+ position: new THREE.Vector3(...c.position),
+ color: c.color,
+ target: blob.mesh,
+ paint: skin,
+ targetRadius: blob.radius,
+ triggerId: c.triggerId,
+ interval: c.interval,
+ muzzleSpeed: 26,
+ splatRadius: 0.45,
+ }))
+}
+
+// ---- demo-only gap trampoline (stands in for game.ts's real one) so an AUTO
+// hold-forward run can clear the shelf gap instead of soft-locking in the slot ----
+{
+ world.physics.createCollider(
+ world.rapier.ColliderDesc.cuboid(6, 0.25, 2).setTranslation(0, 0.25, 0))
+ const tramp = new THREE.Mesh(
+ new THREE.BoxGeometry(12, 0.5, 4),
+ new THREE.MeshStandardMaterial({ color: '#FF9500', roughness: 0.6 }))
+ tramp.position.set(0, 0.25, 0)
+ world.scene.add(tramp)
+ let cd = 0
+ world.addSystem({
+ update(dt) {
+ cd = Math.max(0, cd - dt)
+ const t = blob.body.translation()
+ const v = blob.body.linvel()
+ if (Math.abs(t.x) < 6 && t.z > -2.2 && t.z < 2.2 && t.y < 1.4 && v.y < 0.5 && cd === 0) {
+ blob.body.setLinvel({ x: v.x * 0.4, y: 14, z: Math.max(-3, Math.min(3, (0.5 - t.z) * 1.5)) }, true)
+ cd = 0.6
+ world.events.emit('blob:jumped', {})
+ }
+ },
+ })
+}
+
+// ---- MEGA plate telemetry: did purple weight trip it? ----
+let plateTripped = false
+world.events.on('machine:signal', (p: { id: string }) => {
+ if (p.id === zones.bootSignal) {
+ plateTripped = true
+ console.log('%c[zone] PRESSURE PLATE TRIPPED — MEGA is heavy enough. BOOT YEET!', 'color:#AF52DE;font-weight:700')
+ }
+})
+
+// ---- AUTO hold-forward (dispatch a held KeyW so the real controller drives) ----
+let auto = false
+function setAuto(on: boolean) {
+ auto = on
+ const type = on ? 'keydown' : 'keyup'
+ dispatchEvent(new KeyboardEvent(type, { code: 'KeyW' }))
+ console.log(`[demo] AUTO hold-forward ${on ? 'ON' : 'OFF'}`)
+}
+// re-assert the held key each second (some browsers coalesce synthetic repeats)
+world.onFrame(() => { if (auto) dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW' })) })
+
+function respawn() {
+ blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true)
+ blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
+ skin.cleanse(1)
+ plateTripped = false
+}
+function teleport(x: number, y: number, z: number, label: string) {
+ blob.body.setTranslation({ x, y, z }, true)
+ blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
+ console.log(`[demo] teleport → ${label}`)
+}
+world.onFrame(() => { if (blob.body.translation().y < -25) respawn() })
+
+// ---- HUD (declared before the key handler that toggles it) ----
+const hud = new PaintHUD()
+
+addEventListener('keydown', (e) => {
+ switch (e.code) {
+ case 'KeyF': setAuto(!auto); break
+ case 'Digit1': teleport(0, 2.5, 20, 'RED start'); break
+ case 'Digit2': teleport(0, 1.0, -26, 'BLUE river'); break
+ case 'Digit3': teleport(-6, 1.0, -37, 'PURPLE / MEGA fork mouth'); break
+ case 'Digit4': teleport(6.5, 0.8, -37, 'PINK / MINI tunnel mouth'); break
+ case 'KeyX': skin.cleanse(1); console.log('[demo] full cleanse — blob is CLEAN'); break
+ case 'KeyC': skin.cleanse(0.5); console.log('[demo] cleanse 50%'); break
+ case 'KeyR': respawn(); break
+ case 'KeyP': console.log('[STATE]', JSON.stringify(snapshot())); break
+ case 'KeyH': hud.root.style.display = hud.root.style.display === 'none' ? '' : 'none'; break
+ default: return
+ }
+})
+
+// ---- telemetry -------------------------------------------------------------
+function snapshot() {
+ const cov = skin.coverage()
+ const t = blob.body.translation()
+ const m = blob.modifiers
+ return {
+ pos: [+t.x.toFixed(1), +t.y.toFixed(1), +t.z.toFixed(1)],
+ total: +(cov.total * 100).toFixed(0),
+ colours: COLOR_ORDER.filter((c) => cov.byColor[c] > 0.005).map((c) => `${c}:${(cov.byColor[c] * 100).toFixed(0)}%`),
+ size: +m.size.toFixed(2),
+ mass: +m.massMul.toFixed(2),
+ stamps: zones.totalStamps(),
+ plateThreshold: +zones.plateThreshold.toFixed(2),
+ plateTripped,
+ tunnelUndersideY: zones.tunnel.roofUndersideY,
+ }
+}
+
+let acc = 0
+world.onFrame((dt) => {
+ const cov = skin.coverage()
+ hud.update(cov, blob.modifiers)
+ acc += dt
+ if (acc >= 1) {
+ acc = 0
+ const s = snapshot()
+ const m = blob.modifiers
+ const buffs = [
+ m.speedMul > 1.001 ? `speed×${m.speedMul.toFixed(2)}` : '',
+ m.grip > 0.001 ? `grip${m.grip.toFixed(2)}` : '',
+ m.waterproof ? 'waterproof' : '',
+ m.size > 1.05 ? `MEGA×${m.size.toFixed(2)}` : m.size < 0.95 ? `MINI×${m.size.toFixed(2)}` : '',
+ m.glow > 0 ? 'SUPER' : '',
+ ].filter(Boolean).join(' ') || '(none)'
+ console.log(
+ `[STATE] pos=(${s.pos.join(',')}) total=${s.total}% ${s.colours.join(' ') || '(clean)'} | ` +
+ `size=${s.size} mass=${s.mass} | buffs: ${buffs} | plate ${plateTripped ? 'TRIPPED' : 'armed'} | stamps=${s.stamps}`,
+ )
+ }
+})
+
+world.start()
+console.log('%cBLOBBO Lane G — colour zones · puddles · MEGA/MINI', 'color:#AF52DE;font-weight:700')
+console.log('WASD move · Space jump · F auto-forward · 1-4 teleport zones · X clean · C cleanse50 · R respawn · P state · H hud')
diff --git a/src/paint/buffs.ts b/src/paint/buffs.ts
index 1b18cad..abcd920 100644
--- a/src/paint/buffs.ts
+++ b/src/paint/buffs.ts
@@ -76,7 +76,10 @@ export class BuffSystem implements System {
const cov = paint.coverage() // 250ms-cached; no per-frame readback
const mods = computeModifiers(cov)
- // copy into the shared modifiers object (readers hold this reference)
+ // Copy into the shared modifiers object (readers hold this reference).
+ // NOTE: `size` + `massMul` now also carry the purple MEGA / pink MINI scale
+ // buffs (Lane G) straight out of computeModifiers — the controller reads
+ // `size` to scale the collider + visuals, so no extra work is needed here.
const m = this.blob.modifiers
m.speedMul = mods.speedMul
m.jumpMul = mods.jumpMul
diff --git a/src/paint/coverage-math.test.ts b/src/paint/coverage-math.test.ts
index 1712140..ac89e12 100644
--- a/src/paint/coverage-math.test.ts
+++ b/src/paint/coverage-math.test.ts
@@ -9,6 +9,11 @@ import type { CoverageReport, PaintColor } from '../contracts'
import {
ACTIVATE,
SUPER,
+ MEGA_SIZE,
+ MINI_SIZE,
+ MEGA_MASS,
+ MINI_MASS,
+ MASS_FLOOR,
buffStrength,
computeModifiers,
countBuckets,
@@ -109,9 +114,65 @@ near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint
ok(m.waterproof === true, 'blue 25% waterproof')
}
{
- // massMul clamps at 1.8 when fully painted
+ // massMul clamps at 1.8 when fully painted (no purple/pink → base only)
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
near(m.massMul, 1.8, 'full coverage massMul 1.8')
}
+// ---- purple MEGA (grow + heavy) ------------------------------------------
+{
+ // below 20% purple → no size change, no extra mass
+ const m = computeModifiers(cov({ purple: 0.19 }))
+ near(m.size, 1, 'purple 19% → size unchanged')
+ near(m.massMul, 1 + 0.8 * 0.19, 'purple 19% → base mass only')
+}
+{
+ // purple super (70%) → size maxed to 1.45, MEGA mass added, glow
+ const m = computeModifiers(cov({ purple: 0.7 }))
+ near(m.size, 1 + MEGA_SIZE, 'purple 70% → size 1.45')
+ near(m.massMul, 1 + 0.8 * 0.7 + MEGA_MASS, 'purple 70% → base + full MEGA mass')
+ near(m.glow, 1, 'purple 70% is super (glow)')
+ ok(m.massMul > 1.25, 'MEGA is heavy enough to trip a *1.25 plate')
+}
+{
+ // purple mid (45%) → partial grow via buffStrength
+ const s = 0.15 + 0.85 * 0.5
+ const m = computeModifiers(cov({ purple: 0.45 }))
+ near(m.size, 1 + MEGA_SIZE * s, 'purple 45% → partial grow')
+}
+
+// ---- pink MINI (shrink + light) ------------------------------------------
+{
+ // below 20% pink → no size change
+ const m = computeModifiers(cov({ pink: 0.19 }))
+ near(m.size, 1, 'pink 19% → size unchanged')
+}
+{
+ // pink super (70%) → size shrunk to 0.62, lighter than clean
+ const m = computeModifiers(cov({ pink: 0.7 }))
+ near(m.size, 1 - MINI_SIZE, 'pink 70% → size 0.62')
+ ok(m.size < 1, 'pink shrinks the blob')
+ near(m.massMul, 1 + 0.8 * 0.7 - MINI_MASS, 'pink 70% → base mass minus full MINI reduction')
+ ok(m.massMul < 1, 'MINI is lighter than a clean blob at high %')
+}
+{
+ // pink can never drive mass to/below zero (MASS_FLOOR guards it)
+ const m = computeModifiers(cov({ pink: 1 }))
+ ok(m.massMul >= MASS_FLOOR, 'pink mass floored at MASS_FLOOR')
+}
+
+// ---- purple vs pink fight -------------------------------------------------
+{
+ // equal purple + pink strength cancel → net normal size
+ const m = computeModifiers(cov({ purple: 0.5, pink: 0.5 }))
+ near(m.size, 1, 'equal purple+pink → size nets to 1 (they fight)')
+}
+{
+ // more purple than pink → still grows, but less than pure purple
+ const strong = computeModifiers(cov({ purple: 0.6 }))
+ const fought = computeModifiers(cov({ purple: 0.6, pink: 0.3 }))
+ ok(fought.size > 1, 'purple-dominant mix still grows')
+ ok(fought.size < strong.size, 'pink drags the grow down')
+}
+
console.log(`coverage-math.test: ${passed} assertions passed ✓`)
diff --git a/src/paint/coverage-math.ts b/src/paint/coverage-math.ts
index cbb8567..21ad760 100644
--- a/src/paint/coverage-math.ts
+++ b/src/paint/coverage-math.ts
@@ -54,6 +54,21 @@ export const SUPER = 0.70 // ≥70% one colour = super state
/** Strength floor at the activation threshold so the buff visibly "kicks in". */
const ACTIVATE_FLOOR = 0.15
+// ---- Scale buffs (GDD §5.3 / §6): purple MEGA + pink MINI (Lane G) ---------
+// Both drive `modifiers.size` (the controller scales collider + visuals) and
+// bias `massMul` on top of the paint=weight base. They oppose each other: a blob
+// carrying both purple and pink nets out toward normal size (they "fight").
+/** Uniform scale added at full purple strength: 1.0 → 1.45 (grow, blocks/plates). */
+export const MEGA_SIZE = 0.45
+/** Uniform scale removed at full pink strength: 1.0 → 0.62 (shrink, tunnels). */
+export const MINI_SIZE = 0.38
+/** Extra massMul at full purple strength — MEGA is heavy beyond its paint total. */
+export const MEGA_MASS = 1.5
+/** massMul removed at full pink strength — MINI is lighter than clean at high %. */
+export const MINI_MASS = 0.9
+/** Lower bound so a fully-MINI blob stays positively massed (flingable, not zero). */
+export const MASS_FLOOR = 0.35
+
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
/**
@@ -95,11 +110,13 @@ export function dominantColor(cov: CoverageReport): PaintColor | null {
}
/**
- * The core mapping: coverage -> modifiers (GDD §6 MVP subset).
- * RED -> speedMul up to 1.6 (BURN)
- * GREEN -> grip up to 1.0 (GRIP)
- * BLUE -> waterproof (SLICK)
- * total coverage -> massMul 1.0..1.8 (paint = weight, §5.2)
+ * The core mapping: coverage -> modifiers (GDD §6).
+ * RED -> speedMul up to 1.6 (BURN)
+ * GREEN -> grip up to 1.0 (GRIP)
+ * BLUE -> waterproof (SLICK)
+ * PURPLE -> size up to 1.45 + extra mass (MEGA — grow big & heavy, §5.3)
+ * PINK -> size down to 0.62 + less mass (MINI — shrink small & light)
+ * total coverage -> massMul base 1.0..1.8 (paint = weight, §5.2)
* any colour ≥70% -> super: that buff pinned at max + glow
* Pure and deterministic; the visual side-effects (ember trail, emissive pulse)
* live in BuffSystem and read `glow` / dominant colour off this result + cov.
@@ -108,11 +125,24 @@ export function computeModifiers(cov: CoverageReport): BlobModifiers {
const red = cov.byColor.red
const green = cov.byColor.green
const blue = cov.byColor.blue
+ const purpleS = buffStrength(cov.byColor.purple)
+ const pinkS = buffStrength(cov.byColor.pink)
const speedMul = 1 + 0.6 * buffStrength(red)
const grip = buffStrength(green) // 0..1
const waterproof = blue >= ACTIVATE
- const massMul = 1 + 0.8 * clamp01(cov.total)
+
+ // ---- scale (MEGA vs MINI) ----
+ // net grow/shrink strength; purple and pink oppose so a mixed blob nets out.
+ const net = purpleS - pinkS
+ const size = net >= 0 ? 1 + MEGA_SIZE * net : 1 + MINI_SIZE * net
+
+ // ---- mass (paint=weight base, biased by MEGA heavier / MINI lighter) ----
+ const massMul = Math.max(
+ MASS_FLOOR,
+ 1 + 0.8 * clamp01(cov.total) + MEGA_MASS * purpleS - MINI_MASS * pinkS,
+ )
+
const glow = superColor(cov) ? 1 : 0
return {
@@ -120,7 +150,7 @@ export function computeModifiers(cov: CoverageReport): BlobModifiers {
jumpMul: 1,
massMul,
grip,
- size: 1, // purple/pink grow-shrink is V1 (§5.3) — untouched in MVP
+ size,
waterproof,
glow,
}
diff --git a/src/paint/puddles.ts b/src/paint/puddles.ts
new file mode 100644
index 0000000..d62d54c
--- /dev/null
+++ b/src/paint/puddles.ts
@@ -0,0 +1,196 @@
+/**
+ * PaintPuddle (Lane G) — a flat coloured strip laid ON the track. Its job is to
+ * make paint ROUTE-guaranteed instead of aim-guaranteed: while the blob's ground
+ * contact is inside the strip, we stamp its UNDERSIDE on a steady cadence, so
+ * simply rolling through a puddle coats your belly. Ballistic cannons (Lane B)
+ * become bonus pressure on top; a hold-forward racer can no longer finish clean.
+ *
+ * A puddle is purely a paint source: a decorative mesh (NO collider — you roll
+ * straight over it) plus a footprint trigger. Each stamp goes through the SAME
+ * `skin.splatAtPoint` the cannons use and emits the frozen `paint:splatted`
+ * {color, target:'blob'} event (audio/VFX/netcode hook it downstream).
+ *
+ * Belly-coating detail: the blob's physics body doesn't tumble (rotations are
+ * locked — the comedy roll is visual only), so naively stamping straight down
+ * would pile every stamp onto the south pole. Instead we fake the roll: an
+ * accumulated roll phase (distance / radius) walks the contact point along the
+ * travel great circle, so crossing a strip paints a proper stripe around the
+ * body and coverage actually climbs.
+ */
+import * as THREE from 'three'
+import type { Blob, PaintColor, World } from '../contracts'
+import { PALETTE } from '../contracts'
+import type { PaintSkin } from './skin'
+
+export interface PaintPuddleConfig {
+ /** Strip centre in world space; y is the track surface the strip lies on. */
+ position: [number, number, number]
+ /** Footprint [width(x), length(z)] in world units. */
+ size: [number, number]
+ color: PaintColor
+ /** Underside stamps per second while rolling through. Default 6. */
+ rate?: number
+ /** World-space splat radius per stamp. Default 0.3. */
+ splatRadius?: number
+}
+
+const DEFAULT_RATE = 6
+const DEFAULT_SPLAT_RADIUS = 0.3
+/** How close the belly must be to the strip plane to count as "rolling on it". */
+const CONTACT_TOLERANCE = 0.6
+
+export class PaintPuddle {
+ readonly color: PaintColor
+ readonly mesh: THREE.Mesh
+ /** Total underside stamps this puddle has laid (demo/telemetry). */
+ stamps = 0
+
+ private readonly px: number
+ private readonly pz: number
+ private readonly py: number
+ private readonly hx: number
+ private readonly hz: number
+ private readonly period: number
+ private readonly splatRadius: number
+ private acc = 0
+
+ constructor(world: World, cfg: PaintPuddleConfig) {
+ this.color = cfg.color
+ const [x, y, z] = cfg.position
+ const [w, l] = cfg.size
+ this.px = x
+ this.py = y
+ this.pz = z
+ this.hx = w * 0.5
+ this.hz = l * 0.5
+ this.period = 1 / (cfg.rate ?? DEFAULT_RATE)
+ this.splatRadius = cfg.splatRadius ?? DEFAULT_SPLAT_RADIUS
+
+ // Decorative wet-paint slab — thin, flat, no collider (you roll over it).
+ const mat = new THREE.MeshStandardMaterial({
+ color: PALETTE[cfg.color],
+ roughness: 0.25,
+ metalness: 0.0,
+ transparent: true,
+ opacity: 0.9,
+ emissive: PALETTE[cfg.color],
+ emissiveIntensity: 0.12,
+ })
+ this.mesh = new THREE.Mesh(new THREE.BoxGeometry(w, 0.05, l), mat)
+ this.mesh.position.set(x, y + 0.03, z)
+ this.mesh.receiveShadow = true
+ world.scene.add(this.mesh)
+ }
+
+ /** True while the blob's belly is resting inside this strip's footprint. */
+ private covers(cx: number, cz: number, bellyY: number): boolean {
+ return (
+ cx >= this.px - this.hx && cx <= this.px + this.hx &&
+ cz >= this.pz - this.hz && cz <= this.pz + this.hz &&
+ Math.abs(bellyY - this.py) <= CONTACT_TOLERANCE
+ )
+ }
+
+ /**
+ * Advance one fixed step. `stampPoint(out, phase)` writes the world contact
+ * point for the current roll phase (shared across all puddles so the fake
+ * roll is continuous). Returns the number of stamps laid this step.
+ */
+ step(
+ dt: number,
+ cx: number,
+ cz: number,
+ bellyY: number,
+ phase: number,
+ stampPoint: (out: THREE.Vector3, phase: number) => void,
+ skin: PaintSkin,
+ events: World['events'],
+ scratch: THREE.Vector3,
+ ): number {
+ if (!this.covers(cx, cz, bellyY)) {
+ this.acc = 0
+ return 0
+ }
+ this.acc += dt
+ let laid = 0
+ while (this.acc >= this.period) {
+ this.acc -= this.period
+ stampPoint(scratch, phase)
+ skin.splatAtPoint(scratch, this.color, this.splatRadius)
+ events.emit('paint:splatted', { color: this.color, target: 'blob' })
+ this.stamps++
+ laid++
+ }
+ return laid
+ }
+}
+
+export interface PuddlesHandle {
+ puddles: PaintPuddle[]
+ /** Total stamps laid across all puddles (demo/telemetry). */
+ totalStamps(): number
+}
+
+/**
+ * Build the puddles from `configs` and register the single fixed-step system
+ * that coats the blob's belly while it rolls through any of them. Integration
+ * (and zones.ts) call this; it owns no course geometry beyond the strips.
+ */
+export function installPuddles(
+ world: World,
+ blob: Blob,
+ skin: PaintSkin,
+ configs: PaintPuddleConfig[],
+): PuddlesHandle {
+ const puddles = configs.map((c) => new PaintPuddle(world, c))
+
+ // Continuous fake-roll state (see class docstring).
+ const prev = new THREE.Vector3()
+ let hasPrev = false
+ let phase = 0
+ const dir = new THREE.Vector3(0, 0, -1) // course runs toward -Z; sane default
+ const axis = new THREE.Vector3()
+ const down = new THREE.Vector3(0, -1, 0)
+ const center = new THREE.Vector3()
+ const scratch = new THREE.Vector3()
+ const rotated = new THREE.Vector3()
+
+ world.addSystem({
+ update(dt: number): void {
+ const t = blob.body.translation()
+ center.set(t.x, t.y, t.z)
+ const r = skin.boundingRadius * Math.max(0.01, blob.modifiers.size)
+ const bellyY = t.y - r
+
+ // advance the roll phase from horizontal displacement (roll w/o slipping)
+ if (hasPrev) {
+ const dx = t.x - prev.x
+ const dz = t.z - prev.z
+ const moved = Math.hypot(dx, dz)
+ if (moved > 1e-5) {
+ dir.set(dx / moved, 0, dz / moved)
+ phase += moved / r
+ }
+ }
+ prev.set(t.x, t.y, t.z)
+ hasPrev = true
+
+ // horizontal axis perpendicular to travel; contact point rides the great
+ // circle in the travel-vertical plane as `phase` grows.
+ axis.set(-dir.z, 0, dir.x)
+ const stampPoint = (out: THREE.Vector3, ph: number) => {
+ rotated.copy(down).applyAxisAngle(axis, ph)
+ out.copy(center).addScaledVector(rotated, r)
+ }
+
+ for (const p of puddles) {
+ p.step(dt, t.x, t.z, bellyY, phase, stampPoint, skin, world.events, scratch)
+ }
+ },
+ })
+
+ return {
+ puddles,
+ totalStamps: () => puddles.reduce((s, p) => s + p.stamps, 0),
+ }
+}