fuzzyworld

nuts / commit

added rocks that break

74d506c663c4c9962d2b5f578216e991c3d165bc · Pavel Safronov · 2026-04-12 15:08:07 -0700 · parents 17bc8f89948f

added rocks that break
 src/main.ts        |  33 ++++++----
 src/persistence.ts |   5 +-
 src/physics.ts     | 176 ++++++++++++++++++++++++++++++++++++++++++++++-------
 src/renderer.ts    | 107 +++++++++++++++++++++++++++++++-
 src/share.ts       |  11 +++-
 src/style.css      |   4 ++
 src/ui.ts          |   9 ++-
 7 files changed, 303 insertions(+), 42 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index de651c7..33c294c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -55,8 +55,9 @@ async function main(): Promise<void> {
     let tapsLocked = false
     let gravityMode: GravityMode = 'device'
     let vibrateEnabled = true
+    let currentKind: ItemKind = 'random'
 
-    // ── Restore persisted settings ────────────────────────────────────────────
+    // ── Restore persisted settings ────────────────────────────────────────
     const savedSettings = loadSettings()
     if (savedSettings) {
         currentNutRadius = savedSettings.nutRadius
@@ -67,6 +68,10 @@ async function main(): Promise<void> {
             vibrateEnabled = false
             setVibrateEnabled(false)
         }
+        const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock']
+        if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) {
+            currentKind = savedSettings.kind as ItemKind
+        }
     }
 
     // ── Restore nut positions (share URL takes priority over snapshot) ────────
@@ -90,6 +95,7 @@ async function main(): Promise<void> {
                 if (kind === 'gem') physics.addGem(x, y, r, gv)
                 else if (kind === 'balloon') physics.addBalloon(x, y, r, gv)
                 else if (kind === 'metalnut') physics.addMetalNut(x, y, r)
+                else if (kind === 'rock') physics.addRock(x, y, r)
                 else physics.addNut(x, y, r)
             },
             initialHasSpace,
@@ -103,6 +109,7 @@ async function main(): Promise<void> {
                 if (n.kind === 'gem') physics.addGem(x, y, n.radius, n.gemVariant)
                 else if (n.kind === 'balloon') physics.addBalloon(x, y, n.radius, n.colorIndex)
                 else if (n.kind === 'metalnut') physics.addMetalNut(x, y, n.radius)
+                else if (n.kind === 'rock') physics.addRock(x, y, n.radius)
                 else physics.addNut(x, y, n.radius)
             }
         }
@@ -114,9 +121,10 @@ async function main(): Promise<void> {
         initialLocked: tapsLocked,
         initialGravityMode: gravityMode,
         initialVibrateEnabled: vibrateEnabled,
+        initialKind: currentKind,
         onSizeChange: (radius) => {
             currentNutRadius = radius
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
         },
         onClear: () => {
             physics.clearNuts()
@@ -124,22 +132,24 @@ async function main(): Promise<void> {
         },
         onLockChange: (locked) => {
             tapsLocked = locked
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
+        },
+        onKindChange: (kind) => {
+            currentKind = kind
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
         },
-        onKindChange: (kind) => { currentKind = kind },
         onGravityModeChange: (mode) => {
             gravityMode = mode
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
         },
         onVibrateChange: (enabled) => {
             vibrateEnabled = enabled
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
         },
         getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
     })
 
     // ── Helpers ───────────────────────────────────────────────────────────────
-    let currentKind: ItemKind = 'random'
 
     function hasSpace(x: number, y: number, r: number): boolean {
         for (const { body, radius } of physics.nuts) {
@@ -157,11 +167,12 @@ async function main(): Promise<void> {
             let kind = currentKind
             if (kind === 'random') {
                 const rand = Math.random()
-                kind = rand < 0.20 ? 'balloon' : rand < 0.40 ? 'gem' : rand < 0.55 ? 'metalnut' : 'nut'
+                kind = rand < 0.20 ? 'balloon' : rand < 0.38 ? 'gem' : rand < 0.50 ? 'metalnut' : rand < 0.65 ? 'rock' : 'nut'
             }
             if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius)
             else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius)
             else if (kind === 'metalnut') physics.addMetalNut(cx, cy, currentNutRadius)
+            else if (kind === 'rock') physics.addRock(cx, cy, currentNutRadius)
             else physics.addNut(cx, cy, currentNutRadius)
         }
     }
@@ -301,8 +312,8 @@ async function main(): Promise<void> {
 
         // ── Auto-gravity modes ───────────────────────────────────────────────
         if (gravityMode === 'tumbler') {
-            // Full 360° rotation at ~18 seconds per revolution
-            const angle = (timestamp / 1000) * Math.PI * 2
+            // Full 360° rotation at 0.1 Hz (10 seconds per revolution)
+            const angle = (timestamp / 1000) * 0.1 * Math.PI * 2
             physics.setGravity(Math.sin(angle), Math.cos(angle))
         } else if (gravityMode === 'rock') {
             // Oscillate ±45° around landscape-down (gravity pointing right in portrait)
@@ -327,7 +338,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 })
+        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind })
     })
 }
 
diff --git a/src/persistence.ts b/src/persistence.ts
index 24edf50..c560eba 100644
--- a/src/persistence.ts
+++ b/src/persistence.ts
@@ -12,6 +12,7 @@ export interface PersistedSettings {
     nutRadius: number
     gravityMode?: string
     vibrateEnabled?: boolean
+    kind?: string
 }
 
 export function loadSettings(): PersistedSettings | null {
@@ -40,7 +41,7 @@ export interface NutSnapshot {
     angle: number
     radius: number  // absolute px
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon' | 'metalnut'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
     gemVariant: number
 }
 
@@ -52,7 +53,7 @@ export interface SnapshotRecord {
 }
 
 export function saveSnapshot(
-    nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon' | 'metalnut'; gemVariant: number }>,
+    nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'; gemVariant: number }>,
     screenW: number,
     screenH: number,
 ): void {
diff --git a/src/physics.ts b/src/physics.ts
index a5457b0..b3fb85b 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -11,8 +11,9 @@ export interface NutBody {
     body: Matter.Body
     radius: number
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon' | 'metalnut'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
     gemVariant: number  // gems: 0=diamond…4=amethyst  balloons: colorIndex
+    hitCount: number    // rocks: number of qualifying impacts received
 }
 
 export interface PhysicsWorld {
@@ -22,6 +23,7 @@ export interface PhysicsWorld {
     addGem: (x: number, y: number, radius?: number, gemVariant?: number) => void
     addBalloon: (x: number, y: number, radius?: number, colorIndex?: number) => void
     addMetalNut: (x: number, y: number, nutRadius?: number) => void
+    addRock: (x: number, y: number, radius?: number) => void
     removeAt: (x: number, y: number) => boolean
     clearNuts: () => void
     resize: (width: number, height: number) => void
@@ -64,7 +66,7 @@ function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number):
         frictionAir: CONFIG.frictionAir,
         label: 'nut',
     })
-    return { body, radius, colorIndex, kind: 'nut', gemVariant: 0 }
+    return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0 }
 }
 
 function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
@@ -78,7 +80,7 @@ function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: numb
         frictionAir: 0.0008,
         label: 'metalnut',
     })
-    return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0 }
+    return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0 }
 }
 
 const GEM_SIDES = [4, 6, 6, 8, 6] as const  // sides per gem variant
@@ -97,7 +99,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 }
+    return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0 }
 }
 
 function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody {
@@ -110,11 +112,39 @@ function spawnBalloon(x: number, y: number, radius: number, colorIndex: number):
         frictionAir: 0.008,
         label: 'balloon',
     })
-    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex }
+    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0 }
 }
 
 // ── Factory ────────────────────────────────────────────────────────────────
 
+const ROCK_COLORS_COUNT = 4
+const ROCK_SPLIT_SPEED = 6   // min px/tick to count as a hit
+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 body = Bodies.fromVertices(x, y, [verts], {
+        density: 0.0025,   // granite-like: ~2.5× default
+        restitution: 0.25, // rocks don't bounce much
+        friction: 0.65,
+        frictionAir: 0.001,
+        label: 'rock',
+    })
+    return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0 }
+}
+
 export function createPhysicsWorld(width: number, height: number): PhysicsWorld {
     const engine = Engine.create()
     engine.gravity.x = 0
@@ -124,6 +154,12 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
     let nuts: NutBody[] = []
     let currentWidth = width
     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)
 
@@ -147,45 +183,66 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         for (const pair of event.pairs) {
             const aWall = pair.bodyA.label === 'wall'
             const bWall = pair.bodyB.label === 'wall'
-            const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' || pair.bodyA.label === 'metalnut'
-            const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut'
+            const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' || pair.bodyA.label === 'metalnut' || pair.bodyA.label === 'rock'
+            const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut' || pair.bodyB.label === 'rock'
 
             const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut'
             const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut'
+            const aRock = pair.bodyA.label === 'rock'
+            const bRock = pair.bodyB.label === 'rock'
             const gemInvolved = aGem || bGem
 
+            // 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 (!nb) return
+                    nb.hitCount++
+                    if (nb.hitCount < ROCK_HITS_TO_SPLIT) return
+                    splittingIds.add(b.id)
+                    splitQueue.push(nb)
+                }
+                if (aRock) tryQueueSplit(pair.bodyA)
+                if (bRock) tryQueueSplit(pair.bodyB)
+            }
+
             if (aNut && bNut) {
-                // Nut/gem-nut/gem collision
-                const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed)
+                // Body-body collision
                 dbg('nut-nut speed:', speed.toFixed(2))
                 if (speed > CONFIG.impactThreshold) {
-                    if (aGem && bGem) {
-                        // gem/metal-gem/metal: sharp short clack
+                    if (aRock || bRock) {
+                        // rock collision: earthy thud
+                        const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1)
+                        vibrate(Math.round(12 + t * 28))
+                    } else if (aGem && bGem) {
                         const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1)
                         vibrate(Math.round(8 + t * 18))
                     } else if (gemInvolved) {
-                        // gem/metal-nut: medium clack
                         const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1)
                         vibrate(Math.round(10 + t * 22))
                     } else {
-                        // nut-nut: original soft
                         const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
                         vibrate(Math.round(10 + t * 30))
                     }
                 }
             } else if (aNut || bNut) {
-                // body-wall collision
+                // Body-wall collision
                 const nutBody = aWall ? pair.bodyB : pair.bodyA
-                if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut') continue
-                const speed = nutBody.speed
-                dbg('wall hit speed:', speed.toFixed(2))
-                if (speed > CONFIG.impactThreshold) {
-                    if (nutBody.label === 'gem' || nutBody.label === 'metalnut') {
-                        // gem/metal-wall: sharp hard clack
-                        const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1)
+                if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut' && nutBody.label !== 'rock') continue
+                const wallSpeed = nutBody.speed
+                dbg('wall hit speed:', wallSpeed.toFixed(2))
+                if (wallSpeed > CONFIG.impactThreshold) {
+                    if (nutBody.label === 'rock') {
+                        const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1)
+                        vibrate(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))
                     } else {
-                        const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
+                        const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1)
                         vibrate(Math.round(30 + t * (CONFIG.maxVibrationMs - 30)))
                     }
                 }
@@ -226,6 +283,78 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Composite.add(engine.world, m.body)
     }
 
+    function addRock(x: number, y: number, radius: number = CONFIG.nutBaseRadius): void {
+        const colorIndex = Math.floor(Math.random() * ROCK_COLORS_COUNT)
+        const rock = spawnRock(x, y, radius, colorIndex)
+        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 {
+        if (splitQueue.length === 0) return
+        const toSplit = splitQueue.splice(0)
+        for (const nut of toSplit) {
+            splittingIds.delete(nut.body.id)
+            const idx = nuts.indexOf(nut)
+            if (idx === -1) continue
+            const { position, velocity } = nut.body
+            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
+            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,
+                })
+                nuts.push(fragment)
+                Composite.add(engine.world, fragment.body)
+            }
+        }
+    }
+
     /** Remove the topmost body whose bounding circle contains (x, y). Returns true if one was removed. */
     function removeAt(x: number, y: number): boolean {
         for (let i = nuts.length - 1; i >= 0; i--) {
@@ -271,6 +400,8 @@ 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 {
@@ -285,6 +416,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         addGem,
         addBalloon,
         addMetalNut,
+        addRock,
         removeAt,
         clearNuts,
         resize,
diff --git a/src/renderer.ts b/src/renderer.ts
index 041837c..aa2cb80 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,4 +1,5 @@
 import type { NutBody } from './physics'
+import Matter from 'matter-js'
 import { CONFIG } from './config'
 import QRCode from 'qrcode'
 
@@ -321,6 +322,108 @@ function buildMetalSprite(radius: number, colorIndex: number): HTMLCanvasElement
     return oc
 }
 
+// ── Rock (drawn directly from world-space body vertices) ───────────────────
+
+const ROCK_PALETTE = [
+    { fill: '#7A5A38', hi: '#A07A52', dark: '#4A3218', stroke: '#2E1C08' },
+    { fill: '#6A5028', hi: '#8E7045', dark: '#3C2A0E', stroke: '#221608' },
+    { fill: '#887060', hi: '#A89080', dark: '#604840', stroke: '#3A2820' },
+    { fill: '#705540', hi: '#987860', dark: '#483020', stroke: '#2C1810' },
+]
+
+function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: number, colorIndex: number, hitCount: number): void {
+    const c = ROCK_PALETTE[colorIndex % ROCK_PALETTE.length]
+    const verts = body.vertices
+    const cx = body.position.x
+    const cy = body.position.y
+    const blurR = Math.ceil(radius * 0.15)
+
+    function rockPath(ox = 0, oy = 0) {
+        ctx.beginPath()
+        ctx.moveTo(verts[0].x + ox, verts[0].y + oy)
+        for (let i = 1; i < verts.length; i++) ctx.lineTo(verts[i].x + ox, verts[i].y + oy)
+        ctx.closePath()
+    }
+
+    // Drop shadow
+    ctx.save()
+    ctx.filter = `blur(${blurR}px)`
+    ctx.globalAlpha = 0.35
+    ctx.fillStyle = '#000'
+    rockPath(radius * 0.07, radius * 0.1)
+    ctx.fill()
+    ctx.restore()
+
+    // Base metallic-earthy radial gradient
+    const grad = ctx.createRadialGradient(
+        cx - radius * 0.2, cy - radius * 0.25, radius * 0.05,
+        cx, cy, radius * 0.95
+    )
+    grad.addColorStop(0, c.hi)
+    grad.addColorStop(0.5, c.fill)
+    grad.addColorStop(1, c.dark)
+    rockPath()
+    ctx.fillStyle = grad
+    ctx.fill()
+
+    // Rim stroke
+    ctx.lineWidth = Math.max(1.5, radius * 0.05)
+    ctx.strokeStyle = c.stroke
+    ctx.stroke()
+
+    // Specular highlight clipped to rock shape
+    ctx.save()
+    rockPath()
+    ctx.clip()
+    ctx.beginPath()
+    ctx.ellipse(cx - radius * 0.2, cy - radius * 0.22, radius * 0.3, radius * 0.18, -0.5, 0, Math.PI * 2)
+    ctx.fillStyle = 'rgba(255,255,255,0.14)'
+    ctx.fill()
+    ctx.restore()
+
+    // Cracks — stable per body ID, grow with hit progress
+    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)
+        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})`
+            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.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.stroke()
+            ctx.lineWidth = Math.max(0.6, radius * 0.03 * (0.5 + progress))
+        }
+        ctx.restore()
+    }
+}
+
 function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'metalnut', gemVariant: number): HTMLCanvasElement {
     const r = roundToNearest4(radius)
     const key = kind === 'gem'
@@ -476,7 +579,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         ctx.fillRect(0, 0, w, h)
 
         // Nuts, gems & balloons
-        for (const { body, radius, colorIndex, kind, gemVariant } of nuts) {
+        for (const { body, radius, colorIndex, kind, gemVariant, hitCount } of nuts) {
             const { position, angle } = body
 
             if (kind === 'balloon') {
@@ -485,6 +588,8 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                 ctx.rotate(gravityAngle)
                 drawBalloon(ctx, 0, 0, radius, colorIndex)
                 ctx.restore()
+            } else if (kind === 'rock') {
+                drawRock(ctx, body, radius, colorIndex, hitCount)
             } else {
                 const sprite = getSprite(radius, colorIndex, kind as 'nut' | 'gem' | 'metalnut', gemVariant)
                 ctx.save()
diff --git a/src/share.ts b/src/share.ts
index da51cb8..5138bf4 100644
--- a/src/share.ts
+++ b/src/share.ts
@@ -10,7 +10,7 @@ import type { NutBody } from './physics'
 export interface SharedNut {
     radius: number
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon' | 'metalnut'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
     gemVariant: number
 }
 
@@ -24,6 +24,7 @@ export function encodeNuts(nuts: NutBody[]): string {
         if (n.kind === 'gem') return `r${Math.round(n.radius)}g${n.gemVariant}`
         if (n.kind === 'balloon') return `r${Math.round(n.radius)}b${n.colorIndex % 7}`
         if (n.kind === 'metalnut') return `r${Math.round(n.radius)}m${n.colorIndex % 5}`
+        if (n.kind === 'rock') return `r${Math.round(n.radius)}k${n.colorIndex % 4}`
         return `r${Math.round(n.radius)}c${n.colorIndex % 5}`
     }).join(',')
 }
@@ -38,6 +39,8 @@ export function decodeNuts(encoded: string): SharedNut[] | null {
             if (balloon) return { radius: parseInt(balloon[1], 10), colorIndex: parseInt(balloon[2], 10), kind: 'balloon' as const, gemVariant: parseInt(balloon[2], 10) }
             const metal = part.match(/^r(\d+)m(\d+)$/)
             if (metal) return { radius: parseInt(metal[1], 10), colorIndex: parseInt(metal[2], 10), kind: 'metalnut' as const, gemVariant: 0 }
+            const rock = part.match(/^r(\d+)k(\d+)$/)
+            if (rock) return { radius: parseInt(rock[1], 10), colorIndex: parseInt(rock[2], 10), kind: 'rock' as const, gemVariant: 0 }
             const nut = part.match(/^r(\d+)c(\d+)$/)
             if (nut) return { radius: parseInt(nut[1], 10), colorIndex: parseInt(nut[2], 10), kind: 'nut' as const, gemVariant: 0 }
             throw new Error('invalid')
@@ -66,7 +69,9 @@ export function buildShareURL(nuts: NutBody[], nutRadius: number): string {
                 ? `r${Math.round(n.radius)}b${n.colorIndex % 7}`
                 : n.kind === 'metalnut'
                     ? `r${Math.round(n.radius)}m${n.colorIndex % 5}`
-                    : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
+                    : n.kind === 'rock'
+                        ? `r${Math.round(n.radius)}k${n.colorIndex % 4}`
+                        : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
         const candidate = base + encodeURIComponent(',' + [...parts, token].join(','))
         if (candidate.length > MAX_URL_LENGTH) break
         parts.push(token)
@@ -105,7 +110,7 @@ export function placeSharedNuts(
     nuts: SharedNut[],
     screenW: number,
     screenH: number,
-    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut', gemVariant: number) => void,
+    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock', gemVariant: number) => void,
     hasSpace: (x: number, y: number, r: number) => boolean,
 ): void {
     for (const { radius, colorIndex: _ci, kind, gemVariant } of nuts) {
diff --git a/src/style.css b/src/style.css
index 99f056e..b5ba3ef 100644
--- a/src/style.css
+++ b/src/style.css
@@ -167,6 +167,10 @@ body {
     color: #C0C8D8;
 }
 
+.toolbar-btn.kind-rock {
+    color: #9A7858;
+}
+
 .toolbar-btn.kind-random {
     color: #aaa;
 }
diff --git a/src/ui.ts b/src/ui.ts
index f2c7020..ad40100 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -4,16 +4,17 @@ import QRCode from 'qrcode'
 import { hasVibration } from './compat'
 import { setVibrateEnabled, isVibrateEnabled } from './vibration'
 
-export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut'
+export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
 export type GravityMode = 'device' | 'tumbler' | 'rock'
 
-const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut']
+const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock']
 const KIND_ICON: Record<ItemKind, string> = {
     random: '?',
     nut: '🌰',
     gem: '💎',
     balloon: '🎈',
     metalnut: '🔩',
+    rock: '🪨',
 }
 
 export interface UICallbacks {
@@ -21,6 +22,7 @@ export interface UICallbacks {
     initialLocked: boolean
     initialGravityMode: GravityMode
     initialVibrateEnabled: boolean
+    initialKind: ItemKind
     onSizeChange: (radius: number) => void
     onClear: () => void
     onLockChange: (locked: boolean) => void
@@ -82,7 +84,8 @@ export function initUI(callbacks: UICallbacks): void {
     })
 
     // ── Kind toggle ──────────────────────────────────────────────────────────
-    let kindIndex = 0  // starts at 'random'
+    let kindIndex = KIND_SEQUENCE.indexOf(callbacks.initialKind)
+    if (kindIndex === -1) kindIndex = 0
     kindBtn.textContent = KIND_ICON[KIND_SEQUENCE[kindIndex]]
     kindBtn.className = `toolbar-btn kind-${KIND_SEQUENCE[kindIndex]}`