fuzzyworld

nuts / commit

added stress ball

cfe80f49f345d08f4b6a37f0105612c54c13c44b · Pavel Safronov · 2026-04-12 18:02:08 -0700 · parents 98ba5504e07c

added stress ball
 src/main.ts        |   5 ++-
 src/persistence.ts |   4 +-
 src/physics.ts     | 127 +++++++++++++++++++++++++++++++++++++++++++++-------
 src/renderer.ts    | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/share.ts       |  11 +++--
 src/ui.ts          |   6 ++-
 6 files changed, 258 insertions(+), 23 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index 71e630b..58503c3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -111,6 +111,7 @@ async function main(): Promise<void> {
                 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 if (kind === 'stressball') physics.addStressBall(x, y, r, gv)
                 else physics.addNut(x, y, r)
             },
             initialHasSpace,
@@ -125,6 +126,7 @@ async function main(): Promise<void> {
                 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 if (n.kind === 'stressball') physics.addStressBall(x, y, n.radius, n.colorIndex)
                 else physics.addNut(x, y, n.radius)
             }
         }
@@ -198,13 +200,14 @@ async function main(): Promise<void> {
         if (hasSpace(cx, cy, currentNutRadius)) {
             let kind = currentKind
             if (kind === 'random') {
-                const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock'] as ItemKind[]
+                const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] 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)
             else if (kind === 'metalnut') physics.addMetalNut(cx, cy, currentNutRadius)
             else if (kind === 'rock') physics.addRock(cx, cy, currentNutRadius)
+            else if (kind === 'stressball') physics.addStressBall(cx, cy, currentNutRadius)
             else physics.addNut(cx, cy, currentNutRadius)
         }
     }
diff --git a/src/persistence.ts b/src/persistence.ts
index 05cc5f4..3680e7e 100644
--- a/src/persistence.ts
+++ b/src/persistence.ts
@@ -44,7 +44,7 @@ export interface NutSnapshot {
     angle: number
     radius: number  // absolute px
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
     gemVariant: number
 }
 
@@ -56,7 +56,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' | 'rock'; gemVariant: number }>,
+    nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'; gemVariant: number }>,
     screenW: number,
     screenH: number,
 ): void {
diff --git a/src/physics.ts b/src/physics.ts
index 53d6c2c..e4beb25 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -12,7 +12,7 @@ export interface NutBody {
     body: Matter.Body
     radius: number
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
     gemVariant: number  // gems: 0=diamond…4=amethyst  balloons: colorIndex
     hitCount: number    // rocks: accumulated damage
     templateIdx: number // rocks: which RockTemplate to use; 0 for non-rocks
@@ -25,6 +25,7 @@ export interface PhysicsWorld {
     addNut: (x: number, y: number, nutRadius?: number) => void
     addGem: (x: number, y: number, radius?: number, gemVariant?: number) => void
     addBalloon: (x: number, y: number, radius?: number, colorIndex?: number) => void
+    addStressBall: (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
@@ -89,6 +90,16 @@ function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: numb
 const GEM_SIDES = [4, 6, 6, 8, 6] as const  // sides per gem variant
 export const GEM_VARIANT_COUNT = GEM_SIDES.length
 export const BALLOON_COLOR_COUNT = 7
+export const STRESSBALL_COLOR_COUNT = 6
+
+const STRESSBALL_COLORS = [
+    '#e05050',  // red
+    '#e07830',  // orange
+    '#d4c020',  // yellow
+    '#50b050',  // green
+    '#4090d0',  // blue
+    '#9060c0',  // purple
+]
 
 function spawnGem(x: number, y: number, radius: number, gemVariant: number): NutBody {
     const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
@@ -105,19 +116,37 @@ function spawnGem(x: number, y: number, radius: number, gemVariant: number): Nut
     return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0, templateIdx: 0, toughness: 1 }
 }
 
+const BALLOON_POP_SPEED = 18     // speed (px/tick) that instantly pops a balloon
+const BALLOON_SQUISH_DECAY = 0.86 // per-physics-tick squish decay multiplier
+
 function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody {
     const variance = 0.5 + Math.random() * 1.0  // 0.5× to 1.5× base radius
     const actualRadius = radius * variance
     const body = Bodies.circle(x, y, actualRadius, {
         density: 0.00005,  // air-filled balloon — nearly weightless
-        restitution: 0.8,
-        friction: 0.01,
-        frictionAir: 0.008,
+        restitution: 0.95, // very bouncy
+        friction: 0.005,
+        frictionAir: 0.006,
         label: 'balloon',
     })
     return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 }
 }
 
+const STRESSBALL_SQUISH_DECAY = 0.80  // faster recovery than balloon
+
+function spawnStressBall(x: number, y: number, radius: number, colorIndex: number): NutBody {
+    const variance = 0.7 + Math.random() * 0.6  // 0.7× to 1.3× base radius
+    const actualRadius = radius * variance
+    const body = Bodies.circle(x, y, actualRadius, {
+        density: 0.0006,   // denser than balloon — has some heft
+        restitution: 0.55, // bouncy but not as floaty as balloon
+        friction: 0.4,     // grippy rubber
+        frictionAir: 0.015,
+        label: 'stressball',
+    })
+    return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 }
+}
+
 // ── Factory ────────────────────────────────────────────────────────────────
 
 const ROCK_COLORS_COUNT = 4
@@ -154,20 +183,32 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
     let currentHeight = height
     let splitQueue: NutBody[] = []
     const splittingIds = new Set<number>()
+    let popQueue: NutBody[] = []
+    const poppingIds = new Set<number>()
 
     Composite.add(engine.world, walls)
 
-    // ── Balloon buoyancy ───────────────────────────────────────────────────
-    // Apply a force opposite to the current gravity vector (2.2× magnitude),
-    // so balloons float against whichever direction gravity is currently pointing.
+    // ── Balloon buoyancy + squish decay ────────────────────────────────────
+    // Balloons float against gravity (2.2× lift) and their squish decays each tick.
     Events.on(engine, 'beforeUpdate', () => {
         const scale = engine.gravity.scale * 2.2
-        for (const { body, kind } of nuts) {
-            if (kind === 'balloon') {
-                Body.applyForce(body, body.position, {
-                    x: -body.mass * engine.gravity.x * scale,
-                    y: -body.mass * engine.gravity.y * scale,
+        for (const nb of nuts) {
+            if (nb.kind === 'balloon') {
+                // Decay squish (hitCount) toward zero each tick
+                if (nb.hitCount > 0) {
+                    nb.hitCount *= BALLOON_SQUISH_DECAY
+                    if (nb.hitCount < 0.005) nb.hitCount = 0
+                }
+                // Buoyancy
+                Body.applyForce(nb.body, nb.body.position, {
+                    x: -nb.body.mass * engine.gravity.x * scale,
+                    y: -nb.body.mass * engine.gravity.y * scale,
                 })
+            } else if (nb.kind === 'stressball') {
+                if (nb.hitCount > 0) {
+                    nb.hitCount *= STRESSBALL_SQUISH_DECAY
+                    if (nb.hitCount < 0.005) nb.hitCount = 0
+                }
             }
         }
     })
@@ -177,8 +218,8 @@ 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' || 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 aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' || pair.bodyA.label === 'metalnut' || pair.bodyA.label === 'rock' || pair.bodyA.label === 'stressball'
+            const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut' || pair.bodyB.label === 'rock' || pair.bodyB.label === 'stressball'
 
             const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut'
             const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut'
@@ -207,6 +248,40 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                 if (bRock) tryQueueSplit(pair.bodyB, pair.bodyA)
             }
 
+            // ── Balloon squish / pop ───────────────────────────────────
+            const aBalloon = pair.bodyA.label === 'balloon'
+            const bBalloon = pair.bodyB.label === 'balloon'
+            const aStress = pair.bodyA.label === 'stressball'
+            const bStress = pair.bodyB.label === 'stressball'
+            if (aBalloon || bBalloon) {
+                const applyPressure = (balloonBody: Matter.Body) => {
+                    if (poppingIds.has(balloonBody.id)) return
+                    const nb = nuts.find(n => n.body === balloonBody)
+                    if (!nb) return
+                    // Pressure scales as speed^1.5 so gentle bounces squish, hard hits pop
+                    const pressure = Math.pow(speed / BALLOON_POP_SPEED, 1.5)
+                    nb.hitCount = Math.min(nb.hitCount + pressure, 1.5)
+                    if (nb.hitCount >= 1.0) {
+                        poppingIds.add(balloonBody.id)
+                        popQueue.push(nb)
+                    }
+                }
+                if (aBalloon) applyPressure(pair.bodyA)
+                if (bBalloon) applyPressure(pair.bodyB)
+            }
+
+            // ── Stress ball squish (no pop) ─────────────────────────────
+            if (aStress || bStress) {
+                const applySquish = (sbBody: Matter.Body) => {
+                    const nb = nuts.find(n => n.body === sbBody)
+                    if (!nb) return
+                    const pressure = Math.pow(speed / 14, 1.2)
+                    nb.hitCount = Math.min(nb.hitCount + pressure, 1.0)
+                }
+                if (aStress) applySquish(pair.bodyA)
+                if (bStress) applySquish(pair.bodyB)
+            }
+
             if (aNut && bNut) {
                 // Body-body collision
                 dbg('nut-nut speed:', speed.toFixed(2))
@@ -229,7 +304,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             } else if (aNut || bNut) {
                 // Body-wall collision
                 const nutBody = aWall ? pair.bodyB : pair.bodyA
-                if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut' && nutBody.label !== 'rock') continue
+                if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut' && nutBody.label !== 'rock' && nutBody.label !== 'stressball') continue
                 const wallSpeed = nutBody.speed
                 dbg('wall hit speed:', wallSpeed.toFixed(2))
                 if (wallSpeed > CONFIG.impactThreshold) {
@@ -273,6 +348,13 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Composite.add(engine.world, balloon.body)
     }
 
+    function addStressBall(x: number, y: number, radius: number = CONFIG.nutBaseRadius, colorIndex?: number): void {
+        const ci = colorIndex ?? Math.floor(Math.random() * STRESSBALL_COLOR_COUNT)
+        const sb = spawnStressBall(x, y, radius, ci)
+        nuts.push(sb)
+        Composite.add(engine.world, sb.body)
+    }
+
     function addMetalNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void {
         const colorIndex = nuts.length
         const m = spawnMetalNut(x, y, nutRadius, colorIndex)
@@ -289,6 +371,19 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Composite.add(engine.world, rock.body)
     }
 
+    function processPops(): void {
+        if (popQueue.length === 0) return
+        const toPop = popQueue.splice(0)
+        for (const nut of toPop) {
+            poppingIds.delete(nut.body.id)
+            const idx = nuts.indexOf(nut)
+            if (idx === -1) continue
+            Composite.remove(engine.world, nut.body)
+            nuts.splice(idx, 1)
+            vibrate(70)  // satisfying pop
+        }
+    }
+
     function processSplits(): void {
         if (splitQueue.length === 0) return
         const toSplit = splitQueue.splice(0)
@@ -375,6 +470,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
     function step(deltaMs: number): void {
         Engine.update(engine, Math.min(deltaMs, 50))
         processSplits()
+        processPops()
     }
 
     function destroy(): void {
@@ -388,6 +484,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         addNut,
         addGem,
         addBalloon,
+        addStressBall,
         addMetalNut,
         addRock,
         removeAt,
diff --git a/src/renderer.ts b/src/renderer.ts
index 327012f..d4a8e27 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -439,6 +439,106 @@ function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'me
     return sprite
 }
 
+// ── Stress ball palette ───────────────────────────────────────────────────
+const STRESSBALL_COLORS = [
+    { body: '#d94040', shadow: '#8a1515', hi: '#f08080' },  // red
+    { body: '#d97030', shadow: '#8a3800', hi: '#f0a878' },  // orange
+    { body: '#c8be18', shadow: '#807600', hi: '#e8e070' },  // yellow
+    { body: '#3aaa3a', shadow: '#1a6a1a', hi: '#78d878' },  // green
+    { body: '#3878cc', shadow: '#183a88', hi: '#80aae8' },  // blue
+    { body: '#8850c0', shadow: '#441878', hi: '#c094e8' },  // purple
+]
+
+function drawStressBall(
+    ctx: CanvasRenderingContext2D,
+    x: number, y: number,
+    radius: number,
+    colorIndex: number,
+    squish: number,
+): void {
+    const c = STRESSBALL_COLORS[colorIndex % STRESSBALL_COLORS.length]
+    const r = radius
+
+    // Shadow
+    ctx.save()
+    ctx.filter = `blur(${Math.ceil(r * 0.12)}px)`
+    ctx.globalAlpha = 0.28
+    ctx.fillStyle = '#000'
+    ctx.beginPath()
+    ctx.arc(x + r * 0.1, y + r * 0.12, r * 0.85, 0, Math.PI * 2)
+    ctx.fill()
+    ctx.restore()
+
+    // Body (slightly flattened at bottom — rubber resting shape)
+    const grad = ctx.createRadialGradient(x - r * 0.28, y - r * 0.28, r * 0.05, x, y, r)
+    grad.addColorStop(0, c.hi)
+    grad.addColorStop(0.5, c.body)
+    grad.addColorStop(1, c.shadow)
+    ctx.beginPath()
+    ctx.arc(x, y, r, 0, Math.PI * 2)
+    ctx.fillStyle = grad
+    ctx.fill()
+
+    // Subtle matte rim (no stroke, just darkened outer band)
+    const rimGrad = ctx.createRadialGradient(x, y, r * 0.7, x, y, r)
+    rimGrad.addColorStop(0, 'rgba(0,0,0,0)')
+    rimGrad.addColorStop(1, 'rgba(0,0,0,0.28)')
+    ctx.beginPath()
+    ctx.arc(x, y, r, 0, Math.PI * 2)
+    ctx.fillStyle = rimGrad
+    ctx.fill()
+
+    // Dimples — small indents scattered over surface
+    const dimpleCount = Math.max(3, Math.floor(r / 12))
+    const angleStep = (Math.PI * 2) / dimpleCount
+    ctx.save()
+    ctx.globalAlpha = 0.22
+    ctx.fillStyle = c.shadow
+    for (let i = 0; i < dimpleCount; i++) {
+        const a = i * angleStep
+        const dr = r * 0.55
+        const dx = x + Math.cos(a) * dr
+        const dy = y + Math.sin(a) * dr
+        const dimR = Math.max(2, r * 0.065)
+        ctx.beginPath()
+        ctx.arc(dx, dy, dimR, 0, Math.PI * 2)
+        ctx.fill()
+    }
+    ctx.restore()
+
+    // Soft specular highlight
+    ctx.save()
+    ctx.beginPath()
+    ctx.arc(x, y, r, 0, Math.PI * 2)
+    ctx.clip()
+    const hiGrad = ctx.createRadialGradient(x - r * 0.28, y - r * 0.32, 0, x - r * 0.1, y - r * 0.1, r * 0.7)
+    hiGrad.addColorStop(0, 'rgba(255,255,255,0.45)')
+    hiGrad.addColorStop(0.5, 'rgba(255,255,255,0.08)')
+    hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
+    ctx.fillStyle = hiGrad
+    ctx.fillRect(x - r, y - r, r * 2, r * 2)
+    ctx.restore()
+
+    // Squish stress lines — faint concentric rings when compressed
+    if (squish > 0.05) {
+        ctx.save()
+        ctx.beginPath()
+        ctx.arc(x, y, r, 0, Math.PI * 2)
+        ctx.clip()
+        ctx.globalAlpha = squish * 0.35
+        ctx.strokeStyle = c.shadow
+        ctx.lineWidth = Math.max(1, r * 0.04)
+        ctx.beginPath()
+        ctx.arc(x, y, r * 0.6, 0, Math.PI * 2)
+        ctx.stroke()
+        ctx.globalAlpha = squish * 0.20
+        ctx.beginPath()
+        ctx.arc(x, y, r * 0.35, 0, Math.PI * 2)
+        ctx.stroke()
+        ctx.restore()
+    }
+}
+
 // ── Balloon palette ─────────────────────────────────────────────────────
 interface BalloonColor { body: string; hi: string; shadow: string; rim: string; string: string }
 const BALLOON_COLORS: BalloonColor[] = [
@@ -578,8 +678,36 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                 ctx.save()
                 ctx.translate(position.x, position.y)
                 ctx.rotate(gravityAngle)
+                // Squish deformation: compress along velocity, expand perpendicular
+                const squish = Math.min(hitCount, 0.85)
+                if (squish > 0.02) {
+                    const vx = body.velocity.x, vy = body.velocity.y
+                    const spd = Math.hypot(vx, vy)
+                    if (spd > 0.5) {
+                        const velAngle = Math.atan2(vy, vx) - gravityAngle
+                        ctx.rotate(velAngle)
+                        ctx.scale(1 - squish * 0.38, 1 + squish * 0.30)
+                        ctx.rotate(-velAngle)
+                    }
+                }
                 drawBalloon(ctx, 0, 0, radius, colorIndex)
                 ctx.restore()
+            } else if (kind === 'stressball') {
+                ctx.save()
+                ctx.translate(position.x, position.y)
+                const squish = Math.min(hitCount, 1.0)
+                if (squish > 0.02) {
+                    const vx = body.velocity.x, vy = body.velocity.y
+                    const spd = Math.hypot(vx, vy)
+                    if (spd > 0.5) {
+                        const velAngle = Math.atan2(vy, vx)
+                        ctx.rotate(velAngle)
+                        ctx.scale(1 - squish * 0.32, 1 + squish * 0.26)
+                        ctx.rotate(-velAngle)
+                    }
+                }
+                drawStressBall(ctx, 0, 0, radius, colorIndex, squish)
+                ctx.restore()
             } else if (kind === 'rock') {
                 drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx)
             } else {
diff --git a/src/share.ts b/src/share.ts
index 5138bf4..b192083 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' | 'rock'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
     gemVariant: number
 }
 
@@ -25,6 +25,7 @@ export function encodeNuts(nuts: NutBody[]): string {
         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}`
+        if (n.kind === 'stressball') return `r${Math.round(n.radius)}s${n.colorIndex % 6}`
         return `r${Math.round(n.radius)}c${n.colorIndex % 5}`
     }).join(',')
 }
@@ -41,6 +42,8 @@ export function decodeNuts(encoded: string): SharedNut[] | null {
             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 stress = part.match(/^r(\d+)s(\d+)$/)
+            if (stress) return { radius: parseInt(stress[1], 10), colorIndex: parseInt(stress[2], 10), kind: 'stressball' as const, gemVariant: parseInt(stress[2], 10) }
             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')
@@ -71,7 +74,9 @@ export function buildShareURL(nuts: NutBody[], nutRadius: number): string {
                     ? `r${Math.round(n.radius)}m${n.colorIndex % 5}`
                     : n.kind === 'rock'
                         ? `r${Math.round(n.radius)}k${n.colorIndex % 4}`
-                        : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
+                        : n.kind === 'stressball'
+                            ? `r${Math.round(n.radius)}s${n.colorIndex % 6}`
+                            : `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)
@@ -110,7 +115,7 @@ export function placeSharedNuts(
     nuts: SharedNut[],
     screenW: number,
     screenH: number,
-    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock', gemVariant: number) => void,
+    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball', 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/ui.ts b/src/ui.ts
index e84a7ce..4c788d4 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -4,10 +4,10 @@ import QRCode from 'qrcode'
 import { hasVibration } from './compat'
 import { setVibrateEnabled, isVibrateEnabled, setVibrateItems, isVibrateItems, setVibrateEdges, isVibrateEdges } from './vibration'
 
-export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'
+export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
 export type GravityMode = 'device' | 'tumbler' | 'rock'
 
-const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock']
+const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball']
 const KIND_ICON: Record<ItemKind, string> = {
     random: '?',
     nut: '🌰',
@@ -15,6 +15,7 @@ const KIND_ICON: Record<ItemKind, string> = {
     balloon: '🎈',
     metalnut: '🔩',
     rock: '🪨',
+    stressball: '🟠',
 }
 
 export interface UICallbacks {
@@ -220,6 +221,7 @@ export function initUI(callbacks: UICallbacks): void {
         { kind: 'balloon', label: '🎈 Balloons' },
         { kind: 'metalnut', label: '🔩 Metal nuts' },
         { kind: 'rock', label: '🪨 Rocks' },
+        { kind: 'stressball', label: '🟠 Stress balls' },
     ]
     let randomPool = new Set<ItemKind>(callbacks.initialRandomPool)