fuzzyworld

nuts / commit

lots of small changes

98ba5504e07cbfeda4fb49882991b62058adfdd9 · Pavel Safronov · 2026-04-12 17:44:11 -0700 · parents 74d506c663c4

lots of small changes
 index.html           |  10 +++
 src/debug.ts         |   5 ++
 src/main.ts          | 108 ++++++++++++++++++++++++---
 src/persistence.ts   |   3 +
 src/physics.ts       | 137 ++++++++++++++--------------------
 src/renderer.ts      |  73 ++++++++----------
 src/rockTemplates.ts | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/style.css        | 110 +++++++++++++++++++++++++++
 src/ui.ts            |  89 +++++++++++++++++++++-
 src/vibration.ts     |  29 +++++---
 10 files changed, 619 insertions(+), 150 deletions(-)

diff --git a/index.html b/index.html
index 09fe973..26e920d 100644
--- a/index.html
+++ b/index.html
@@ -74,6 +74,16 @@
         <span id="nut-size-display" class="size-pill-value">130</span>
     </div>
 
+    <!-- Slow-frame recovery overlay -->
+    <div id="perf-overlay" hidden>
+        <div class="perf-box">
+            <p class="perf-msg">The scene is taking too long to simulate.<br>Consider reducing the number of items.
+            </p>
+            <button id="perf-reset-btn" class="perf-btn">Clear &amp; reset</button>
+            <button id="perf-continue-btn" class="perf-btn perf-btn-secondary">Continue anyway</button>
+        </div>
+    </div>
+
     <!-- Settings panel -->
     <div id="settings-panel" hidden>
         <div class="panel-backdrop"></div>
diff --git a/src/debug.ts b/src/debug.ts
index f827aa7..6d0bbbd 100644
--- a/src/debug.ts
+++ b/src/debug.ts
@@ -80,3 +80,8 @@ export function dbgError(...args: unknown[]): void {
     display(msg, true)
     sendToServer('error', msg)
 }
+
+export function remoteLog(msg: string): void {
+    if (import.meta.env.PROD) return
+    sendToServer('log', msg)
+}
diff --git a/src/main.ts b/src/main.ts
index 33c294c..71e630b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3,7 +3,7 @@ import { createPhysicsWorld } from './physics'
 import { createRenderer } from './renderer'
 import { requestMotionPermission, initMotion } from './motion'
 import { initUI, removeStartOverlay, type ItemKind, type GravityMode } from './ui'
-import { setFlashFallback, unlockVibration, setVibrateEnabled } from './vibration'
+import { unlockVibration, setVibrateEnabled, setVibrateItems, setVibrateEdges } from './vibration'
 import { dbg, dbgError } from './debug'
 import { checkCompat, showUnsupportedWall } from './compat'
 import {
@@ -44,9 +44,6 @@ async function main(): Promise<void> {
     const physics = createPhysicsWorld(cssWidth, cssHeight)
     const renderer = createRenderer(canvas)
 
-    // Wire visual flash fallback for browsers without Vibration API (e.g. Firefox)
-    setFlashFallback(() => renderer.flash())
-
     // Correct for the DPR scale we already applied to the context
     // The renderer draws at CSS px resolution; canvas is scaled by dpr above
     renderer.resize(getPixelWidth() / dpr, getPixelHeight() / dpr)
@@ -55,7 +52,11 @@ async function main(): Promise<void> {
     let tapsLocked = false
     let gravityMode: GravityMode = 'device'
     let vibrateEnabled = true
+    let vibrateItemsEnabled = true
+    let vibrateEdgesEnabled = true
     let currentKind: ItemKind = 'random'
+    const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock']
+    let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL]
 
     // ── Restore persisted settings ────────────────────────────────────────
     const savedSettings = loadSettings()
@@ -68,10 +69,24 @@ async function main(): Promise<void> {
             vibrateEnabled = false
             setVibrateEnabled(false)
         }
+        if (savedSettings.vibrateItems === false) {
+            vibrateItemsEnabled = false
+            setVibrateItems(false)
+        }
+        if (savedSettings.vibrateEdges === false) {
+            vibrateEdgesEnabled = false
+            setVibrateEdges(false)
+        }
         const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock']
         if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) {
             currentKind = savedSettings.kind as ItemKind
         }
+        if (Array.isArray(savedSettings.randomPool)) {
+            const valid = savedSettings.randomPool.filter(k =>
+                (validKinds as string[]).includes(k) && k !== 'random'
+            ) as ItemKind[]
+            if (valid.length > 0) randomPool = valid
+        }
     }
 
     // ── Restore nut positions (share URL takes priority over snapshot) ────────
@@ -121,10 +136,13 @@ async function main(): Promise<void> {
         initialLocked: tapsLocked,
         initialGravityMode: gravityMode,
         initialVibrateEnabled: vibrateEnabled,
+        initialVibrateItems: vibrateItemsEnabled,
+        initialVibrateEdges: vibrateEdgesEnabled,
         initialKind: currentKind,
+        initialRandomPool: randomPool,
         onSizeChange: (radius) => {
             currentNutRadius = radius
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
         },
         onClear: () => {
             physics.clearNuts()
@@ -132,20 +150,34 @@ async function main(): Promise<void> {
         },
         onLockChange: (locked) => {
             tapsLocked = locked
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
         },
         onKindChange: (kind) => {
             currentKind = kind
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
         },
         onGravityModeChange: (mode) => {
             gravityMode = mode
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
         },
         onVibrateChange: (enabled) => {
             vibrateEnabled = enabled
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
+        },
+        onVibrateItemsChange: (enabled) => {
+            vibrateItemsEnabled = enabled
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
+        },
+        onVibrateEdgesChange: (enabled) => {
+            vibrateEdgesEnabled = enabled
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
         },
+        onRandomPoolChange: (pool) => {
+            randomPool = pool
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
+        },
+        onSettingsOpen: () => { settingsPaused = true },
+        onSettingsClose: () => { settingsPaused = false },
         getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
     })
 
@@ -166,8 +198,8 @@ async function main(): Promise<void> {
         if (hasSpace(cx, cy, currentNutRadius)) {
             let kind = currentKind
             if (kind === 'random') {
-                const rand = Math.random()
-                kind = rand < 0.20 ? 'balloon' : rand < 0.38 ? 'gem' : rand < 0.50 ? 'metalnut' : rand < 0.65 ? 'rock' : 'nut'
+                const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock'] as ItemKind[]
+                kind = pool[Math.floor(Math.random() * pool.length)]
             }
             if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius)
             else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius)
@@ -296,9 +328,33 @@ async function main(): Promise<void> {
         }, 400)
     }
 
+    // ── Slow-frame recovery ───────────────────────────────────────────────
+    const perfOverlay = document.getElementById('perf-overlay')!
+    const perfResetBtn = document.getElementById('perf-reset-btn')!
+    const perfContinueBtn = document.getElementById('perf-continue-btn')!
+    let perfPaused = false
+    const SLOW_FRAME_MS = 5000
+
+    perfResetBtn.addEventListener('click', () => {
+        physics.clearNuts()
+        clearSnapshot()
+        perfOverlay.hidden = true
+        perfPaused = false
+        lastTimestamp = 0
+        requestAnimationFrame(loop)
+    })
+
+    perfContinueBtn.addEventListener('click', () => {
+        perfOverlay.hidden = true
+        perfPaused = false
+        lastTimestamp = 0
+        requestAnimationFrame(loop)
+    })
+
     // ── Game loop ─────────────────────────────────────────────────────────────
     let lastTimestamp = 0
     let loopRunning = false
+    let settingsPaused = false
 
     function startLoop(): void {
         if (loopRunning) return
@@ -307,9 +363,37 @@ async function main(): Promise<void> {
     }
 
     function loop(timestamp: number): void {
+        // Pause when settings are open
+        if (settingsPaused) {
+            lastTimestamp = 0
+            requestAnimationFrame(loop)
+            return
+        }
+
+        // On mobile: pause physics + vibration when not fullscreen
+        const isMobile = 'ontouchstart' in window
+        const isFullscreen = !!document.fullscreenElement
+        if (isMobile && !isFullscreen) {
+            setVibrateEnabled(false)
+            lastTimestamp = 0
+            requestAnimationFrame(loop)
+            return
+        }
+        if (isMobile && vibrateEnabled) {
+            setVibrateEnabled(true)
+        }
+
         const delta = lastTimestamp === 0 ? 16.67 : timestamp - lastTimestamp
         lastTimestamp = timestamp
 
+        // Detect runaway frame time — tab wake-up or physics explosion
+        if (delta > SLOW_FRAME_MS) {
+            perfOverlay.hidden = false
+            perfPaused = true
+            loopRunning = false
+            return
+        }
+
         // ── Auto-gravity modes ───────────────────────────────────────────────
         if (gravityMode === 'tumbler') {
             // Full 360° rotation at 0.1 Hz (10 seconds per revolution)
@@ -338,7 +422,7 @@ async function main(): Promise<void> {
     // Also save on tab close / navigation
     window.addEventListener('pagehide', () => {
         saveSnapshot(physics.nuts, cssWidth, cssHeight)
-        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
+        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
     })
 }
 
diff --git a/src/persistence.ts b/src/persistence.ts
index c560eba..05cc5f4 100644
--- a/src/persistence.ts
+++ b/src/persistence.ts
@@ -12,7 +12,10 @@ export interface PersistedSettings {
     nutRadius: number
     gravityMode?: string
     vibrateEnabled?: boolean
+    vibrateItems?: boolean
+    vibrateEdges?: boolean
     kind?: string
+    randomPool?: string[]
 }
 
 export function loadSettings(): PersistedSettings | null {
diff --git a/src/physics.ts b/src/physics.ts
index b3fb85b..53d6c2c 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -1,7 +1,8 @@
 import Matter from 'matter-js'
 import { CONFIG } from './config'
-import { vibrate } from './vibration'
+import { vibrate, vibrateWall } from './vibration'
 import { dbg } from './debug'
+import { ROCK_TEMPLATES } from './rockTemplates'
 
 const { Engine, Bodies, Body, Composite, Events, Vector } = Matter
 
@@ -13,7 +14,9 @@ export interface NutBody {
     colorIndex: number
     kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
     gemVariant: number  // gems: 0=diamond…4=amethyst  balloons: colorIndex
-    hitCount: number    // rocks: number of qualifying impacts received
+    hitCount: number    // rocks: accumulated damage
+    templateIdx: number // rocks: which RockTemplate to use; 0 for non-rocks
+    toughness: number   // rocks: damage multiplier (0.5=brittle … 2=hard); 1 for non-rocks
 }
 
 export interface PhysicsWorld {
@@ -66,7 +69,7 @@ function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number):
         frictionAir: CONFIG.frictionAir,
         label: 'nut',
     })
-    return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0 }
+    return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1 }
 }
 
 function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
@@ -80,7 +83,7 @@ function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: numb
         frictionAir: 0.0008,
         label: 'metalnut',
     })
-    return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0 }
+    return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1 }
 }
 
 const GEM_SIDES = [4, 6, 6, 8, 6] as const  // sides per gem variant
@@ -99,7 +102,7 @@ function spawnGem(x: number, y: number, radius: number, gemVariant: number): Nut
         frictionAir: 0.0005,
         label: 'gem',
     })
-    return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0 }
+    return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0, templateIdx: 0, toughness: 1 }
 }
 
 function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody {
@@ -112,7 +115,7 @@ function spawnBalloon(x: number, y: number, radius: number, colorIndex: number):
         frictionAir: 0.008,
         label: 'balloon',
     })
-    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0 }
+    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 }
 }
 
 // ── Factory ────────────────────────────────────────────────────────────────
@@ -124,25 +127,20 @@ const ROCK_HITS_TO_SPLIT = 10 // hits required to break
 function spawnRock(x: number, y: number, targetRadius: number, colorIndex: number): NutBody {
     const variance = 0.85 + Math.random() * 0.3
     const radius = targetRadius * variance
-    const sides = 5 + Math.floor(Math.random() * 4)  // 5–8 sides
-
-    // Irregular convex polygon: evenly-spaced base angles + small jitter
-    const verts: Matter.Vector[] = []
-    for (let i = 0; i < sides; i++) {
-        const baseAngle = (i / sides) * Math.PI * 2
-        const jitter = (Math.random() - 0.5) * (Math.PI / sides) * 0.85
-        const r = radius * (0.6 + Math.random() * 0.65)
-        verts.push(Vector.create(Math.cos(baseAngle + jitter) * r, Math.sin(baseAngle + jitter) * r))
-    }
+    const templateIdx = Math.floor(Math.random() * ROCK_TEMPLATES.length)
+    const template = ROCK_TEMPLATES[templateIdx]
 
+    const verts = template.normVerts.map(v => Vector.create(v.x * radius, v.y * radius))
     const body = Bodies.fromVertices(x, y, [verts], {
-        density: 0.0025,   // granite-like: ~2.5× default
-        restitution: 0.25, // rocks don't bounce much
+        density: 0.0025,
+        restitution: 0.25,
         friction: 0.65,
         frictionAir: 0.001,
         label: 'rock',
     })
-    return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0 }
+    // Toughness: log-uniform from 0.5 (brittle) to 2.0 (hard)
+    const toughness = Math.pow(2, (Math.random() - 0.5) * 2)  // 2^[-1, 1] = 0.5 … 2.0
+    return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0, templateIdx, toughness }
 }
 
 export function createPhysicsWorld(width: number, height: number): PhysicsWorld {
@@ -156,10 +154,6 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
     let currentHeight = height
     let splitQueue: NutBody[] = []
     const splittingIds = new Set<number>()
-    let targetRockMass = 0
-    let rockAddCount = 0
-    let rockAddRadiusSum = 0
-    let refillRetryAt = 0  // engine timestamp after which to try again
 
     Composite.add(engine.world, walls)
 
@@ -194,19 +188,23 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
 
             // Queue rock splits before vibration so we have speed info
             const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed)
-            if (speed >= ROCK_SPLIT_SPEED) {
-                const tryQueueSplit = (b: Matter.Body) => {
-                    if (b.label !== 'rock') return
-                    if (splittingIds.has(b.id)) return
-                    const nb = nuts.find(n => n.body === b)
+            if (speed > ROCK_SPLIT_SPEED) {
+                const tryQueueSplit = (rockBody: Matter.Body, otherBody: Matter.Body) => {
+                    if (rockBody.label !== 'rock') return
+                    if (splittingIds.has(rockBody.id)) return
+                    const nb = nuts.find(n => n.body === rockBody)
                     if (!nb) return
-                    nb.hitCount++
+                    // Damage = excess kinetic energy of impactor relative to rock mass.
+                    // Normalised so 10 hits at ROCK_SPLIT_SPEED*1.5 from equal mass ≈ 10.
+                    const excess = speed - ROCK_SPLIT_SPEED
+                    const massRatio = otherBody.mass / rockBody.mass
+                    nb.hitCount += (excess * excess * massRatio) / (ROCK_SPLIT_SPEED * ROCK_SPLIT_SPEED * 1.25 * nb.toughness)
                     if (nb.hitCount < ROCK_HITS_TO_SPLIT) return
-                    splittingIds.add(b.id)
+                    splittingIds.add(rockBody.id)
                     splitQueue.push(nb)
                 }
-                if (aRock) tryQueueSplit(pair.bodyA)
-                if (bRock) tryQueueSplit(pair.bodyB)
+                if (aRock) tryQueueSplit(pair.bodyA, pair.bodyB)
+                if (bRock) tryQueueSplit(pair.bodyB, pair.bodyA)
             }
 
             if (aNut && bNut) {
@@ -237,13 +235,13 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                 if (wallSpeed > CONFIG.impactThreshold) {
                     if (nutBody.label === 'rock') {
                         const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1)
-                        vibrate(Math.round(15 + t * 30))
+                        vibrateWall(Math.round(15 + t * 30))
                     } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') {
                         const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1)
-                        vibrate(Math.round(15 + t * 35))
+                        vibrateWall(Math.round(15 + t * 35))
                     } else {
                         const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1)
-                        vibrate(Math.round(30 + t * (CONFIG.maxVibrationMs - 30)))
+                        vibrateWall(Math.round(30 + t * (CONFIG.maxVibrationMs - 30)))
                     }
                 }
             }
@@ -289,39 +287,6 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Body.setAngle(rock.body, Math.random() * Math.PI * 2)
         nuts.push(rock)
         Composite.add(engine.world, rock.body)
-        targetRockMass += rock.body.mass
-        rockAddRadiusSum += radius
-        rockAddCount++
-    }
-
-    function refillRocks(): void {
-        if (targetRockMass === 0) return
-        const now = engine.timing.timestamp
-        if (now < refillRetryAt) return
-        const currentMass = nuts.filter(n => n.kind === 'rock').reduce((s, n) => s + n.body.mass, 0)
-        const avgRadius = rockAddRadiusSum / rockAddCount
-        const bigRockRefMass = Math.PI * avgRadius * avgRadius * 0.0025
-        if (currentMass >= targetRockMass - bigRockRefMass) return
-
-        const spawnRadius = avgRadius
-        const clearance = spawnRadius * 2.2  // minimum gap around spawn point
-        const x = currentWidth / 2 + (Math.random() - 0.5) * currentWidth * 0.1
-        const y = currentHeight / 2 + (Math.random() - 0.5) * currentHeight * 0.1
-        const blocked = nuts.some(n => {
-            const dx = n.body.position.x - x
-            const dy = n.body.position.y - y
-            return Math.sqrt(dx * dx + dy * dy) < clearance + n.radius
-        })
-        if (blocked) {
-            refillRetryAt = now + 1500  // retry in 1.5 s
-            return
-        }
-
-        const colorIndex = Math.floor(Math.random() * ROCK_COLORS_COUNT)
-        const rock = spawnRock(x, y, spawnRadius, colorIndex)
-        Body.setAngle(rock.body, Math.random() * Math.PI * 2)
-        nuts.push(rock)
-        Composite.add(engine.world, rock.body)
     }
 
     function processSplits(): void {
@@ -331,26 +296,35 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             splittingIds.delete(nut.body.id)
             const idx = nuts.indexOf(nut)
             if (idx === -1) continue
-            const { position, velocity } = nut.body
+
+            const pos = { x: nut.body.position.x, y: nut.body.position.y }
+            const vel = { x: nut.body.velocity.x, y: nut.body.velocity.y }
+
             Composite.remove(engine.world, nut.body)
             nuts.splice(idx, 1)
 
             if (nut.radius <= CONFIG.nutMinRadius * 1.4) continue
 
-            const count = nut.radius > CONFIG.nutMinRadius * 3 ? 3 : 2
-            const newRadius = (nut.radius / Math.sqrt(count)) * 0.9
+            // Spawn smaller rocks preserving total area (≈ mass at constant density)
+            // More pieces for larger rocks: 2 at min size, up to 5 at max size
+            const sizeFraction = Math.min(1, (nut.radius - CONFIG.nutMinRadius) / (CONFIG.nutMaxRadius - CONFIG.nutMinRadius))
+            const count = Math.round(2 + sizeFraction * 3)  // 2–5 fragments
+            // r² × count = R²  →  r = R / √count
+            const fragRadius = (nut.radius / Math.sqrt(count)) * 0.95
+
             for (let i = 0; i < count; i++) {
-                const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.8
-                const dist = nut.radius * 0.3
-                const fx = position.x + Math.cos(spreadAngle) * dist
-                const fy = position.y + Math.sin(spreadAngle) * dist
-                const fragment = spawnRock(fx, fy, newRadius, nut.colorIndex)
-                Body.setVelocity(fragment.body, {
-                    x: velocity.x + Math.cos(spreadAngle) * 3.5,
-                    y: velocity.y + Math.sin(spreadAngle) * 3.5,
+                if (fragRadius < CONFIG.nutMinRadius * 0.5) continue
+                const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.5
+                const dist = nut.radius * 0.35
+                const fx = pos.x + Math.cos(spreadAngle) * dist
+                const fy = pos.y + Math.sin(spreadAngle) * dist
+                const frag = spawnRock(fx, fy, fragRadius, nut.colorIndex)
+                Body.setVelocity(frag.body, {
+                    x: vel.x + Math.cos(spreadAngle) * (2 + Math.random() * 2),
+                    y: vel.y + Math.sin(spreadAngle) * (2 + Math.random() * 2),
                 })
-                nuts.push(fragment)
-                Composite.add(engine.world, fragment.body)
+                nuts.push(frag)
+                Composite.add(engine.world, frag.body)
             }
         }
     }
@@ -401,7 +375,6 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
     function step(deltaMs: number): void {
         Engine.update(engine, Math.min(deltaMs, 50))
         processSplits()
-        refillRocks()
     }
 
     function destroy(): void {
diff --git a/src/renderer.ts b/src/renderer.ts
index aa2cb80..327012f 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -2,6 +2,7 @@ import type { NutBody } from './physics'
 import Matter from 'matter-js'
 import { CONFIG } from './config'
 import QRCode from 'qrcode'
+import { ROCK_TEMPLATES } from './rockTemplates'
 
 // ── Nut palette – warm tones ───────────────────────────────────────────────
 interface NutColor { hi: string; mid: string; shadow: string; rim: string }
@@ -331,7 +332,7 @@ const ROCK_PALETTE = [
     { fill: '#705540', hi: '#987860', dark: '#483020', stroke: '#2C1810' },
 ]
 
-function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: number, colorIndex: number, hitCount: number): void {
+function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: number, colorIndex: number, hitCount: number, templateIdx: number): void {
     const c = ROCK_PALETTE[colorIndex % ROCK_PALETTE.length]
     const verts = body.vertices
     const cx = body.position.x
@@ -381,44 +382,41 @@ function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: numb
     ctx.fill()
     ctx.restore()
 
-    // Cracks — stable per body ID, grow with hit progress
+    // Cracks — template-based, vertex-to-vertex through shared fracture point
     const HITS_TO_SPLIT = 10
     const progress = hitCount / HITS_TO_SPLIT
     if (progress >= 0.1) {
-        let s = (body.id * 2654435761) >>> 0
-        const rng = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 0x100000000 }
-        const numCracks = Math.min(Math.floor(progress * 5) + 1, 5)
+        const template = ROCK_TEMPLATES[templateIdx % ROCK_TEMPLATES.length]
+        const numCracks = Math.min(Math.floor(progress * template.cracks.length) + 1, template.cracks.length)
+
+        const cos = Math.cos(body.angle), sin = Math.sin(body.angle)
+        const tw = (lx: number, ly: number) => ({
+            x: cx + (lx * radius) * cos - (ly * radius) * sin,
+            y: cy + (lx * radius) * sin + (ly * radius) * cos,
+        })
+        const fw = tw(template.fracture.x, template.fracture.y)
+
         ctx.save()
         rockPath()
         ctx.clip()
         ctx.lineCap = 'round'
-        ctx.lineWidth = Math.max(0.6, radius * 0.03 * (0.5 + progress))
+
         for (let i = 0; i < numCracks; i++) {
-            const startAngle = rng() * Math.PI * 2
-            const startDist = rng() * radius * 0.25
-            const crackAngle = rng() * Math.PI * 2
-            const crackLen = radius * (0.35 + rng() * 0.45) * (0.4 + progress * 0.6)
-            const segs = 2 + Math.floor(rng() * 2)
-            const sx = cx + Math.cos(startAngle) * startDist
-            const sy = cy + Math.sin(startAngle) * startDist
-            // dark inner crack
-            ctx.strokeStyle = `rgba(0,0,0,${0.55 + progress * 0.3})`
+            const [v0l, v1l] = template.cracks[i]
+            const v0 = tw(v0l.x, v0l.y)
+            const v1 = tw(v1l.x, v1l.y)
+
+            const lw = Math.max(0.8, radius * 0.035 * (0.4 + progress))
+            ctx.lineWidth = lw
+            ctx.strokeStyle = `rgba(0,0,0,${0.5 + progress * 0.35})`
             ctx.beginPath()
-            ctx.moveTo(sx, sy)
-            for (let j = 1; j <= segs; j++) {
-                const t = j / segs
-                const jitter = (rng() - 0.5) * radius * 0.18
-                ctx.lineTo(
-                    sx + Math.cos(crackAngle) * crackLen * t + Math.cos(crackAngle + Math.PI / 2) * jitter,
-                    sy + Math.sin(crackAngle) * crackLen * t + Math.sin(crackAngle + Math.PI / 2) * jitter
-                )
-            }
+            ctx.moveTo(v0.x, v0.y)
+            ctx.lineTo(fw.x, fw.y)
+            ctx.lineTo(v1.x, v1.y)
             ctx.stroke()
-            // thin light edge to give depth
-            ctx.strokeStyle = `rgba(255,255,255,${0.12 + progress * 0.08})`
-            ctx.lineWidth = Math.max(0.4, radius * 0.012)
+            ctx.lineWidth = Math.max(0.4, lw * 0.35)
+            ctx.strokeStyle = `rgba(255,255,255,${0.1 + progress * 0.1})`
             ctx.stroke()
-            ctx.lineWidth = Math.max(0.6, radius * 0.03 * (0.5 + progress))
         }
         ctx.restore()
     }
@@ -530,12 +528,10 @@ export interface Renderer {
     render: (nuts: NutBody[], gravityAngle: number, showArrow?: boolean) => void
     setShareURL: (url: string) => void
     resize: (width: number, height: number) => void
-    flash: () => void
 }
 
 export function createRenderer(canvas: HTMLCanvasElement): Renderer {
     const ctx = canvas.getContext('2d')!
-    let flashAlpha = 0
     let qrBitmap: HTMLCanvasElement | null = null
     let qrPending = false
     let lastURL = ''
@@ -566,10 +562,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         }
     }
 
-    function flash(): void {
-        flashAlpha = 0.35
-    }
-
     function render(nuts: NutBody[], gravityAngle: number, showArrow = false): void {
         const w = canvas.width
         const h = canvas.height
@@ -579,7 +571,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         ctx.fillRect(0, 0, w, h)
 
         // Nuts, gems & balloons
-        for (const { body, radius, colorIndex, kind, gemVariant, hitCount } of nuts) {
+        for (const { body, radius, colorIndex, kind, gemVariant, hitCount, templateIdx } of nuts) {
             const { position, angle } = body
 
             if (kind === 'balloon') {
@@ -589,7 +581,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                 drawBalloon(ctx, 0, 0, radius, colorIndex)
                 ctx.restore()
             } else if (kind === 'rock') {
-                drawRock(ctx, body, radius, colorIndex, hitCount)
+                drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx)
             } else {
                 const sprite = getSprite(radius, colorIndex, kind as 'nut' | 'gem' | 'metalnut', gemVariant)
                 ctx.save()
@@ -643,13 +635,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
 
             ctx.restore()
         }
-
-        // Flash overlay (fallback haptic cue for Firefox / no-vibration devices)
-        if (flashAlpha > 0) {
-            ctx.fillStyle = `rgba(255,255,255,${flashAlpha.toFixed(3)})`
-            ctx.fillRect(0, 0, w, h)
-            flashAlpha = Math.max(0, flashAlpha - 0.05)
-        }
     }
 
     function resize(width: number, height: number): void {
@@ -657,5 +642,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         canvas.height = height
     }
 
-    return { render, setShareURL, resize, flash }
+    return { render, setShareURL, resize }
 }
diff --git a/src/rockTemplates.ts b/src/rockTemplates.ts
new file mode 100644
index 0000000..cd28099
--- /dev/null
+++ b/src/rockTemplates.ts
@@ -0,0 +1,205 @@
+/**
+ * Precomputed rock fracture templates.
+ *
+ * Each template defines:
+ *  - The polygon shape (normVerts, bounding radius = 1, centroid at origin)
+ *  - A shared fracture point
+ *  - Crack endpoint pairs (for the renderer)
+ *  - Pre-split fragment polygons (for physics)
+ *
+ * Templates are generated once at module load with thorough boundary checks so
+ * that every fragment is guaranteed to be physics-valid before any rock is even
+ * spawned.
+ */
+
+import Matter from 'matter-js'
+const { Bodies, Vector } = Matter
+
+// ── Public types ────────────────────────────────────────────────────────────
+
+export interface Vec2 { x: number; y: number }
+
+export interface RockFragment {
+    /** Fragment centroid in template-normalised local space (bounding radius = 1) */
+    centroid: Vec2
+    /** Fragment polygon vertices RELATIVE to this fragment's centroid, same scale */
+    localVerts: Vec2[]
+    /** Bounding radius of this fragment in normalised space */
+    boundR: number
+}
+
+export interface RockTemplate {
+    /** Polygon vertices, centroid at origin, bounding radius = 1 */
+    normVerts: Vec2[]
+    /** Fracture point in template-local space */
+    fracture: Vec2
+    /** Crack endpoint pairs in template-local space (same scale as normVerts) */
+    cracks: [Vec2, Vec2][]
+    /** Pre-validated fragment polygons */
+    fragments: RockFragment[]
+}
+
+// ── Geometry helpers ────────────────────────────────────────────────────────
+
+function polygonArea(verts: Vec2[]): number {
+    let a = 0
+    const n = verts.length
+    for (let i = 0, j = n - 1; i < n; j = i++) {
+        a += verts[j].x * verts[i].y - verts[i].x * verts[j].y
+    }
+    return Math.abs(a) / 2
+}
+
+/** Proper area-weighted centroid (shoelace). */
+function polygonCentroid(verts: Vec2[]): Vec2 {
+    let ax = 0, ay = 0, area = 0
+    const n = verts.length
+    for (let i = 0, j = n - 1; i < n; j = i++) {
+        const c = verts[j].x * verts[i].y - verts[i].x * verts[j].y
+        ax += (verts[j].x + verts[i].x) * c
+        ay += (verts[j].y + verts[i].y) * c
+        area += c
+    }
+    area /= 2
+    if (Math.abs(area) < 1e-12) return { x: 0, y: 0 }
+    return { x: ax / (6 * area), y: ay / (6 * area) }
+}
+
+function pointInPolygon(pt: Vec2, poly: Vec2[]): boolean {
+    let inside = false
+    const n = poly.length
+    for (let i = 0, j = n - 1; i < n; j = i++) {
+        const xi = poly[i].x, yi = poly[i].y
+        const xj = poly[j].x, yj = poly[j].y
+        if ((yi > pt.y) !== (yj > pt.y) &&
+            pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi) {
+            inside = !inside
+        }
+    }
+    return inside
+}
+
+function makeRng(seed: number) {
+    let s = (seed * 2654435761 + 1) >>> 0
+    return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 0x100000000 }
+}
+
+// ── Template builder ────────────────────────────────────────────────────────
+
+const MIN_FRAG_AREA = 0.04       // normalised-space minimum fragment area
+const MIN_FRAG_BOUND_R = 0.08   // minimum fragment bounding radius
+
+function buildTemplate(seed: number): RockTemplate | null {
+    const rng = makeRng(seed)
+
+    // 1. Generate raw polygon (same distribution as spawnRock)
+    const sides = 5 + Math.floor(rng() * 4)  // 5–8 sides
+    const rawVerts: Vec2[] = []
+    for (let i = 0; i < sides; i++) {
+        const baseAngle = (i / sides) * Math.PI * 2
+        const jitter = (rng() - 0.5) * (Math.PI / sides) * 0.85
+        const r = 0.6 + rng() * 0.65
+        rawVerts.push({ x: Math.cos(baseAngle + jitter) * r, y: Math.sin(baseAngle + jitter) * r })
+    }
+
+    // 2. Canonicalise via Matter.js — get actual body vertex order & centroid correction
+    const mVerts = rawVerts.map(v => Vector.create(v.x, v.y))
+    const testBody = Bodies.fromVertices(0, 0, [mVerts], {
+        density: 0.0025, restitution: 0.25, friction: 0.65, frictionAir: 0.001, label: 'rock',
+    })
+    if (!testBody || testBody.vertices.length < 4) return null
+
+    // 3. Extract centroid-relative local verts from the canonicalised body
+    const n = testBody.vertices.length
+    const bodyLocalVerts: Vec2[] = testBody.vertices.map(v => ({
+        x: v.x - testBody.position.x,
+        y: v.y - testBody.position.y,
+    }))
+
+    // 4. Normalise to bounding radius = 1
+    const maxR = Math.max(...bodyLocalVerts.map(v => Math.sqrt(v.x * v.x + v.y * v.y)))
+    if (maxR < 0.1) return null
+    const normVerts: Vec2[] = bodyLocalVerts.map(v => ({ x: v.x / maxR, y: v.y / maxR }))
+
+    // 5. Area check on the original polygon
+    if (polygonArea(normVerts) < 0.6) return null
+
+    // 6. Fracture point — must land inside the polygon
+    const fx = (rng() - 0.5) * 0.3
+    const fy = (rng() - 0.5) * 0.3
+    const fracture: Vec2 = { x: fx, y: fy }
+    if (!pointInPolygon(fracture, normVerts)) return null
+
+    // 7. Generate crack pairs
+    const usedPairs = new Set<string>()
+    const cracks: [Vec2, Vec2][] = []
+    const cutIndices: number[] = []
+
+    for (let attempt = 0; attempt < 30 && cracks.length < 3; attempt++) {
+        const startIdx = Math.floor(rng() * n)
+        const half = Math.floor(n / 2)
+        const endIdx = (startIdx + half + (rng() < 0.5 ? 0 : 1)) % n
+        if (startIdx === endIdx) continue
+        const key = `${Math.min(startIdx, endIdx)}-${Math.max(startIdx, endIdx)}`
+        if (usedPairs.has(key)) continue
+        usedPairs.add(key)
+        cracks.push([{ ...normVerts[startIdx] }, { ...normVerts[endIdx] }])
+        if (!cutIndices.includes(startIdx)) cutIndices.push(startIdx)
+        if (!cutIndices.includes(endIdx)) cutIndices.push(endIdx)
+    }
+
+    if (cracks.length < 2 || cutIndices.length < 2) return null
+    cutIndices.sort((a, b) => a - b)
+
+    // 8. Build and validate fragment polygons
+    const fragments: RockFragment[] = []
+    for (let i = 0; i < cutIndices.length; i++) {
+        const fromIdx = cutIndices[i]
+        const toIdx = cutIndices[(i + 1) % cutIndices.length]
+
+        // Arc of vertices from fromIdx → toIdx (inclusive) plus the shared fracture point
+        const fv: Vec2[] = [{ ...fracture }]
+        let vi = fromIdx
+        while (true) {
+            fv.push({ ...normVerts[vi] })
+            if (vi === toIdx) break
+            vi = (vi + 1) % n
+        }
+
+        if (fv.length < 3) return null
+
+        // Area check
+        if (polygonArea(fv) < MIN_FRAG_AREA) return null
+
+        // Centroid and centroid-relative verts
+        const c = polygonCentroid(fv)
+        const localVerts = fv.map(v => ({ x: v.x - c.x, y: v.y - c.y }))
+        const boundR = Math.max(...localVerts.map(v => Math.sqrt(v.x * v.x + v.y * v.y)))
+        if (boundR < MIN_FRAG_BOUND_R) return null
+
+        // Test that Matter.js can actually create this fragment body
+        const testFragVerts = localVerts.map(v => Vector.create(v.x, v.y))
+        const testFrag = Bodies.fromVertices(0, 0, [testFragVerts], {
+            density: 0.0025, restitution: 0.25, friction: 0.65, frictionAir: 0.001, label: 'rock',
+        })
+        if (!testFrag || testFrag.vertices.length < 3) return null
+
+        fragments.push({ centroid: c, localVerts, boundR })
+    }
+
+    if (fragments.length < 2) return null
+
+    return { normVerts, fracture, cracks, fragments }
+}
+
+// ── Build the pool ─────────────────────────────────────────────────────────
+
+export const ROCK_TEMPLATES: readonly RockTemplate[] = (() => {
+    const templates: RockTemplate[] = []
+    let seed = 0
+    while (templates.length < 10 && seed < 5000) {
+        const t = buildTemplate(seed++)
+        if (t) templates.push(t)
+    }
+    return templates
+})()
diff --git a/src/style.css b/src/style.css
index b5ba3ef..9752bdf 100644
--- a/src/style.css
+++ b/src/style.css
@@ -365,6 +365,50 @@ input[type="range"]::-moz-range-thumb {
     color: #444;
 }
 
+/* ── Random pool section ─────────────────────────────────── */
+.pool-section {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+
+.pool-label {
+    font-size: 13px;
+    color: #888;
+    margin: 0;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+}
+
+.pool-grid {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 8px;
+}
+
+.pool-btn {
+    background: #2a2a2a;
+    border: 1px solid #3a3a3a;
+    color: #888;
+    font-size: 13px;
+    padding: 9px 6px;
+    border-radius: 9px;
+    cursor: pointer;
+    font-family: inherit;
+    text-align: center;
+    transition: background 0.15s, color 0.15s;
+}
+
+.pool-btn.active {
+    background: #1e2e1e;
+    border-color: #4a7a4a;
+    color: #c0e0c0;
+}
+
+.pool-btn:active {
+    opacity: 0.8;
+}
+
 .attribution {
     font-size: 12px;
     color: #555;
@@ -377,6 +421,72 @@ input[type="range"]::-moz-range-thumb {
     text-underline-offset: 2px;
 }
 
+.no-vibration-note {
+    font-size: 13px;
+    color: #666;
+    margin: 0;
+    line-height: 1.45;
+}
+
+/* ── Slow-frame recovery overlay ───────────────────────────── */
+#perf-overlay {
+    position: fixed;
+    inset: 0;
+    z-index: 200;
+    background: rgba(0, 0, 0, 0.75);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+#perf-overlay[hidden] {
+    display: none;
+}
+
+.perf-box {
+    background: #1e1e1e;
+    border-radius: 16px;
+    padding: 28px 24px;
+    max-width: 320px;
+    width: calc(100% - 48px);
+    display: flex;
+    flex-direction: column;
+    gap: 14px;
+    font-family: system-ui, -apple-system, sans-serif;
+}
+
+.perf-msg {
+    color: #ccc;
+    font-size: 15px;
+    line-height: 1.5;
+    margin: 0;
+    text-align: center;
+}
+
+.perf-btn {
+    background: #c8a84b;
+    border: none;
+    color: #111;
+    font-size: 15px;
+    font-weight: 600;
+    padding: 13px 16px;
+    border-radius: 11px;
+    cursor: pointer;
+    font-family: inherit;
+    transition: opacity 0.15s;
+}
+
+.perf-btn:active {
+    opacity: 0.8;
+}
+
+.perf-btn-secondary {
+    background: #2a2a2a;
+    border: 1px solid #3a3a3a;
+    color: #aaa;
+    font-weight: 400;
+}
+
 /* ── Share section ─────────────────────────────────────────────────────── */
 .share-section {
     display: flex;
diff --git a/src/ui.ts b/src/ui.ts
index ad40100..e84a7ce 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -2,7 +2,7 @@ import { CONFIG } from './config'
 import { dbg } from './debug'
 import QRCode from 'qrcode'
 import { hasVibration } from './compat'
-import { setVibrateEnabled, isVibrateEnabled } from './vibration'
+import { setVibrateEnabled, isVibrateEnabled, setVibrateItems, isVibrateItems, setVibrateEdges, isVibrateEdges } from './vibration'
 
 export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
 export type GravityMode = 'device' | 'tumbler' | 'rock'
@@ -22,13 +22,21 @@ export interface UICallbacks {
     initialLocked: boolean
     initialGravityMode: GravityMode
     initialVibrateEnabled: boolean
+    initialVibrateItems: boolean
+    initialVibrateEdges: boolean
     initialKind: ItemKind
+    initialRandomPool: ItemKind[]
     onSizeChange: (radius: number) => void
     onClear: () => void
     onLockChange: (locked: boolean) => void
     onKindChange: (kind: ItemKind) => void
     onGravityModeChange: (mode: GravityMode) => void
     onVibrateChange: (enabled: boolean) => void
+    onVibrateItemsChange: (enabled: boolean) => void
+    onVibrateEdgesChange: (enabled: boolean) => void
+    onRandomPoolChange: (pool: ItemKind[]) => void
+    onSettingsOpen: () => void
+    onSettingsClose: () => void
     getShareURL: () => string
 }
 
@@ -138,15 +146,22 @@ export function initUI(callbacks: UICallbacks): void {
     if (!hasVibration()) {
         testVibrateBtn.hidden = true
         vibrateToggleBtn.hidden = true
+        const noVibNote = document.createElement('p')
+        noVibNote.className = 'no-vibration-note'
+        noVibNote.textContent = '📳 Haptic feedback is available on Android and some other devices.'
+        vibrateToggleBtn.insertAdjacentElement('afterend', noVibNote)
     } else {
         // Restore persisted vibration state
         setVibrateEnabled(callbacks.initialVibrateEnabled)
+        setVibrateItems(callbacks.initialVibrateItems)
+        setVibrateEdges(callbacks.initialVibrateEdges)
         vibrateToggleBtn.textContent = callbacks.initialVibrateEnabled ? 'Vibration: On' : 'Vibration: Off'
         vibrateToggleBtn.classList.toggle('active', callbacks.initialVibrateEnabled)
     }
 
     settingsBtn.addEventListener('click', () => {
         settingsPanel.hidden = false
+        callbacks.onSettingsOpen()
         refreshQR()
     })
 
@@ -169,6 +184,77 @@ export function initUI(callbacks: UICallbacks): void {
         callbacks.onVibrateChange(on)
     })
 
+    // ── Vibration sub-options (only shown when vibration available) ──────────
+    if (hasVibration()) {
+        const vibrateItemsBtn = document.createElement('button')
+        vibrateItemsBtn.className = 'action-btn' + (callbacks.initialVibrateItems ? ' active' : '')
+        vibrateItemsBtn.textContent = callbacks.initialVibrateItems ? 'Item hits: On' : 'Item hits: Off'
+        vibrateItemsBtn.addEventListener('click', () => {
+            const on = !isVibrateItems()
+            setVibrateItems(on)
+            vibrateItemsBtn.textContent = on ? 'Item hits: On' : 'Item hits: Off'
+            vibrateItemsBtn.classList.toggle('active', on)
+            callbacks.onVibrateItemsChange(on)
+        })
+
+        const vibrateEdgesBtn = document.createElement('button')
+        vibrateEdgesBtn.className = 'action-btn' + (callbacks.initialVibrateEdges ? ' active' : '')
+        vibrateEdgesBtn.textContent = callbacks.initialVibrateEdges ? 'Edge hits: On' : 'Edge hits: Off'
+        vibrateEdgesBtn.addEventListener('click', () => {
+            const on = !isVibrateEdges()
+            setVibrateEdges(on)
+            vibrateEdgesBtn.textContent = on ? 'Edge hits: On' : 'Edge hits: Off'
+            vibrateEdgesBtn.classList.toggle('active', on)
+            callbacks.onVibrateEdgesChange(on)
+        })
+
+        // Insert after vibrateToggleBtn
+        vibrateToggleBtn.insertAdjacentElement('afterend', vibrateEdgesBtn)
+        vibrateToggleBtn.insertAdjacentElement('afterend', vibrateItemsBtn)
+    }
+
+    // ── Random pool toggles ──────────────────────────────────────────────────
+    const RANDOM_KINDS: Array<{ kind: Exclude<ItemKind, 'random'>; label: string }> = [
+        { kind: 'nut', label: '🌰 Nuts' },
+        { kind: 'gem', label: '💎 Gems' },
+        { kind: 'balloon', label: '🎈 Balloons' },
+        { kind: 'metalnut', label: '🔩 Metal nuts' },
+        { kind: 'rock', label: '🪨 Rocks' },
+    ]
+    let randomPool = new Set<ItemKind>(callbacks.initialRandomPool)
+
+    const poolSection = document.createElement('div')
+    poolSection.className = 'pool-section'
+    const poolLabel = document.createElement('p')
+    poolLabel.className = 'pool-label'
+    poolLabel.textContent = '? random includes'
+    poolSection.appendChild(poolLabel)
+    const poolGrid = document.createElement('div')
+    poolGrid.className = 'pool-grid'
+
+    for (const { kind, label } of RANDOM_KINDS) {
+        const btn = document.createElement('button')
+        btn.className = 'pool-btn' + (randomPool.has(kind) ? ' active' : '')
+        btn.textContent = label
+        btn.addEventListener('click', () => {
+            if (randomPool.has(kind)) {
+                if (randomPool.size <= 1) return  // keep at least one
+                randomPool.delete(kind)
+                btn.classList.remove('active')
+            } else {
+                randomPool.add(kind)
+                btn.classList.add('active')
+            }
+            callbacks.onRandomPoolChange([...randomPool])
+        })
+        poolGrid.appendChild(btn)
+    }
+    poolSection.appendChild(poolGrid)
+
+    // Insert before the close button
+    const closeBtn2 = settingsPanel.querySelector('#close-settings') as HTMLButtonElement
+    closeBtn2.parentElement!.insertBefore(poolSection, closeBtn2)
+
     copyLinkBtn.addEventListener('click', () => {
         const url = callbacks.getShareURL()
         const orig = copyLinkBtn.textContent
@@ -198,6 +284,7 @@ export function initUI(callbacks: UICallbacks): void {
 
     function closePanel(): void {
         settingsPanel.hidden = true
+        callbacks.onSettingsClose()
     }
 }
 
diff --git a/src/vibration.ts b/src/vibration.ts
index 734767e..47ab91f 100644
--- a/src/vibration.ts
+++ b/src/vibration.ts
@@ -2,14 +2,10 @@ import { CONFIG } from './config'
 import { dbg } from './debug'
 
 let lastVibrationAt = 0
-let flashFn: (() => void) | undefined
 let unlocked = false
 let vibrateEnabled = true
-
-/** Register a visual fallback called when the Vibration API is unavailable. */
-export function setFlashFallback(fn: () => void): void {
-    flashFn = fn
-}
+let vibrateItems = true
+let vibrateEdges = true
 
 export function setVibrateEnabled(on: boolean): void {
     vibrateEnabled = on
@@ -19,6 +15,11 @@ export function isVibrateEnabled(): boolean {
     return vibrateEnabled
 }
 
+export function setVibrateItems(on: boolean): void { vibrateItems = on }
+export function isVibrateItems(): boolean { return vibrateItems }
+export function setVibrateEdges(on: boolean): void { vibrateEdges = on }
+export function isVibrateEdges(): boolean { return vibrateEdges }
+
 /**
  * Call once from a user-gesture handler (e.g. the tap-to-start).
  * Chrome requires at least one vibrate() call from a gesture context before
@@ -60,6 +61,7 @@ function canVibrate(): boolean {
 
 /** Fire the Web Vibration API, with a minimum interval to avoid spam. */
 export function vibrate(durationMs: number): void {
+    if (!vibrateItems) return
     const now = Date.now()
     if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return
     // Chrome won't vibrate if the page is backgrounded or display is off
@@ -69,12 +71,19 @@ export function vibrate(durationMs: number): void {
     if (canVibrate()) {
         const ok = navigator.vibrate(durationMs)
         dbg('vibrate', durationMs, 'ms ->', ok)
-    } else {
-        dbg('vibrate: fallback flash (unlocked:', unlocked, ')')
-        flashFn?.()
     }
 }
 
+export function vibrateWall(durationMs: number): void {
+    if (!vibrateEdges) return
+    const now = Date.now()
+    if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return
+    if (document.visibilityState !== 'visible') return
+    lastVibrationAt = now
+    if (!vibrateEnabled) return
+    if (canVibrate()) navigator.vibrate(durationMs)
+}
+
 /** Short double-pulse for a shake event. */
 export function vibrateShake(): void {
     const now = Date.now()
@@ -83,7 +92,5 @@ export function vibrateShake(): void {
     if (!vibrateEnabled) return
     if (canVibrate()) {
         navigator.vibrate([25, 15, 25])
-    } else {
-        flashFn?.()
     }
 }