fuzzyworld

nuts / commit

bug fixes

5dba9120a4509b048c1e2da88484f8470b0c96ef · Pavel Safronov · 2026-04-12 19:46:50 -0700 · parents cfe80f49f345

bug fixes
 src/main.ts     |   6 +--
 src/physics.ts  |  76 +++++++++++++++++----------------
 src/renderer.ts | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/share.ts    |  16 ++-----
 4 files changed, 170 insertions(+), 58 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index 58503c3..3efbf4f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -55,7 +55,7 @@ async function main(): Promise<void> {
     let vibrateItemsEnabled = true
     let vibrateEdgesEnabled = true
     let currentKind: ItemKind = 'random'
-    const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock']
+    const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball']
     let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL]
 
     // ── Restore persisted settings ────────────────────────────────────────
@@ -77,7 +77,7 @@ async function main(): Promise<void> {
             vibrateEdgesEnabled = false
             setVibrateEdges(false)
         }
-        const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock']
+        const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball']
         if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) {
             currentKind = savedSettings.kind as ItemKind
         }
@@ -263,7 +263,6 @@ async function main(): Promise<void> {
         if (motionEnabled) return
         motionEnabled = true
         dbg('startMotion, vibrate?', 'vibrate' in navigator, 'protocol:', location.protocol)
-        let lastGx = 0, lastGy = 0
         initMotion({
             onShake: (intensity) => {
                 physics.applyShakeForce(intensity)
@@ -271,7 +270,6 @@ async function main(): Promise<void> {
             onOrientation: (gx, gy) => {
                 if (gravityMode === 'device') {
                     physics.setGravity(gx, gy)
-                    lastGx = gx; lastGy = gy
                 }
             },
         })
diff --git a/src/physics.ts b/src/physics.ts
index e4beb25..25841cf 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -6,6 +6,9 @@ import { ROCK_TEMPLATES } from './rockTemplates'
 
 const { Engine, Bodies, Body, Composite, Events, Vector } = Matter
 
+// ── Item label set (single source of truth for "is this an item body?") ────
+export const ITEM_LABELS = new Set(['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] as const)
+
 // ── Types ──────────────────────────────────────────────────────────────────
 
 export interface NutBody {
@@ -13,8 +16,8 @@ export interface NutBody {
     radius: number
     colorIndex: number
     kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
-    gemVariant: number  // gems: 0=diamond…4=amethyst  balloons: colorIndex
-    hitCount: number    // rocks: accumulated damage
+    gemVariant: number  // gems: 0=diamond…4=amethyst; always 0 for non-gems
+    state: number    // rocks: damage (0→10+); balloons/stressballs: squish (0→1); 0 for others
     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
 }
@@ -70,7 +73,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, templateIdx: 0, toughness: 1 }
+    return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 }
 }
 
 function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
@@ -84,7 +87,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, templateIdx: 0, toughness: 1 }
+    return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 }
 }
 
 const GEM_SIDES = [4, 6, 6, 8, 6] as const  // sides per gem variant
@@ -92,15 +95,6 @@ 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
     const actualRadius = radius * variance
@@ -113,7 +107,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, templateIdx: 0, toughness: 1 }
+    return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, state: 0, templateIdx: 0, toughness: 1 }
 }
 
 const BALLOON_POP_SPEED = 18     // speed (px/tick) that instantly pops a balloon
@@ -129,7 +123,7 @@ function spawnBalloon(x: number, y: number, radius: number, colorIndex: number):
         frictionAir: 0.006,
         label: 'balloon',
     })
-    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 }
+    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 }
 }
 
 const STRESSBALL_SQUISH_DECAY = 0.80  // faster recovery than balloon
@@ -144,7 +138,7 @@ function spawnStressBall(x: number, y: number, radius: number, colorIndex: numbe
         frictionAir: 0.015,
         label: 'stressball',
     })
-    return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 }
+    return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 }
 }
 
 // ── Factory ────────────────────────────────────────────────────────────────
@@ -169,7 +163,7 @@ function spawnRock(x: number, y: number, targetRadius: number, colorIndex: numbe
     })
     // 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 }
+    return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, state: 0, templateIdx, toughness }
 }
 
 export function createPhysicsWorld(width: number, height: number): PhysicsWorld {
@@ -179,8 +173,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
 
     let walls = buildWalls(width, height)
     let nuts: NutBody[] = []
-    let currentWidth = width
-    let currentHeight = height
+    const nutBodyMap = new Map<Matter.Body, NutBody>()
     let splitQueue: NutBody[] = []
     const splittingIds = new Set<number>()
     let popQueue: NutBody[] = []
@@ -195,9 +188,9 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         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
+                if (nb.state > 0) {
+                    nb.state *= BALLOON_SQUISH_DECAY
+                    if (nb.state < 0.005) nb.state = 0
                 }
                 // Buoyancy
                 Body.applyForce(nb.body, nb.body.position, {
@@ -205,9 +198,9 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                     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
+                if (nb.state > 0) {
+                    nb.state *= STRESSBALL_SQUISH_DECAY
+                    if (nb.state < 0.005) nb.state = 0
                 }
             }
         }
@@ -218,8 +211,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' || 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 aNut = ITEM_LABELS.has(pair.bodyA.label as never)
+            const bNut = ITEM_LABELS.has(pair.bodyB.label as never)
 
             const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut'
             const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut'
@@ -239,8 +232,8 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                     // 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
+                    nb.state += (excess * excess * massRatio) / (ROCK_SPLIT_SPEED * ROCK_SPLIT_SPEED * 1.25 * nb.toughness)
+                    if (nb.state < ROCK_HITS_TO_SPLIT) return
                     splittingIds.add(rockBody.id)
                     splitQueue.push(nb)
                 }
@@ -256,12 +249,12 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             if (aBalloon || bBalloon) {
                 const applyPressure = (balloonBody: Matter.Body) => {
                     if (poppingIds.has(balloonBody.id)) return
-                    const nb = nuts.find(n => n.body === balloonBody)
+                    const nb = nutBodyMap.get(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) {
+                    nb.state = Math.min(nb.state + pressure, 1.5)
+                    if (nb.state >= 1.0) {
                         poppingIds.add(balloonBody.id)
                         popQueue.push(nb)
                     }
@@ -273,10 +266,10 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             // ── Stress ball squish (no pop) ─────────────────────────────
             if (aStress || bStress) {
                 const applySquish = (sbBody: Matter.Body) => {
-                    const nb = nuts.find(n => n.body === sbBody)
+                    const nb = nutBodyMap.get(sbBody)
                     if (!nb) return
                     const pressure = Math.pow(speed / 14, 1.2)
-                    nb.hitCount = Math.min(nb.hitCount + pressure, 1.0)
+                    nb.state = Math.min(nb.state + pressure, 1.0)
                 }
                 if (aStress) applySquish(pair.bodyA)
                 if (bStress) applySquish(pair.bodyB)
@@ -304,7 +297,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' && nutBody.label !== 'stressball') continue
+                if (!ITEM_LABELS.has(nutBody.label as never)) continue
                 const wallSpeed = nutBody.speed
                 dbg('wall hit speed:', wallSpeed.toFixed(2))
                 if (wallSpeed > CONFIG.impactThreshold) {
@@ -330,6 +323,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         const nut = spawnNut(x, y, nutRadius, colorIndex)
         Body.setAngle(nut.body, Math.random() * Math.PI * 2)
         nuts.push(nut)
+        nutBodyMap.set(nut.body, nut)
         Composite.add(engine.world, nut.body)
     }
 
@@ -338,6 +332,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         const gem = spawnGem(x, y, radius, variant)
         Body.setAngle(gem.body, Math.random() * Math.PI * 2)
         nuts.push(gem)
+        nutBodyMap.set(gem.body, gem)
         Composite.add(engine.world, gem.body)
     }
 
@@ -345,6 +340,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         const ci = colorIndex ?? Math.floor(Math.random() * BALLOON_COLOR_COUNT)
         const balloon = spawnBalloon(x, y, radius, ci)
         nuts.push(balloon)
+        nutBodyMap.set(balloon.body, balloon)
         Composite.add(engine.world, balloon.body)
     }
 
@@ -352,6 +348,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         const ci = colorIndex ?? Math.floor(Math.random() * STRESSBALL_COLOR_COUNT)
         const sb = spawnStressBall(x, y, radius, ci)
         nuts.push(sb)
+        nutBodyMap.set(sb.body, sb)
         Composite.add(engine.world, sb.body)
     }
 
@@ -360,6 +357,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         const m = spawnMetalNut(x, y, nutRadius, colorIndex)
         Body.setAngle(m.body, Math.random() * Math.PI * 2)
         nuts.push(m)
+        nutBodyMap.set(m.body, m)
         Composite.add(engine.world, m.body)
     }
 
@@ -368,6 +366,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         const rock = spawnRock(x, y, radius, colorIndex)
         Body.setAngle(rock.body, Math.random() * Math.PI * 2)
         nuts.push(rock)
+        nutBodyMap.set(rock.body, rock)
         Composite.add(engine.world, rock.body)
     }
 
@@ -378,6 +377,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             poppingIds.delete(nut.body.id)
             const idx = nuts.indexOf(nut)
             if (idx === -1) continue
+            nutBodyMap.delete(nut.body)
             Composite.remove(engine.world, nut.body)
             nuts.splice(idx, 1)
             vibrate(70)  // satisfying pop
@@ -395,6 +395,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             const pos = { x: nut.body.position.x, y: nut.body.position.y }
             const vel = { x: nut.body.velocity.x, y: nut.body.velocity.y }
 
+            nutBodyMap.delete(nut.body)
             Composite.remove(engine.world, nut.body)
             nuts.splice(idx, 1)
 
@@ -419,6 +420,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                     y: vel.y + Math.sin(spreadAngle) * (2 + Math.random() * 2),
                 })
                 nuts.push(frag)
+                nutBodyMap.set(frag.body, frag)
                 Composite.add(engine.world, frag.body)
             }
         }
@@ -431,6 +433,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             const dx = body.position.x - x
             const dy = body.position.y - y
             if (dx * dx + dy * dy <= radius * radius) {
+                nutBodyMap.delete(body)
                 Composite.remove(engine.world, body)
                 nuts.splice(i, 1)
                 return true
@@ -441,12 +444,11 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
 
     function clearNuts(): void {
         for (const { body } of nuts) Composite.remove(engine.world, body)
+        nutBodyMap.clear()
         nuts = []
     }
 
     function resize(newWidth: number, newHeight: number): void {
-        currentWidth = newWidth
-        currentHeight = newHeight
         for (const wall of walls) Composite.remove(engine.world, wall)
         walls = buildWalls(newWidth, newHeight)
         Composite.add(engine.world, walls)
diff --git a/src/renderer.ts b/src/renderer.ts
index d4a8e27..b4d56a7 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,5 +1,5 @@
 import type { NutBody } from './physics'
-import Matter from 'matter-js'
+import type Matter from 'matter-js'
 import { CONFIG } from './config'
 import QRCode from 'qrcode'
 import { ROCK_TEMPLATES } from './rockTemplates'
@@ -439,6 +439,28 @@ function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'me
     return sprite
 }
 
+function getBalloonBodySprite(radius: number, colorIndex: number): HTMLCanvasElement {
+    const r = roundToNearest4(radius)
+    const key = `balloon_${r}_${colorIndex % BALLOON_COLORS.length}`
+    let sprite = spriteCache.get(key)
+    if (!sprite) {
+        sprite = buildBalloonBodySprite(r, colorIndex)
+        spriteCache.set(key, sprite)
+    }
+    return sprite
+}
+
+function getStressBallSprite(radius: number, colorIndex: number): HTMLCanvasElement {
+    const r = roundToNearest4(radius)
+    const key = `stressball_${r}_${colorIndex % STRESSBALL_COLORS.length}`
+    let sprite = spriteCache.get(key)
+    if (!sprite) {
+        sprite = buildStressBallBodySprite(r, colorIndex)
+        spriteCache.set(key, sprite)
+    }
+    return sprite
+}
+
 // ── Stress ball palette ───────────────────────────────────────────────────
 const STRESSBALL_COLORS = [
     { body: '#d94040', shadow: '#8a1515', hi: '#f08080' },  // red
@@ -449,6 +471,17 @@ const STRESSBALL_COLORS = [
     { body: '#8850c0', shadow: '#441878', hi: '#c094e8' },  // purple
 ]
 
+function buildStressBallBodySprite(radius: number, colorIndex: number): HTMLCanvasElement {
+    const r = radius
+    const pad = Math.ceil(r * 0.26)
+    const size = Math.ceil(r * 2 + pad * 2)
+    const oc = document.createElement('canvas')
+    oc.width = size
+    oc.height = size
+    drawStressBall(oc.getContext('2d')!, size / 2, size / 2, r, colorIndex, 0)
+    return oc
+}
+
 function drawStressBall(
     ctx: CanvasRenderingContext2D,
     x: number, y: number,
@@ -551,6 +584,52 @@ const BALLOON_COLORS: BalloonColor[] = [
     { body: '#22DDEE', hi: '#88EEFF', shadow: '#117788', rim: '#11CCDD', string: '#0099AA' }, // cyan
 ]
 
+function buildBalloonBodySprite(radius: number, colorIndex: number): HTMLCanvasElement {
+    const c = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length]
+    const r = radius
+    const pad = Math.ceil(r * 0.28)
+    const size = Math.ceil(r * 2 + pad * 2)
+    const oc = document.createElement('canvas')
+    oc.width = size
+    oc.height = size
+    const bctx = oc.getContext('2d')!
+    const cx = size / 2, cy = size / 2
+    // Shadow (blur done once, cached)
+    bctx.save()
+    bctx.filter = `blur(${Math.ceil(r * 0.14)}px)`
+    bctx.globalAlpha = 0.32
+    bctx.fillStyle = '#000'
+    bctx.beginPath()
+    bctx.arc(cx + r * 0.12, cy + r * 0.15, r * 0.88, 0, Math.PI * 2)
+    bctx.fill()
+    bctx.restore()
+    // Body
+    const grad = bctx.createRadialGradient(cx - r * 0.3, cy - r * 0.35, r * 0.04, cx, cy, r)
+    grad.addColorStop(0, c.hi)
+    grad.addColorStop(0.55, c.body)
+    grad.addColorStop(1, c.shadow)
+    bctx.beginPath()
+    bctx.arc(cx, cy, r, 0, Math.PI * 2)
+    bctx.fillStyle = grad
+    bctx.fill()
+    bctx.strokeStyle = c.rim
+    bctx.lineWidth = Math.max(1.5, r * 0.03)
+    bctx.stroke()
+    // Specular highlight
+    bctx.save()
+    bctx.beginPath()
+    bctx.arc(cx, cy, r, 0, Math.PI * 2)
+    bctx.clip()
+    const hiGrad = bctx.createRadialGradient(cx - r * 0.3, cy - r * 0.38, 0, cx - r * 0.1, cy - r * 0.1, r * 0.65)
+    hiGrad.addColorStop(0, 'rgba(255,255,255,0.78)')
+    hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.18)')
+    hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
+    bctx.fillStyle = hiGrad
+    bctx.fillRect(0, 0, size, size)
+    bctx.restore()
+    return oc
+}
+
 function drawBalloon(
     ctx: CanvasRenderingContext2D,
     x: number, y: number,
@@ -671,7 +750,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         ctx.fillRect(0, 0, w, h)
 
         // Nuts, gems & balloons
-        for (const { body, radius, colorIndex, kind, gemVariant, hitCount, templateIdx } of nuts) {
+        for (const { body, radius, colorIndex, kind, gemVariant, state: hitCount, templateIdx } of nuts) {
             const { position, angle } = body
 
             if (kind === 'balloon') {
@@ -690,7 +769,29 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                         ctx.rotate(-velAngle)
                     }
                 }
-                drawBalloon(ctx, 0, 0, radius, colorIndex)
+                // Cached body sphere (blur shadow pre-rendered once)
+                const balloonSprite = getBalloonBodySprite(radius, colorIndex)
+                ctx.drawImage(balloonSprite, -balloonSprite.width / 2, -balloonSprite.height / 2)
+                // Inline knot + string (fast, no filter)
+                const bknotH = Math.max(4, radius * 0.13)
+                const bstringLen = radius * 0.9
+                const bc = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length]
+                ctx.beginPath()
+                ctx.moveTo(0, radius + bknotH * 1.6)
+                ctx.lineTo(-bknotH * 0.55, radius)
+                ctx.lineTo(bknotH * 0.55, radius)
+                ctx.closePath()
+                ctx.fillStyle = bc.rim
+                ctx.fill()
+                ctx.beginPath()
+                ctx.moveTo(0, radius + bknotH * 1.6)
+                ctx.quadraticCurveTo(radius * 0.18, radius + bstringLen * 0.5, radius * 0.06, radius + bstringLen + bknotH * 1.6)
+                ctx.strokeStyle = bc.string
+                ctx.lineWidth = Math.max(1, radius * 0.025)
+                ctx.save()
+                ctx.globalAlpha *= 0.75
+                ctx.stroke()
+                ctx.restore()
                 ctx.restore()
             } else if (kind === 'stressball') {
                 ctx.save()
@@ -706,7 +807,28 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                         ctx.rotate(-velAngle)
                     }
                 }
-                drawStressBall(ctx, 0, 0, radius, colorIndex, squish)
+                // Cached body sprite (blur shadow pre-rendered once)
+                const sbSprite = getStressBallSprite(radius, colorIndex)
+                ctx.drawImage(sbSprite, -sbSprite.width / 2, -sbSprite.height / 2)
+                // Inline squish stress rings (fast, no filter)
+                if (squish > 0.05) {
+                    const sc = STRESSBALL_COLORS[colorIndex % STRESSBALL_COLORS.length]
+                    ctx.save()
+                    ctx.beginPath()
+                    ctx.arc(0, 0, radius, 0, Math.PI * 2)
+                    ctx.clip()
+                    ctx.globalAlpha = squish * 0.35
+                    ctx.strokeStyle = sc.shadow
+                    ctx.lineWidth = Math.max(1, radius * 0.04)
+                    ctx.beginPath()
+                    ctx.arc(0, 0, radius * 0.6, 0, Math.PI * 2)
+                    ctx.stroke()
+                    ctx.globalAlpha = squish * 0.20
+                    ctx.beginPath()
+                    ctx.arc(0, 0, radius * 0.35, 0, Math.PI * 2)
+                    ctx.stroke()
+                    ctx.restore()
+                }
                 ctx.restore()
             } else if (kind === 'rock') {
                 drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx)
diff --git a/src/share.ts b/src/share.ts
index b192083..fa19f0d 100644
--- a/src/share.ts
+++ b/src/share.ts
@@ -63,20 +63,10 @@ const MAX_URL_LENGTH = 1800  // comfortable QR limit at medium error-correction
 export function buildShareURL(nuts: NutBody[], nutRadius: number): string {
     const settingsPart = `z${Math.round(nutRadius)}`
     const base = `${BASE_URL}#s=${encodeURIComponent(settingsPart)}`
-    // Fit items greedily
+    // Fit items greedily — encode each token via encodeNuts and count length
+    const allTokens = encodeNuts(nuts).split(',').filter(Boolean)
     const parts: string[] = []
-    for (const n of nuts) {
-        const token = n.kind === 'gem'
-            ? `r${Math.round(n.radius)}g${n.gemVariant}`
-            : n.kind === 'balloon'
-                ? `r${Math.round(n.radius)}b${n.colorIndex % 7}`
-                : n.kind === 'metalnut'
-                    ? `r${Math.round(n.radius)}m${n.colorIndex % 5}`
-                    : n.kind === 'rock'
-                        ? `r${Math.round(n.radius)}k${n.colorIndex % 4}`
-                        : n.kind === 'stressball'
-                            ? `r${Math.round(n.radius)}s${n.colorIndex % 6}`
-                            : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
+    for (const token of allTokens) {
         const candidate = base + encodeURIComponent(',' + [...parts, token].join(','))
         if (candidate.length > MAX_URL_LENGTH) break
         parts.push(token)