fuzzyworld

nuts / commit

lots of changes, added balloons even

f1262fd9acd2663bf6db0a788e626e614c08171d · Pavel Safronov · 2026-04-12 00:13:10 -0700 · parents 8d4a3ae47e6b

lots of changes, added balloons even
 index.html              |  25 ++-
 package.json            |   5 +-
 pnpm-lock.yaml          | 239 +++++++++++++++++++++++++++++
 public/site.webmanifest |   1 +
 src/compat.ts           |  67 +++++++++
 src/config.ts           |  22 +--
 src/debug.ts            |  82 ++++++++++
 src/env.d.ts            |   6 +
 src/main.ts             | 178 ++++++++++++++++++++--
 src/motion.ts           |  24 ++-
 src/persistence.ts      |  91 +++++++++++
 src/physics.ts          | 168 ++++++++++++++++++---
 src/renderer.ts         | 393 ++++++++++++++++++++++++++++++++++++------------
 src/share.ts            |  82 ++++++++++
 src/style.css           |  73 +++++++++
 src/ui.ts               |  90 +++++++++--
 src/vibration.ts        |  65 +++++++-
 vite.config.ts          |  38 ++++-
 18 files changed, 1474 insertions(+), 175 deletions(-)

diff --git a/index.html b/index.html
index 1813b3b..d7bbb7c 100644
--- a/index.html
+++ b/index.html
@@ -38,6 +38,15 @@
         </div>
     </div>
 
+    <!-- Fullscreen guard overlay -->
+    <div id="fullscreen-overlay" hidden>
+        <div class="start-content">
+            <img class="nut-icon" src="/acorn.png" alt="Acorn icon" width="80" height="80" />
+            <h1>Nuts</h1>
+            <p>Tap to go fullscreen</p>
+        </div>
+    </div>
+
     <!-- Settings button -->
     <button id="settings-btn" aria-label="Settings" hidden>⚙</button>
 
@@ -49,14 +58,22 @@
             <h2>Settings</h2>
 
             <label class="setting-row">
-                <span>Nuts</span>
-                <span id="nut-count-display" class="setting-value">20</span>
+                <span>Size</span>
+                <span id="nut-size-display" class="setting-value">130</span>
             </label>
-            <input type="range" id="nut-count" min="1" max="50" value="20" step="1" />
+            <input type="range" id="nut-size" min="10" max="180" value="130" step="1" />
 
-            <button id="reset-btn" class="action-btn">Reset positions</button>
+            <button id="lock-btn" class="action-btn">⏸ Pause</button>
+            <button id="clear-btn" class="action-btn">Clear</button>
+            <button id="test-vibrate-btn" class="action-btn">Test vibration</button>
             <button id="close-settings" class="close-btn">Done</button>
 
+            <div class="share-section">
+                <p class="share-label">Share this collection</p>
+                <canvas id="qr-canvas" width="160" height="160"></canvas>
+                <button id="copy-link-btn" class="action-btn">Copy link</button>
+            </div>
+
             <div class="panel-links">
                 <a href="https://pavelsafronov.gitlab.io/nuts/" target="_blank" rel="noopener">Live app</a>
                 <span aria-hidden="true">·</span>
diff --git a/package.json b/package.json
index 1dafd7a..24cdaa7 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,14 @@
         "generate-icons": "tsx scripts/generate-icons.ts"
     },
     "dependencies": {
-        "matter-js": "^0.19.0"
+        "matter-js": "^0.19.0",
+        "qrcode": "^1.5.4"
     },
     "devDependencies": {
         "@types/matter-js": "^0.19.6",
         "@types/node": "^25.6.0",
+        "@types/qrcode": "^1.5.6",
+        "@vitejs/plugin-basic-ssl": "^2.3.0",
         "sharp": "^0.34.5",
         "tsx": "^4.21.0",
         "typescript": "^5.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b76ef08..e198ed1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ importers:
       matter-js:
         specifier: ^0.19.0
         version: 0.19.0
+      qrcode:
+        specifier: ^1.5.4
+        version: 1.5.4
     devDependencies:
       '@types/matter-js':
         specifier: ^0.19.6
@@ -18,6 +21,12 @@ importers:
       '@types/node':
         specifier: ^25.6.0
         version: 25.6.0
+      '@types/qrcode':
+        specifier: ^1.5.6
+        version: 1.5.6
+      '@vitejs/plugin-basic-ssl':
+        specifier: ^2.3.0
+        version: 2.3.0(vite@5.4.21(@types/node@25.6.0))
       sharp:
         specifier: ^0.34.5
         version: 0.34.5
@@ -604,10 +613,51 @@ packages:
   '@types/node@25.6.0':
     resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
 
+  '@types/qrcode@1.5.6':
+    resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
+  '@vitejs/plugin-basic-ssl@2.3.0':
+    resolution: {integrity: sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    peerDependencies:
+      vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+  ansi-regex@5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+
+  ansi-styles@4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+
+  camelcase@5.3.1:
+    resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+    engines: {node: '>=6'}
+
+  cliui@6.0.0:
+    resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  decamelize@1.2.0:
+    resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+    engines: {node: '>=0.10.0'}
+
   detect-libc@2.1.2:
     resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
     engines: {node: '>=8'}
 
+  dijkstrajs@1.0.3:
+    resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
+  emoji-regex@8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
   esbuild@0.21.5:
     resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
     engines: {node: '>=12'}
@@ -618,14 +668,30 @@ packages:
     engines: {node: '>=18'}
     hasBin: true
 
+  find-up@4.1.0:
+    resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+    engines: {node: '>=8'}
+
   fsevents@2.3.3:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
     os: [darwin]
 
+  get-caller-file@2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+
   get-tsconfig@4.13.7:
     resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
 
+  is-fullwidth-code-point@3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+
+  locate-path@5.0.0:
+    resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+    engines: {node: '>=8'}
+
   matter-js@0.19.0:
     resolution: {integrity: sha512-v2huwvQGOHTGOkMqtHd2hercCG3f6QAObTisPPHg8TZqq2lz7eIY/5i/5YUV8Ibf3mEioFEmwibcPUF2/fnKKQ==}
 
@@ -634,13 +700,45 @@ packages:
     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
     hasBin: true
 
+  p-limit@2.3.0:
+    resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+    engines: {node: '>=6'}
+
+  p-locate@4.1.0:
+    resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+    engines: {node: '>=8'}
+
+  p-try@2.2.0:
+    resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+    engines: {node: '>=6'}
+
+  path-exists@4.0.0:
+    resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+    engines: {node: '>=8'}
+
   picocolors@1.1.1:
     resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
 
+  pngjs@5.0.0:
+    resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+    engines: {node: '>=10.13.0'}
+
   postcss@8.5.9:
     resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
     engines: {node: ^10 || ^12 || >=14}
 
+  qrcode@1.5.4:
+    resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+
+  require-directory@2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+
+  require-main-filename@2.0.0:
+    resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
   resolve-pkg-maps@1.0.0:
     resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
 
@@ -654,6 +752,9 @@ packages:
     engines: {node: '>=10'}
     hasBin: true
 
+  set-blocking@2.0.0:
+    resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
   sharp@0.34.5:
     resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -662,6 +763,14 @@ packages:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
 
+  string-width@4.2.3:
+    resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+    engines: {node: '>=8'}
+
+  strip-ansi@6.0.1:
+    resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+    engines: {node: '>=8'}
+
   tslib@2.8.1:
     resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
 
@@ -717,6 +826,24 @@ packages:
       terser:
         optional: true
 
+  which-module@2.0.1:
+    resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
+  wrap-ansi@6.2.0:
+    resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+    engines: {node: '>=8'}
+
+  y18n@4.0.3:
+    resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
+  yargs-parser@18.1.3:
+    resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+    engines: {node: '>=6'}
+
+  yargs@15.4.1:
+    resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+    engines: {node: '>=8'}
+
 snapshots:
 
   '@emnapi/runtime@1.9.2':
@@ -1050,8 +1177,42 @@ snapshots:
     dependencies:
       undici-types: 7.19.2
 
+  '@types/qrcode@1.5.6':
+    dependencies:
+      '@types/node': 25.6.0
+
+  '@vitejs/plugin-basic-ssl@2.3.0(vite@5.4.21(@types/node@25.6.0))':
+    dependencies:
+      vite: 5.4.21(@types/node@25.6.0)
+
+  ansi-regex@5.0.1: {}
+
+  ansi-styles@4.3.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  camelcase@5.3.1: {}
+
+  cliui@6.0.0:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 6.2.0
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  decamelize@1.2.0: {}
+
   detect-libc@2.1.2: {}
 
+  dijkstrajs@1.0.3: {}
+
+  emoji-regex@8.0.0: {}
+
   esbuild@0.21.5:
     optionalDependencies:
       '@esbuild/aix-ppc64': 0.21.5
@@ -1107,25 +1268,62 @@ snapshots:
       '@esbuild/win32-ia32': 0.27.7
       '@esbuild/win32-x64': 0.27.7
 
+  find-up@4.1.0:
+    dependencies:
+      locate-path: 5.0.0
+      path-exists: 4.0.0
+
   fsevents@2.3.3:
     optional: true
 
+  get-caller-file@2.0.5: {}
+
   get-tsconfig@4.13.7:
     dependencies:
       resolve-pkg-maps: 1.0.0
 
+  is-fullwidth-code-point@3.0.0: {}
+
+  locate-path@5.0.0:
+    dependencies:
+      p-locate: 4.1.0
+
   matter-js@0.19.0: {}
 
   nanoid@3.3.11: {}
 
+  p-limit@2.3.0:
+    dependencies:
+      p-try: 2.2.0
+
+  p-locate@4.1.0:
+    dependencies:
+      p-limit: 2.3.0
+
+  p-try@2.2.0: {}
+
+  path-exists@4.0.0: {}
+
   picocolors@1.1.1: {}
 
+  pngjs@5.0.0: {}
+
   postcss@8.5.9:
     dependencies:
       nanoid: 3.3.11
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
+  qrcode@1.5.4:
+    dependencies:
+      dijkstrajs: 1.0.3
+      pngjs: 5.0.0
+      yargs: 15.4.1
+
+  require-directory@2.1.1: {}
+
+  require-main-filename@2.0.0: {}
+
   resolve-pkg-maps@1.0.0: {}
 
   rollup@4.60.1:
@@ -1161,6 +1359,8 @@ snapshots:
 
   semver@7.7.4: {}
 
+  set-blocking@2.0.0: {}
+
   sharp@0.34.5:
     dependencies:
       '@img/colour': 1.1.0
@@ -1194,6 +1394,16 @@ snapshots:
 
   source-map-js@1.2.1: {}
 
+  string-width@4.2.3:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.1
+
+  strip-ansi@6.0.1:
+    dependencies:
+      ansi-regex: 5.0.1
+
   tslib@2.8.1:
     optional: true
 
@@ -1223,3 +1433,32 @@ snapshots:
     optionalDependencies:
       '@types/node': 25.6.0
       fsevents: 2.3.3
+
+  which-module@2.0.1: {}
+
+  wrap-ansi@6.2.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  y18n@4.0.3: {}
+
+  yargs-parser@18.1.3:
+    dependencies:
+      camelcase: 5.3.1
+      decamelize: 1.2.0
+
+  yargs@15.4.1:
+    dependencies:
+      cliui: 6.0.0
+      decamelize: 1.2.0
+      find-up: 4.1.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      require-main-filename: 2.0.0
+      set-blocking: 2.0.0
+      string-width: 4.2.3
+      which-module: 2.0.1
+      y18n: 4.0.3
+      yargs-parser: 18.1.3
diff --git a/public/site.webmanifest b/public/site.webmanifest
index 2298244..78cd33d 100644
--- a/public/site.webmanifest
+++ b/public/site.webmanifest
@@ -4,6 +4,7 @@
     "description": "A box of nuts bouncing around with haptic feedback.",
     "start_url": "./",
     "display": "fullscreen",
+    "orientation": "portrait",
     "background_color": "#111111",
     "theme_color": "#111111",
     "icons": [
diff --git a/src/compat.ts b/src/compat.ts
new file mode 100644
index 0000000..b35c5f7
--- /dev/null
+++ b/src/compat.ts
@@ -0,0 +1,67 @@
+export type CompatResult =
+    | { ok: true }
+    | { ok: false; reason: 'no-touch' | 'no-motion' | 'no-vibration' }
+
+/**
+ * Check whether this platform can run the full experience.
+ * Returns immediately (no async, no permissions needed).
+ */
+export function checkCompat(): CompatResult {
+    // Must be a touch device
+    if (!('ontouchstart' in window) && !navigator.maxTouchPoints) {
+        return { ok: false, reason: 'no-touch' }
+    }
+
+    // Must have DeviceMotion (accelerometer / tilt gravity)
+    if (typeof DeviceMotionEvent === 'undefined') {
+        return { ok: false, reason: 'no-motion' }
+    }
+
+    // Must have the Vibration API
+    if (!('vibrate' in navigator) || typeof navigator.vibrate !== 'function') {
+        return { ok: false, reason: 'no-vibration' }
+    }
+
+    return { ok: true }
+}
+
+const MESSAGES: Record<string, { headline: string; body: string }> = {
+    'no-touch': {
+        headline: 'Phone only',
+        body: 'This app needs a touchscreen to work.\nOpen it on your Android phone in Chrome.',
+    },
+    'no-motion': {
+        headline: 'No accelerometer',
+        body: 'This app needs a motion sensor to work.\nOpen it on your Android phone in Chrome.',
+    },
+    'no-vibration': {
+        headline: 'No haptics',
+        body: 'This app needs vibration to work.\nOpen it in Chrome on your Android phone\n— Firefox and iOS don\'t support haptics.',
+    },
+}
+
+/** Replace the page body with a full-screen "not supported" wall. */
+export function showUnsupportedWall(reason: string): void {
+    const { headline, body } = MESSAGES[reason] ?? {
+        headline: 'Not supported',
+        body: 'Open this on your Android phone in Chrome.',
+    }
+
+    document.body.innerHTML = `
+      <div style="
+        position:fixed;inset:0;
+        background:#111;
+        display:flex;flex-direction:column;align-items:center;justify-content:center;
+        padding:32px;gap:20px;
+        font-family:system-ui,-apple-system,sans-serif;
+        text-align:center;
+      ">
+        <div style="font-size:72px;line-height:1;filter:grayscale(1) opacity(0.4)">🔩</div>
+        <h1 style="color:#fff;font-size:28px;font-weight:700;margin:0">${headline}</h1>
+        <p style="color:#666;font-size:16px;line-height:1.6;margin:0;white-space:pre-line">${body}</p>
+        <a href="https://pavelsafronov.gitlab.io/nuts/"
+           style="margin-top:8px;color:#c8a84b;font-size:13px;opacity:0.6;text-decoration:none">
+          pavelsafronov.gitlab.io/nuts
+        </a>
+      </div>`
+}
diff --git a/src/config.ts b/src/config.ts
index 6e385e4..581613a 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,24 +1,24 @@
 export const CONFIG = {
     // Nuts
-    defaultNutCount: 20,
-    minNuts: 1,
-    maxNuts: 50,
-    nutBaseRadius: 22,        // px, circumradius of hex polygon
-    nutRadiusVariance: 0.28,  // ±28% random size variance
+    nutBaseRadius: 130,       // px – semi-major axis (runtime overridden by size slider)
+    nutMinRadius: 10,         // px – slider minimum
+    nutMaxRadius: 180,        // px – slider maximum
+    nutAspectRatio: 1.75,     // width:height – peanut-like elongation
+    nutRadiusVariance: 0.12,  // ±12% size variance
 
     // Physics
-    restitution: 0.45,   // bounciness
-    friction: 0.18,      // surface friction
-    frictionAir: 0.012,  // air resistance / damping
+    restitution: 0.7,    // bounciness
+    friction: 0.02,      // surface friction
+    frictionAir: 0.002,  // air resistance / damping
     gravityScale: 1,
 
     // Walls
     wallThickness: 60,
 
     // Collision haptics
-    impactThreshold: 3,       // min speed (px/step) to trigger vibration
-    maxVibrationMs: 80,       // cap on vibration duration
-    vibrationCooldownMs: 40,  // min ms between vibration calls
+    impactThreshold: 0.5,     // min speed (px/step) to trigger vibration
+    maxVibrationMs: 120,      // cap on vibration duration
+    vibrationCooldownMs: 150, // min ms between vibration calls
 
     // Shake
     shakeAccelThreshold: 14,  // m/s² delta to count as shake
diff --git a/src/debug.ts b/src/debug.ts
new file mode 100644
index 0000000..f827aa7
--- /dev/null
+++ b/src/debug.ts
@@ -0,0 +1,82 @@
+/**
+ * Tiny on-screen debug log + remote terminal forwarder.
+ * Call dbg('some message') from anywhere to show it in the overlay
+ * and forward it to the Vite dev server terminal as [web] prefix.
+ * Tap the overlay to dismiss.
+ *
+ * In production (import.meta.env.PROD) all calls are no-ops.
+ */
+
+const ENABLED = false  // flip to true to re-enable on-screen log + remote forwarding
+
+let panel: HTMLDivElement | null = null
+const MAX_LINES = 20
+const lines: string[] = []
+
+function getPanel(): HTMLDivElement {
+    if (panel) return panel
+    panel = document.createElement('div')
+    Object.assign(panel.style, {
+        position: 'fixed',
+        bottom: '0',
+        left: '0',
+        right: '0',
+        maxHeight: '40vh',
+        overflowY: 'auto',
+        background: 'rgba(0,0,0,0.78)',
+        color: '#0f0',
+        fontFamily: 'monospace',
+        fontSize: '11px',
+        padding: '8px',
+        zIndex: '9999',
+        whiteSpace: 'pre-wrap',
+        wordBreak: 'break-all',
+        pointerEvents: 'auto',
+    })
+    panel.addEventListener('click', () => {
+        panel?.remove()
+        panel = null
+        lines.length = 0
+    })
+    document.body.appendChild(panel)
+    return panel
+}
+
+function ts(): string {
+    const d = new Date()
+    return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}.${d.getMilliseconds().toString().padStart(3, '0')}`
+}
+
+function sendToServer(level: 'log' | 'error', msg: string): void {
+    // Best-effort: fire-and-forget, never throws
+    fetch('/__log', {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({ level, msg }),
+    }).catch(() => { /* dev server may not be listening */ })
+}
+
+function display(msg: string, isError = false): void {
+    lines.push(msg)
+    if (lines.length > MAX_LINES) lines.shift()
+    const p = getPanel()
+    p.style.color = isError ? '#f55' : '#0f0'
+    p.textContent = lines.join('\n')
+    p.scrollTop = p.scrollHeight
+}
+
+export function dbg(...args: unknown[]): void {
+    if (import.meta.env.PROD || !ENABLED) return
+    const msg = `[${ts()}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}`
+    console.log(msg)
+    display(msg)
+    sendToServer('log', msg)
+}
+
+export function dbgError(...args: unknown[]): void {
+    if (import.meta.env.PROD || !ENABLED) return
+    const msg = `[${ts()}] ❌ ${args.map(a => a instanceof Error ? a.message : typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}`
+    console.error(msg)
+    display(msg, true)
+    sendToServer('error', msg)
+}
diff --git a/src/env.d.ts b/src/env.d.ts
new file mode 100644
index 0000000..9f90c76
--- /dev/null
+++ b/src/env.d.ts
@@ -0,0 +1,6 @@
+/// <reference types="vite/client" />
+
+declare module '*.png' {
+    const url: string
+    export default url
+}
diff --git a/src/main.ts b/src/main.ts
index 89241c7..b95c2a7 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3,10 +3,24 @@ import { createPhysicsWorld } from './physics'
 import { createRenderer } from './renderer'
 import { requestMotionPermission, initMotion } from './motion'
 import { initUI, removeStartOverlay } from './ui'
+import { setFlashFallback, unlockVibration } from './vibration'
+import { dbg, dbgError } from './debug'
+import { checkCompat, showUnsupportedWall } from './compat'
+import {
+    loadSettings, saveSettings,
+    loadSnapshot, saveSnapshot, clearSnapshot,
+} from './persistence'
+import { buildShareURL, parseShareFromURL, placeSharedNuts } from './share'
 
 // ── Bootstrap ──────────────────────────────────────────────────────────────
 
 async function main(): Promise<void> {
+    const compat = checkCompat()
+    if (!compat.ok) {
+        showUnsupportedWall(compat.reason)
+        return
+    }
+
     const canvas = document.getElementById('canvas') as HTMLCanvasElement
 
     // Match canvas to the physical pixel dimensions for crisp rendering on HiDPI
@@ -30,24 +44,128 @@ 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)
 
-    let nutCount: number = CONFIG.defaultNutCount
-    physics.setNutCount(nutCount)
+    let currentNutRadius: number = CONFIG.nutBaseRadius
+    let tapsLocked = false
+
+    // ── Restore persisted settings ────────────────────────────────────────────
+    const savedSettings = loadSettings()
+    if (savedSettings) {
+        currentNutRadius = savedSettings.nutRadius
+        tapsLocked = savedSettings.locked
+    }
+
+    // ── Restore nut positions (share URL takes priority over snapshot) ────────
+    const sharedNuts = parseShareFromURL()
+    if (sharedNuts && sharedNuts.length > 0) {
+        clearSnapshot()
+        // hasSpace not available yet — define inline for initial placement
+        const placed: Array<{ x: number; y: number; r: number }> = []
+        function initialHasSpace(x: number, y: number, r: number): boolean {
+            for (const p of placed) {
+                const dx = p.x - x; const dy = p.y - y
+                if (dx * dx + dy * dy < (p.r + r) ** 2) return false
+            }
+            return true
+        }
+        placeSharedNuts(sharedNuts, cssWidth, cssHeight,
+            (x, y, r, kind, gv) => {
+                placed.push({ x, y, r })
+                if (kind === 'gem') physics.addGem(x, y, r, gv)
+                else if (kind === 'balloon') physics.addBalloon(x, y, r, gv)
+                else physics.addNut(x, y, r)
+            },
+            initialHasSpace,
+        )
+    } else {
+        const snapshot = loadSnapshot()
+        if (snapshot) {
+            for (const n of snapshot.nuts) {
+                const x = n.x * cssWidth
+                const y = n.y * cssHeight
+                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 physics.addNut(x, y, n.radius)
+            }
+        }
+    }
 
     // ── UI ───────────────────────────────────────────────────────────────────
     initUI({
-        onNutCountChange: (count) => {
-            nutCount = count
-            physics.setNutCount(count)
+        initialRadius: currentNutRadius,
+        initialLocked: tapsLocked,
+        onSizeChange: (radius) => {
+            currentNutRadius = radius
+            saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked })
         },
-        onReset: () => {
-            physics.setNutCount(nutCount)
+        onClear: () => {
+            physics.clearNuts()
+            clearSnapshot()
         },
+        onLockChange: (locked) => {
+            tapsLocked = locked
+            saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked })
+        },
+        getShareURL: () => buildShareURL(physics.nuts),
     })
 
+    // ── Helpers ───────────────────────────────────────────────────────────────
+    function hasSpace(x: number, y: number, r: number): boolean {
+        for (const { body, radius } of physics.nuts) {
+            const dx = body.position.x - x
+            const dy = body.position.y - y
+            const minDist = radius + r
+            if (dx * dx + dy * dy < minDist * minDist) return false
+        }
+        return true
+    }
+
+    function tryAddNut(cx: number, cy: number): void {
+        if (tapsLocked) return  // already guarded at pointerdown, but keep for safety
+        if (hasSpace(cx, cy, currentNutRadius)) {
+            const rand = Math.random()
+            if (rand < 0.20) physics.addBalloon(cx, cy, currentNutRadius)
+            else if (rand < 0.45) physics.addGem(cx, cy, currentNutRadius)
+            else physics.addNut(cx, cy, currentNutRadius)
+        }
+    }
+
+    // ── Pointer: tap/drag to add; tap/drag-through to erase ─────────────────
+    // Mode is decided on pointerdown: if we hit an existing item we erase,
+    // otherwise we add. The mode persists until pointerup.
+    let pointerDown = false
+    let eraseMode = false
+
+    canvas.addEventListener('pointerdown', (e: PointerEvent) => {
+        if (tapsLocked) return
+        pointerDown = true
+        canvas.setPointerCapture(e.pointerId)
+        const rect = canvas.getBoundingClientRect()
+        const x = e.clientX - rect.left
+        const y = e.clientY - rect.top
+        // Hit an existing item → erase mode
+        eraseMode = physics.removeAt(x, y)
+        if (!eraseMode) tryAddNut(x, y)
+    })
+
+    canvas.addEventListener('pointermove', (e: PointerEvent) => {
+        if (!pointerDown) return
+        const rect = canvas.getBoundingClientRect()
+        const x = e.clientX - rect.left
+        const y = e.clientY - rect.top
+        if (eraseMode) physics.removeAt(x, y)
+        else tryAddNut(x, y)
+    })
+
+    canvas.addEventListener('pointerup', () => { pointerDown = false; eraseMode = false })
+    canvas.addEventListener('pointercancel', () => { pointerDown = false; eraseMode = false })
+
     // ── Resize ───────────────────────────────────────────────────────────────
     window.addEventListener('resize', () => {
         cssWidth = window.innerWidth
@@ -68,6 +186,7 @@ async function main(): Promise<void> {
     function startMotion(): void {
         if (motionEnabled) return
         motionEnabled = true
+        dbg('startMotion, vibrate?', 'vibrate' in navigator, 'protocol:', location.protocol)
         initMotion({
             onShake: (intensity) => {
                 physics.applyShakeForce(intensity)
@@ -78,6 +197,31 @@ async function main(): Promise<void> {
         })
     }
 
+    // ── Fullscreen + orientation lock ──────────────────────────────────────
+    async function enterFullscreen(): Promise<void> {
+        try {
+            await document.documentElement.requestFullscreen({ navigationUI: 'hide' })
+        } catch {
+            // Not supported or denied – ignore
+        }
+        try {
+            const orientation = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise<void> }
+            await orientation.lock?.('portrait')
+        } catch {
+            // Not supported (desktop, or browser restriction) – ignore
+        }
+    }
+
+    // ── Fullscreen guard ──────────────────────────────────────────────────────
+    const fullscreenOverlay = document.getElementById('fullscreen-overlay')!
+    document.addEventListener('fullscreenchange', () => {
+        const isFullscreen = !!document.fullscreenElement
+        fullscreenOverlay.hidden = isFullscreen
+    })
+    fullscreenOverlay.addEventListener('click', async () => {
+        await enterFullscreen()
+    })
+
     // ── Start overlay / permission ────────────────────────────────────────────
     const startOverlay = document.getElementById('start-overlay')!
     startOverlay.addEventListener(
@@ -85,9 +229,11 @@ async function main(): Promise<void> {
         async () => {
             try {
                 await requestMotionPermission()
-            } catch {
-                // Permission denied or unavailable – proceed without motion
+            } catch (e) {
+                dbgError('requestMotionPermission failed', e)
             }
+            unlockVibration()  // must be called from gesture handler
+            await enterFullscreen()
             startMotion()
             removeStartOverlay()
             startLoop()
@@ -120,10 +266,22 @@ async function main(): Promise<void> {
         lastTimestamp = timestamp
 
         physics.step(delta)
-        renderer.render(physics.nuts)
+        const g = physics.engine.gravity
+        renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2)
+
+        // Save snapshot every ~3 s
+        if (Math.floor(timestamp / 3000) !== Math.floor((timestamp - delta) / 3000)) {
+            saveSnapshot(physics.nuts, cssWidth, cssHeight)
+        }
 
         requestAnimationFrame(loop)
     }
+
+    // Also save on tab close / navigation
+    window.addEventListener('pagehide', () => {
+        saveSnapshot(physics.nuts, cssWidth, cssHeight)
+        saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked })
+    })
 }
 
 main().catch(console.error)
diff --git a/src/motion.ts b/src/motion.ts
index 220b35f..c01df9f 100644
--- a/src/motion.ts
+++ b/src/motion.ts
@@ -1,5 +1,6 @@
 import { CONFIG } from './config'
 import { vibrateShake } from './vibration'
+import { dbg, dbgError } from './debug'
 
 export interface MotionCallbacks {
     onShake: (intensity: number) => void
@@ -15,9 +16,12 @@ export async function requestMotionPermission(): Promise<void> {
         typeof (DeviceMotionEvent as unknown as { requestPermission: () => Promise<string> })
             .requestPermission === 'function'
     ) {
-        await (
+        const result = await (
             DeviceMotionEvent as unknown as { requestPermission: () => Promise<string> }
         ).requestPermission()
+        dbg('DeviceMotionEvent permission:', result)
+    } else {
+        dbg('DeviceMotionEvent: no permission API (Android or desktop)')
     }
 
     // DeviceOrientationEvent
@@ -27,20 +31,34 @@ export async function requestMotionPermission(): Promise<void> {
         typeof (DeviceOrientationEvent as unknown as { requestPermission: () => Promise<string> })
             .requestPermission === 'function'
     ) {
-        await (
+        const result = await (
             DeviceOrientationEvent as unknown as { requestPermission: () => Promise<string> }
         ).requestPermission()
+        dbg('DeviceOrientationEvent permission:', result)
     }
 }
 
 /** Attach DeviceMotion (shake) and DeviceOrientation (gravity tilt) listeners. */
 export function initMotion(callbacks: MotionCallbacks): () => void {
+    dbg('initMotion: attaching listeners, secure?', location.protocol)
+
+    if (typeof DeviceMotionEvent === 'undefined') {
+        dbgError('DeviceMotionEvent not available')
+    }
+    if (typeof DeviceOrientationEvent === 'undefined') {
+        dbgError('DeviceOrientationEvent not available')
+    }
+
     let prevX = 0
     let prevY = 0
     let prevZ = 0
     let lastCheckAt = 0
+    let motionCount = 0
+    let orientationCount = 0
 
     function onMotion(event: DeviceMotionEvent): void {
+        motionCount++
+        if (motionCount === 1) dbg('first devicemotion event')
         const acc = event.accelerationIncludingGravity
         if (!acc) return
 
@@ -67,6 +85,8 @@ export function initMotion(callbacks: MotionCallbacks): () => void {
     }
 
     function onOrientation(event: DeviceOrientationEvent): void {
+        orientationCount++
+        if (orientationCount === 1) dbg('first deviceorientation event')
         if (event.beta === null || event.gamma === null) return
 
         // beta  = front/back tilt, 0 = flat, 90 = vertical facing up
diff --git a/src/persistence.ts b/src/persistence.ts
new file mode 100644
index 0000000..f380786
--- /dev/null
+++ b/src/persistence.ts
@@ -0,0 +1,91 @@
+const DATA_VERSION = 2
+
+const KEYS = {
+    settings: 'nuts-settings-v2',
+    snapshot: 'nuts-snapshot-v2',
+} as const
+
+// ── Settings ───────────────────────────────────────────────────────────────
+
+export interface PersistedSettings {
+    v: number
+    nutRadius: number
+    locked: boolean
+}
+
+export function loadSettings(): PersistedSettings | null {
+    try {
+        const raw = localStorage.getItem(KEYS.settings)
+        if (!raw) return null
+        const parsed = JSON.parse(raw) as PersistedSettings
+        if (parsed.v !== DATA_VERSION) return null
+        return parsed
+    } catch {
+        return null
+    }
+}
+
+export function saveSettings(s: Omit<PersistedSettings, 'v'>): void {
+    try {
+        localStorage.setItem(KEYS.settings, JSON.stringify({ v: DATA_VERSION, ...s }))
+    } catch { /* storage full – ignore */ }
+}
+
+// ── Nut snapshot ───────────────────────────────────────────────────────────
+
+export interface NutSnapshot {
+    x: number       // normalised 0..1 (relative to screen size)
+    y: number
+    angle: number
+    radius: number  // absolute px
+    colorIndex: number
+    kind: 'nut' | 'gem' | 'balloon'
+    gemVariant: number
+}
+
+export interface SnapshotRecord {
+    v: number
+    screenW: number
+    screenH: number
+    nuts: NutSnapshot[]
+}
+
+export function saveSnapshot(
+    nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon'; gemVariant: number }>,
+    screenW: number,
+    screenH: number,
+): void {
+    try {
+        const record: SnapshotRecord = {
+            v: DATA_VERSION,
+            screenW,
+            screenH,
+            nuts: nuts.map(n => ({
+                x: n.body.position.x / screenW,
+                y: n.body.position.y / screenH,
+                angle: n.body.angle,
+                radius: n.radius,
+                colorIndex: n.colorIndex,
+                kind: n.kind,
+                gemVariant: n.gemVariant,
+            })),
+        }
+        localStorage.setItem(KEYS.snapshot, JSON.stringify(record))
+    } catch { /* storage full – ignore */ }
+}
+
+export function loadSnapshot(): SnapshotRecord | null {
+    try {
+        const raw = localStorage.getItem(KEYS.snapshot)
+        if (!raw) return null
+        const record = JSON.parse(raw) as SnapshotRecord
+        if (record.v !== DATA_VERSION) return null
+        return record
+    } catch {
+        return null
+    }
+}
+
+export function clearSnapshot(): void {
+    localStorage.removeItem(KEYS.snapshot)
+}
diff --git a/src/physics.ts b/src/physics.ts
index a14b787..8a0267d 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -1,8 +1,9 @@
 import Matter from 'matter-js'
 import { CONFIG } from './config'
 import { vibrate } from './vibration'
+import { dbg } from './debug'
 
-const { Engine, Bodies, Body, Composite, Events } = Matter
+const { Engine, Bodies, Body, Composite, Events, Vector } = Matter
 
 // ── Types ──────────────────────────────────────────────────────────────────
 
@@ -10,12 +11,18 @@ export interface NutBody {
     body: Matter.Body
     radius: number
     colorIndex: number
+    kind: 'nut' | 'gem' | 'balloon'
+    gemVariant: number  // gems: 0=diamond…4=amethyst  balloons: colorIndex
 }
 
 export interface PhysicsWorld {
     engine: Matter.Engine
     nuts: NutBody[]
-    setNutCount: (count: number) => void
+    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
+    removeAt: (x: number, y: number) => boolean
+    clearNuts: () => void
     resize: (width: number, height: number) => void
     applyShakeForce: (intensity: number) => void
     setGravity: (gx: number, gy: number) => void
@@ -36,19 +43,55 @@ function buildWalls(width: number, height: number): Matter.Body[] {
     ]
 }
 
-function spawnNut(width: number, height: number, colorIndex: number): NutBody {
+function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
     const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
-    const radius = CONFIG.nutBaseRadius * variance
-    const margin = radius * 2
-    const x = margin + Math.random() * (width - margin * 2)
-    const y = margin + Math.random() * (height * 0.5)
-    const body = Bodies.polygon(x, y, 6, radius, {
+    const radius = nutRadius * variance
+    const ry = radius / CONFIG.nutAspectRatio
+
+    // Convex ellipse polygon approximation (20 vertices)
+    const N = 20
+    const verts: Matter.Vector[] = []
+    for (let i = 0; i < N; i++) {
+        const a = (i / N) * Math.PI * 2
+        verts.push(Vector.create(Math.cos(a) * radius, Math.sin(a) * ry))
+    }
+
+    const body = Bodies.fromVertices(x, y, [verts], {
         restitution: CONFIG.restitution,
         friction: CONFIG.friction,
         frictionAir: CONFIG.frictionAir,
         label: 'nut',
     })
-    return { body, radius, colorIndex }
+    return { body, radius, colorIndex, kind: 'nut', gemVariant: 0 }
+}
+
+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
+
+function spawnGem(x: number, y: number, radius: number, gemVariant: number): NutBody {
+    const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
+    const actualRadius = radius * variance
+    const sides = GEM_SIDES[gemVariant % GEM_SIDES.length]
+    const body = Bodies.polygon(x, y, sides, actualRadius, {
+        restitution: CONFIG.restitution,
+        friction: CONFIG.friction,
+        frictionAir: CONFIG.frictionAir,
+        label: 'gem',
+    })
+    return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant }
+}
+
+function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody {
+    const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance * 0.5
+    const actualRadius = radius * variance
+    const body = Bodies.circle(x, y, actualRadius, {
+        restitution: 0.8,
+        friction: 0.01,
+        frictionAir: 0.008,
+        label: 'balloon',
+    })
+    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex }
 }
 
 // ── Factory ────────────────────────────────────────────────────────────────
@@ -65,34 +108,96 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
 
     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.
+    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,
+                })
+            }
+        }
+    })
+
     // ── Collision haptics ──────────────────────────────────────────────────
     Events.on(engine, 'collisionStart', (event: Matter.IEventCollision<Matter.Engine>) => {
         for (const pair of event.pairs) {
             const aWall = pair.bodyA.label === 'wall'
             const bWall = pair.bodyB.label === 'wall'
-            if (!aWall && !bWall) continue
-
-            const nutBody = aWall ? pair.bodyB : pair.bodyA
-            if (nutBody.label !== 'nut') continue
-
-            const speed = nutBody.speed
-            if (speed > CONFIG.impactThreshold) {
-                const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
-                vibrate(Math.max(8, Math.round(t * CONFIG.maxVibrationMs)))
+            const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon'
+            const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon'
+
+            if (aNut && bNut) {
+                // Nut/gem-nut/gem collision – softer haptic
+                const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed)
+                dbg('nut-nut speed:', speed.toFixed(2))
+                if (speed > CONFIG.impactThreshold) {
+                    const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
+                    const ms = Math.round(10 + t * 30)
+                    vibrate(ms)
+                }
+            } else if (aNut || bNut) {
+                // Nut/gem-wall collision – stronger haptic
+                const nutBody = aWall ? pair.bodyB : pair.bodyA
+                if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon') continue
+                const speed = nutBody.speed
+                dbg('wall hit speed:', speed.toFixed(2))
+                if (speed > CONFIG.impactThreshold) {
+                    const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
+                    const ms = Math.round(30 + t * (CONFIG.maxVibrationMs - 30))
+                    vibrate(ms)
+                }
             }
         }
     })
 
     // ── Public API ─────────────────────────────────────────────────────────
 
-    function setNutCount(count: number): void {
+    function addNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void {
+        const colorIndex = nuts.length
+        const nut = spawnNut(x, y, nutRadius, colorIndex)
+        Body.setAngle(nut.body, Math.random() * Math.PI * 2)
+        nuts.push(nut)
+        Composite.add(engine.world, nut.body)
+    }
+
+    function addGem(x: number, y: number, radius: number = CONFIG.nutBaseRadius, gemVariant?: number): void {
+        const variant = gemVariant ?? Math.floor(Math.random() * GEM_VARIANT_COUNT)
+        const gem = spawnGem(x, y, radius, variant)
+        Body.setAngle(gem.body, Math.random() * Math.PI * 2)
+        nuts.push(gem)
+        Composite.add(engine.world, gem.body)
+    }
+
+    function addBalloon(x: number, y: number, radius: number = CONFIG.nutBaseRadius, colorIndex?: number): void {
+        const ci = colorIndex ?? Math.floor(Math.random() * BALLOON_COLOR_COUNT)
+        const balloon = spawnBalloon(x, y, radius, ci)
+        nuts.push(balloon)
+        Composite.add(engine.world, balloon.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--) {
+            const { body, radius } = nuts[i]
+            const dx = body.position.x - x
+            const dy = body.position.y - y
+            if (dx * dx + dy * dy <= radius * radius) {
+                Composite.remove(engine.world, body)
+                nuts.splice(i, 1)
+                return true
+            }
+        }
+        return false
+    }
+
+    function clearNuts(): void {
         for (const { body } of nuts) Composite.remove(engine.world, body)
         nuts = []
-        for (let i = 0; i < count; i++) {
-            const nut = spawnNut(currentWidth, currentHeight, i)
-            nuts.push(nut)
-            Composite.add(engine.world, nut.body)
-        }
     }
 
     function resize(newWidth: number, newHeight: number): void {
@@ -127,5 +232,18 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Engine.clear(engine)
     }
 
-    return { engine, nuts, setNutCount, resize, applyShakeForce, setGravity, step, destroy }
+    return {
+        engine,
+        get nuts() { return nuts },
+        addNut,
+        addGem,
+        addBalloon,
+        removeAt,
+        clearNuts,
+        resize,
+        applyShakeForce,
+        setGravity,
+        step,
+        destroy,
+    }
 }
diff --git a/src/renderer.ts b/src/renderer.ts
index e9c232e..ce12b6c 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,27 +1,84 @@
 import type { NutBody } from './physics'
+import { CONFIG } from './config'
 
-// ── Palette ────────────────────────────────────────────────────────────────
-// Each entry: [fill, highlight, shadow, stroke]
-const PALETTE: [string, string, string, string][] = [
-    ['#C8A84B', '#E8CC7A', '#7A6218', '#5C4A12'], // brass
-    ['#A0A0A8', '#CDD0D8', '#606068', '#404048'], // steel
-    ['#B87333', '#D8993A', '#6B3F14', '#4A2A0A'], // copper
-    ['#8C6D3F', '#B89060', '#50381A', '#362208'], // bronze
-    ['#C0C0C0', '#E8E8E8', '#707070', '#484848'], // chrome
-    ['#787860', '#A0A088', '#404030', '#282818'], // dark zinc
+// ── Nut palette – warm tones ───────────────────────────────────────────────
+interface NutColor { hi: string; mid: string; shadow: string; rim: string }
+const PALETTE: NutColor[] = [
+    { hi: '#E8C86A', mid: '#C8993A', shadow: '#7A5810', rim: '#5A3C08' }, // golden
+    { hi: '#D4956A', mid: '#AA6030', shadow: '#6A3410', rim: '#4A2008' }, // copper-tan
+    { hi: '#C8B478', mid: '#A08840', shadow: '#6A5418', rim: '#4A3808' }, // hazel
+    { hi: '#B87850', mid: '#8C4E20', shadow: '#5A2C0A', rim: '#3A1C04' }, // dark walnut
+    { hi: '#DDB860', mid: '#B88E30', shadow: '#785808', rim: '#503A04' }, // light chestnut
 ]
 
 // ── Cache pre-rendered sprites ─────────────────────────────────────────────
-// Key: "<sizeRounded>_<colorIndex>"
 const spriteCache = new Map<string, HTMLCanvasElement>()
 
-function roundToNearest2(n: number): number {
-    return Math.round(n / 2) * 2
+function roundToNearest4(n: number): number {
+    return Math.round(n / 4) * 4
 }
 
-function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement {
-    const [fill, hi, shadow, stroke] = PALETTE[colorIndex % PALETTE.length]
-    const size = Math.ceil(radius * 2 + 4) // +padding for stroke/shadow
+// ── Gem palette ────────────────────────────────────────────────────────────
+interface GemDef {
+    sides: number
+    rotation: number
+    hi: string; mid: string; shadow: string; rim: string
+    facetHi: string; facetLo: string
+}
+
+const GEM_TYPES: GemDef[] = [
+    // 0 — Diamond: icy white-blue, 4-sided ◇
+    {
+        sides: 4, rotation: Math.PI / 4,
+        hi: '#F4FBFF', mid: '#B8D8F0', shadow: '#5880A8', rim: '#C8E4F4',
+        facetHi: 'rgba(255,255,255,0.55)', facetLo: 'rgba(70,120,170,0.35)'
+    },
+    // 1 — Ruby: deep red, hexagon
+    {
+        sides: 6, rotation: 0,
+        hi: '#FF7090', mid: '#C01030', shadow: '#580010', rim: '#FF2040',
+        facetHi: 'rgba(255,130,150,0.55)', facetLo: 'rgba(55,0,10,0.45)'
+    },
+    // 2 — Sapphire: deep blue, hexagon rotated
+    {
+        sides: 6, rotation: Math.PI / 6,
+        hi: '#80A8FF', mid: '#1838C8', shadow: '#060848', rim: '#3058F0',
+        facetHi: 'rgba(130,170,255,0.55)', facetLo: 'rgba(4,8,55,0.45)'
+    },
+    // 3 — Emerald: deep green, octagon
+    {
+        sides: 8, rotation: Math.PI / 8,
+        hi: '#70E090', mid: '#108040', shadow: '#022810', rim: '#28B060',
+        facetHi: 'rgba(100,230,140,0.55)', facetLo: 'rgba(2,24,8,0.45)'
+    },
+    // 4 — Amethyst: purple, hexagon
+    {
+        sides: 6, rotation: Math.PI / 12,
+        hi: '#D080FF', mid: '#7020C0', shadow: '#280040', rim: '#A030E0',
+        facetHi: 'rgba(205,105,255,0.55)', facetLo: 'rgba(28,0,50,0.45)'
+    },
+]
+
+function gemPolygon(cx: number, cy: number, r: number, sides: number, rot: number): Array<[number, number]> {
+    const pts: Array<[number, number]> = []
+    for (let i = 0; i < sides; i++) {
+        const a = rot + (i / sides) * Math.PI * 2
+        pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r])
+    }
+    return pts
+}
+
+function tracePoly(ctx: CanvasRenderingContext2D, pts: Array<[number, number]>): void {
+    ctx.beginPath()
+    ctx.moveTo(pts[0][0], pts[0][1])
+    for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
+    ctx.closePath()
+}
+
+function buildGemSprite(radius: number, gemVariant: number): HTMLCanvasElement {
+    const g = GEM_TYPES[gemVariant % GEM_TYPES.length]
+    const pad = Math.ceil(radius * 0.35)
+    const size = Math.ceil(radius * 2 + pad * 2)
     const oc = document.createElement('canvas')
     oc.width = size
     oc.height = size
@@ -29,97 +86,234 @@ function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement {
     const cx = size / 2
     const cy = size / 2
 
-    // -- Hex path (regular hexagon, flat-top orientation) --
-    ctx.beginPath()
-    for (let i = 0; i < 6; i++) {
-        const a = (Math.PI / 3) * i - Math.PI / 6 // pointy-top
-        const x = cx + radius * Math.cos(a)
-        const y = cy + radius * Math.sin(a)
-        if (i === 0) ctx.moveTo(x, y)
-        else ctx.lineTo(x, y)
+    // Shadow
+    ctx.save()
+    ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
+    ctx.globalAlpha = 0.5
+    ctx.fillStyle = '#000'
+    tracePoly(ctx, gemPolygon(cx + pad * 0.15, cy + pad * 0.35, radius * 0.9, g.sides, g.rotation))
+    ctx.fill()
+    ctx.restore()
+
+    const pts = gemPolygon(cx, cy, radius, g.sides, g.rotation)
+
+    // Base radial gradient fill
+    const grad = ctx.createRadialGradient(cx - radius * 0.3, cy - radius * 0.35, radius * 0.05, cx, cy, radius)
+    grad.addColorStop(0, g.hi)
+    grad.addColorStop(0.55, g.mid)
+    grad.addColorStop(1, g.shadow)
+    tracePoly(ctx, pts)
+    ctx.fillStyle = grad
+    ctx.fill()
+
+    // Alternating facet triangles
+    ctx.save()
+    tracePoly(ctx, pts)
+    ctx.clip()
+    for (let i = 0; i < pts.length; i++) {
+        const [ax, ay] = pts[i]
+        const [bx, by] = pts[(i + 1) % pts.length]
+        ctx.beginPath()
+        ctx.moveTo(cx, cy)
+        ctx.lineTo(ax, ay)
+        ctx.lineTo(bx, by)
+        ctx.closePath()
+        ctx.fillStyle = i % 2 === 0 ? g.facetHi : g.facetLo
+        ctx.fill()
     }
-    ctx.closePath()
+    ctx.restore()
 
-    // Center hole sub-path (CCW so evenodd creates a hole)
-    const holeR = radius * 0.36
-    ctx.moveTo(cx + holeR, cy)
-    ctx.arc(cx, cy, holeR, 0, Math.PI * 2, true)
-    ctx.closePath()
+    // Bright rim
+    tracePoly(ctx, pts)
+    ctx.strokeStyle = g.rim
+    ctx.lineWidth = Math.max(1.5, radius * 0.04)
+    ctx.stroke()
 
-    // Drop shadow
-    ctx.shadowColor = 'rgba(0,0,0,0.55)'
-    ctx.shadowBlur = 6
-    ctx.shadowOffsetY = 3
+    // Faint facet-edge lines from centre to vertices
+    ctx.strokeStyle = 'rgba(255,255,255,0.22)'
+    ctx.lineWidth = 1
+    for (const [px, py] of pts) {
+        ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(px, py); ctx.stroke()
+    }
 
-    // Radial gradient – off-centre light source (top-left)
-    const grad = ctx.createRadialGradient(
-        cx - radius * 0.25, cy - radius * 0.3, radius * 0.05,
-        cx, cy, radius
-    )
-    grad.addColorStop(0, hi)
-    grad.addColorStop(0.45, fill)
-    grad.addColorStop(1, shadow)
+    // Specular highlight — top-left soft glow
+    ctx.save()
+    tracePoly(ctx, pts)
+    ctx.clip()
+    const hiGrad = ctx.createRadialGradient(cx - radius * 0.25, cy - radius * 0.3, 0, cx, cy, radius * 0.7)
+    hiGrad.addColorStop(0, 'rgba(255,255,255,0.55)')
+    hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.14)')
+    hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
+    ctx.fillStyle = hiGrad
+    ctx.fillRect(0, 0, size, size)
+    ctx.restore()
 
-    ctx.fillStyle = grad
-    ctx.fill('evenodd')
+    return oc
+}
 
-    // Reset shadow for stroke
-    ctx.shadowColor = 'transparent'
-    ctx.shadowBlur = 0
-    ctx.shadowOffsetY = 0
+function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement {
+    const c = PALETTE[colorIndex % PALETTE.length]
+    const rx = radius
+    const ry = Math.round(radius / CONFIG.nutAspectRatio)
+    const pad = Math.ceil(rx * 0.35)
+    const sizeW = Math.ceil(rx * 2 + pad * 2)
+    const sizeH = Math.ceil(ry * 2 + pad * 2)
+    const oc = document.createElement('canvas')
+    oc.width = sizeW
+    oc.height = sizeH
+    const ctx = oc.getContext('2d')!
+    const cx = sizeW / 2
+    const cy = sizeH / 2
 
-    // Outer stroke
-    ctx.lineWidth = 1.5
-    ctx.strokeStyle = stroke
-    // Re-build outer hex only for stroke (no hole)
+    // Contained drop shadow – blurred offset ellipse drawn before the nut
+    ctx.save()
+    ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
+    ctx.globalAlpha = 0.55
+    ctx.fillStyle = '#000'
     ctx.beginPath()
-    for (let i = 0; i < 6; i++) {
-        const a = (Math.PI / 3) * i - Math.PI / 6
-        const x = cx + radius * Math.cos(a)
-        const y = cy + radius * Math.sin(a)
-        if (i === 0) ctx.moveTo(x, y)
-        else ctx.lineTo(x, y)
-    }
-    ctx.closePath()
-    ctx.stroke()
+    ctx.ellipse(cx + pad * 0.15, cy + pad * 0.35, rx * 0.88, ry * 0.88, 0, 0, Math.PI * 2)
+    ctx.fill()
+    ctx.restore()
 
-    // Inner hole edge
-    ctx.lineWidth = 1
-    ctx.strokeStyle = shadow
+    // Ellipse fill – off-centre radial gradient for 3-D sheen
+    const grad = ctx.createRadialGradient(
+        cx - rx * 0.28, cy - ry * 0.32, rx * 0.04,
+        cx, cy, rx
+    )
+    grad.addColorStop(0, c.hi)
+    grad.addColorStop(0.5, c.mid)
+    grad.addColorStop(1, c.shadow)
     ctx.beginPath()
-    ctx.arc(cx, cy, holeR, 0, Math.PI * 2)
+    ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
+    ctx.fillStyle = grad
+    ctx.fill()
+
+    // Rim stroke
+    ctx.lineWidth = Math.max(1.5, rx * 0.035)
+    ctx.strokeStyle = c.rim
     ctx.stroke()
 
-    // Highlight arc (top-left glint)
-    ctx.save()
-    ctx.clip()  // clip to hex (already closed path is gone – need a fresh clip)
-    ctx.restore()
+    // Specular highlight – small bright arc top-left
+    ctx.beginPath()
+    ctx.ellipse(cx - rx * 0.22, cy - ry * 0.28, rx * 0.28, ry * 0.18, -0.5, 0, Math.PI * 2)
+    ctx.fillStyle = 'rgba(255,255,255,0.12)'
+    ctx.fill()
 
     return oc
 }
 
-function getSprite(radius: number, colorIndex: number): HTMLCanvasElement {
-    const r = roundToNearest2(radius)
-    const key = `${r}_${colorIndex % PALETTE.length}`
+function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem', gemVariant: number): HTMLCanvasElement {
+    const r = roundToNearest4(radius)
+    const key = kind === 'gem'
+        ? `gem_${r}_${gemVariant % GEM_TYPES.length}`
+        : `nut_${r}_${colorIndex % PALETTE.length}`
     let sprite = spriteCache.get(key)
     if (!sprite) {
-        sprite = buildSprite(r, colorIndex)
+        sprite = kind === 'gem' ? buildGemSprite(r, gemVariant) : buildSprite(r, colorIndex)
         spriteCache.set(key, sprite)
     }
     return sprite
 }
 
+// ── Balloon palette ─────────────────────────────────────────────────────
+interface BalloonColor { body: string; hi: string; shadow: string; rim: string; string: string }
+const BALLOON_COLORS: BalloonColor[] = [
+    { body: '#FF4466', hi: '#FF99AA', shadow: '#AA0022', rim: '#FF2244', string: '#CC1133' }, // red
+    { body: '#FF8800', hi: '#FFCC66', shadow: '#AA4400', rim: '#FF6600', string: '#CC5500' }, // orange
+    { body: '#FFE000', hi: '#FFF599', shadow: '#AA8800', rim: '#FFCC00', string: '#BB9900' }, // yellow
+    { body: '#33CC55', hi: '#88EE99', shadow: '#116622', rim: '#22AA44', string: '#228833' }, // green
+    { body: '#4488FF', hi: '#99BBFF', shadow: '#1133CC', rim: '#2255EE', string: '#1144CC' }, // blue
+    { body: '#EE44CC', hi: '#FF99EE', shadow: '#991188', rim: '#DD22BB', string: '#BB0099' }, // pink
+    { body: '#22DDEE', hi: '#88EEFF', shadow: '#117788', rim: '#11CCDD', string: '#0099AA' }, // cyan
+]
+
+function drawBalloon(
+    ctx: CanvasRenderingContext2D,
+    x: number, y: number,
+    radius: number,
+    colorIndex: number,
+): void {
+    const c = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length]
+    const r = radius
+
+    // Shadow
+    ctx.save()
+    ctx.filter = `blur(${Math.ceil(r * 0.14)}px)`
+    ctx.globalAlpha = 0.32
+    ctx.fillStyle = '#000'
+    ctx.beginPath()
+    ctx.arc(x + r * 0.12, y + r * 0.15, r * 0.88, 0, Math.PI * 2)
+    ctx.fill()
+    ctx.restore()
+
+    // Body
+    const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.04, x, y, r)
+    grad.addColorStop(0, c.hi)
+    grad.addColorStop(0.55, c.body)
+    grad.addColorStop(1, c.shadow)
+    ctx.beginPath()
+    ctx.arc(x, y, r, 0, Math.PI * 2)
+    ctx.fillStyle = grad
+    ctx.fill()
+    ctx.strokeStyle = c.rim
+    ctx.lineWidth = Math.max(1.5, r * 0.03)
+    ctx.stroke()
+
+    // Specular highlight
+    ctx.save()
+    ctx.beginPath()
+    ctx.arc(x, y, r, 0, Math.PI * 2)
+    ctx.clip()
+    const hiGrad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.38, 0, x - r * 0.1, y - 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)')
+    ctx.fillStyle = hiGrad
+    ctx.fillRect(x - r, y - r, r * 2, r * 2)
+    ctx.restore()
+
+    // Knot
+    const knotH = Math.max(4, r * 0.13)
+    ctx.beginPath()
+    ctx.moveTo(x, y + r + knotH * 1.6)
+    ctx.lineTo(x - knotH * 0.55, y + r)
+    ctx.lineTo(x + knotH * 0.55, y + r)
+    ctx.closePath()
+    ctx.fillStyle = c.rim
+    ctx.fill()
+
+    // String
+    const stringLen = r * 0.9
+    ctx.beginPath()
+    ctx.moveTo(x, y + r + knotH * 1.6)
+    ctx.quadraticCurveTo(
+        x + r * 0.18, y + r + stringLen * 0.5,
+        x + r * 0.06, y + r + stringLen + knotH * 1.6,
+    )
+    ctx.strokeStyle = c.string
+    ctx.lineWidth = Math.max(1, r * 0.025)
+    ctx.globalAlpha = 0.75
+    ctx.stroke()
+    ctx.globalAlpha = 1
+}
+
 // ── Renderer ───────────────────────────────────────────────────────────────
 
 export interface Renderer {
-    render: (nuts: NutBody[]) => void
+    render: (nuts: NutBody[], gravityAngle: number) => void
     resize: (width: number, height: number) => void
+    flash: () => void
 }
 
 export function createRenderer(canvas: HTMLCanvasElement): Renderer {
     const ctx = canvas.getContext('2d')!
+    let flashAlpha = 0
 
-    function render(nuts: NutBody[]): void {
+    function flash(): void {
+        flashAlpha = 0.35
+    }
+
+    function render(nuts: NutBody[], gravityAngle: number): void {
         const w = canvas.width
         const h = canvas.height
 
@@ -127,33 +321,32 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         ctx.fillStyle = '#111111'
         ctx.fillRect(0, 0, w, h)
 
-        // Container border (subtle inner glow suggesting a tube/box)
-        const borderW = 4
-        ctx.strokeStyle = '#2a2a2a'
-        ctx.lineWidth = borderW
-        ctx.strokeRect(borderW / 2, borderW / 2, w - borderW, h - borderW)
-
-        // Nuts
-        for (const { body, radius, colorIndex } of nuts) {
-            const { position, angle, vertices } = body
-            const sprite = getSprite(radius, colorIndex)
-            const size = sprite.width
-
-            ctx.save()
-            ctx.translate(position.x, position.y)
-            ctx.rotate(angle)
-            ctx.drawImage(sprite, -size / 2, -size / 2, size, size)
+        // Nuts, gems & balloons
+        for (const { body, radius, colorIndex, kind, gemVariant } of nuts) {
+            const { position, angle } = body
+
+            if (kind === 'balloon') {
+                ctx.save()
+                ctx.translate(position.x, position.y)
+                ctx.rotate(gravityAngle)
+                drawBalloon(ctx, 0, 0, radius, colorIndex)
+                ctx.restore()
+            } else {
+                const sprite = getSprite(radius, colorIndex, kind, gemVariant)
+                ctx.save()
+                ctx.translate(position.x, position.y)
+                ctx.rotate(angle)
+                ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height)
+                ctx.restore()
+            }
             ctx.restore()
+        }
 
-            // Debug: visualise physics polygon (uncomment for dev)
-            // ctx.beginPath()
-            // ctx.moveTo(vertices[0].x, vertices[0].y)
-            // for (const v of vertices) ctx.lineTo(v.x, v.y)
-            // ctx.closePath()
-            // ctx.strokeStyle = 'red'
-            // ctx.lineWidth = 1
-            // ctx.stroke()
-            void vertices // suppress unused-var warning
+        // 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)
         }
     }
 
@@ -162,5 +355,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         canvas.height = height
     }
 
-    return { render, resize }
+    return { render, resize, flash }
 }
diff --git a/src/share.ts b/src/share.ts
new file mode 100644
index 0000000..8dc5aa3
--- /dev/null
+++ b/src/share.ts
@@ -0,0 +1,82 @@
+import type { NutBody } from './physics'
+
+// ── Encoding ───────────────────────────────────────────────────────────────
+// Nuts:  "r<radius>c<colorIndex>"  e.g. "r130c2"
+// Gems:  "r<radius>g<gemVariant>"  e.g. "r80g1"
+// Comma-separated, e.g. "r130c0,r80g1,r60g3"
+
+export interface SharedNut {
+    radius: number
+    colorIndex: number
+    kind: 'nut' | 'gem' | 'balloon'
+    gemVariant: number
+}
+
+export function encodeNuts(nuts: NutBody[]): string {
+    return nuts.map(n => {
+        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}`
+        return `r${Math.round(n.radius)}c${n.colorIndex % 5}`
+    }).join(',')
+}
+
+export function decodeNuts(encoded: string): SharedNut[] | null {
+    try {
+        if (!encoded) return null
+        return encoded.split(',').map(part => {
+            const gem = part.match(/^r(\d+)g(\d+)$/)
+            if (gem) return { radius: parseInt(gem[1], 10), colorIndex: 0, kind: 'gem' as const, gemVariant: parseInt(gem[2], 10) }
+            const balloon = part.match(/^r(\d+)b(\d+)$/)
+            if (balloon) return { radius: parseInt(balloon[1], 10), colorIndex: parseInt(balloon[2], 10), kind: 'balloon' as const, gemVariant: parseInt(balloon[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')
+        })
+    } catch {
+        return null
+    }
+}
+
+const BASE_URL = 'https://pavelsafronov.gitlab.io/nuts/'
+
+export function buildShareURL(nuts: NutBody[]): string {
+    const encoded = encodeNuts(nuts)
+    return `${BASE_URL}#s=${encodeURIComponent(encoded)}`
+}
+
+/** Parse share data from the current URL hash. Returns null if none present. */
+export function parseShareFromURL(): SharedNut[] | null {
+    const m = location.hash.match(/[#&]s=([^&]*)/)
+    if (!m) return null
+    const parsed = decodeNuts(decodeURIComponent(m[1]))
+    // Clear the hash so refreshing doesn't re-import
+    history.replaceState(null, '', location.pathname + location.search)
+    return parsed
+}
+
+/** Place shared nuts/gems into the world with a loose random packing. */
+export function placeSharedNuts(
+    nuts: SharedNut[],
+    screenW: number,
+    screenH: number,
+    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon', gemVariant: number) => void,
+    hasSpace: (x: number, y: number, r: number) => boolean,
+): void {
+    for (const { radius, colorIndex: _ci, kind, gemVariant } of nuts) {
+        // Attempt random positions until one fits (max 200 tries)
+        const margin = radius * 1.2
+        let placed = false
+        for (let attempt = 0; attempt < 200; attempt++) {
+            const x = margin + Math.random() * (screenW - margin * 2)
+            const y = margin + Math.random() * (screenH - margin * 2)
+            if (hasSpace(x, y, radius)) {
+                addBody(x, y, radius, kind, gemVariant)
+                placed = true
+                break
+            }
+        }
+        if (!placed) {
+            addBody(margin + Math.random() * (screenW - margin * 2), margin, radius, kind, gemVariant)
+        }
+    }
+}
diff --git a/src/style.css b/src/style.css
index 78e0ee9..5433a92 100644
--- a/src/style.css
+++ b/src/style.css
@@ -38,6 +38,21 @@ body {
     cursor: pointer;
 }
 
+#fullscreen-overlay {
+    position: fixed;
+    inset: 0;
+    background: #111;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 200;
+    cursor: pointer;
+}
+
+#fullscreen-overlay[hidden] {
+    display: none;
+}
+
 .start-content {
     display: flex;
     flex-direction: column;
@@ -223,6 +238,12 @@ input[type="range"]::-moz-range-thumb {
     background: #333;
 }
 
+.action-btn.active {
+    background: #2a3a2a;
+    border-color: #4a7a4a;
+    color: #8fce8f;
+}
+
 .close-btn {
     background: #c8a84b;
     border: none;
@@ -270,4 +291,56 @@ input[type="range"]::-moz-range-thumb {
     color: #666;
     text-decoration: underline;
     text-underline-offset: 2px;
+}
+
+/* ── Share section ─────────────────────────────────────────────────────── */
+.share-section {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 10px;
+    padding-top: 4px;
+    border-top: 1px solid #2a2a2a;
+}
+
+.share-label {
+    font-size: 13px;
+    color: #666;
+    align-self: flex-start;
+}
+
+#qr-canvas {
+    border-radius: 10px;
+    background: #fff;
+    padding: 8px;
+    width: 160px;
+    height: 160px;
+}
+
+/* ── Vibration hint toast ───────────────────────────────────────────────── */
+#vibration-hint {
+    position: fixed;
+    bottom: max(24px, env(safe-area-inset-bottom, 24px));
+    left: 50%;
+    transform: translateX(-50%);
+    background: rgba(30, 30, 30, 0.92);
+    color: #e0e0e0;
+    font-family: system-ui, -apple-system, sans-serif;
+    font-size: 13px;
+    line-height: 1.5;
+    padding: 10px 16px;
+    border-radius: 12px;
+    backdrop-filter: blur(8px);
+    -webkit-backdrop-filter: blur(8px);
+    border: 1px solid rgba(255, 255, 255, 0.08);
+    white-space: nowrap;
+    z-index: 60;
+    pointer-events: auto;
+    cursor: pointer;
+    opacity: 1;
+    transition: opacity 0.4s;
+}
+
+#vibration-hint.fade-out {
+    opacity: 0;
 }
\ No newline at end of file
diff --git a/src/ui.ts b/src/ui.ts
index 63dd3a3..614e7af 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -1,46 +1,104 @@
 import { CONFIG } from './config'
+import { dbg } from './debug'
+import QRCode from 'qrcode'
 
 export interface UICallbacks {
-    onNutCountChange: (count: number) => void
-    onReset: () => void
+    initialRadius: number
+    initialLocked: boolean
+    onSizeChange: (radius: number) => void
+    onClear: () => void
+    onLockChange: (locked: boolean) => void
+    getShareURL: () => string
 }
 
 export function initUI(callbacks: UICallbacks): void {
     const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement
     const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement
-    const nutCountInput = document.getElementById('nut-count') as HTMLInputElement
-    const nutCountDisplay = document.getElementById('nut-count-display') as HTMLSpanElement
-    const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement
+    const nutSizeInput = document.getElementById('nut-size') as HTMLInputElement
+    const nutSizeDisplay = document.getElementById('nut-size-display') as HTMLSpanElement
+    const lockBtn = document.getElementById('lock-btn') as HTMLButtonElement
+    const clearBtn = document.getElementById('clear-btn') as HTMLButtonElement
+    const testVibrateBtn = document.getElementById('test-vibrate-btn') as HTMLButtonElement
     const closeBtn = document.getElementById('close-settings') as HTMLButtonElement
     const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement
+    const qrCanvas = document.getElementById('qr-canvas') as HTMLCanvasElement
+    const copyLinkBtn = document.getElementById('copy-link-btn') as HTMLButtonElement
 
-    // Sync slider bounds from config
-    nutCountInput.min = String(CONFIG.minNuts)
-    nutCountInput.max = String(CONFIG.maxNuts)
-    nutCountInput.value = String(CONFIG.defaultNutCount)
-    nutCountDisplay.textContent = String(CONFIG.defaultNutCount)
+    // Sync slider from config bounds + persisted value
+    nutSizeInput.min = String(CONFIG.nutMinRadius)
+    nutSizeInput.max = String(CONFIG.nutMaxRadius)
+    nutSizeInput.value = String(callbacks.initialRadius)
+    nutSizeDisplay.textContent = String(callbacks.initialRadius)
+
+    let locked = callbacks.initialLocked
+    lockBtn.textContent = locked ? '▶ Resume' : '⏸ Pause'
+    lockBtn.classList.toggle('active', locked)
 
     // Show settings button
     settingsBtn.hidden = false
 
     settingsBtn.addEventListener('click', () => {
         settingsPanel.hidden = false
+        refreshQR()
     })
 
     closeBtn.addEventListener('click', closePanel)
     backdrop.addEventListener('click', closePanel)
 
-    nutCountInput.addEventListener('input', () => {
-        const count = parseInt(nutCountInput.value, 10)
-        nutCountDisplay.textContent = String(count)
-        callbacks.onNutCountChange(count)
+    nutSizeInput.addEventListener('input', () => {
+        const radius = parseInt(nutSizeInput.value, 10)
+        nutSizeDisplay.textContent = String(radius)
+        callbacks.onSizeChange(radius)
     })
 
-    resetBtn.addEventListener('click', () => {
-        callbacks.onReset()
+    lockBtn.addEventListener('click', () => {
+        locked = !locked
+        lockBtn.textContent = locked ? '▶ Resume' : '⏸ Pause'
+        lockBtn.classList.toggle('active', locked)
+        callbacks.onLockChange(locked)
         closePanel()
     })
 
+    clearBtn.addEventListener('click', () => {
+        callbacks.onClear()
+        closePanel()
+    })
+
+    testVibrateBtn.addEventListener('click', () => {
+        const ok = navigator.vibrate(300)
+        dbg('test vibrate 300ms ->', ok)
+        const orig = testVibrateBtn.textContent
+        testVibrateBtn.textContent = ok ? '✓ Sent' : '✗ Failed'
+        setTimeout(() => { testVibrateBtn.textContent = orig }, 1500)
+    })
+
+    copyLinkBtn.addEventListener('click', () => {
+        const url = callbacks.getShareURL()
+        const orig = copyLinkBtn.textContent
+        if (navigator.share) {
+            navigator.share({ title: 'My collection', url }).catch(() => { /* cancelled */ })
+        } else {
+            navigator.clipboard.writeText(url).then(() => {
+                copyLinkBtn.textContent = '✓ Copied!'
+                setTimeout(() => { copyLinkBtn.textContent = orig }, 1800)
+            }).catch(() => {
+                copyLinkBtn.textContent = '✗ Failed'
+                setTimeout(() => { copyLinkBtn.textContent = orig }, 1800)
+            })
+        }
+    })
+
+    async function refreshQR(): Promise<void> {
+        const url = callbacks.getShareURL()
+        try {
+            await QRCode.toCanvas(qrCanvas, url, {
+                width: 160,
+                margin: 1,
+                color: { dark: '#111111', light: '#ffffff' },
+            })
+        } catch { /* ignore */ }
+    }
+
     function closePanel(): void {
         settingsPanel.hidden = true
     }
diff --git a/src/vibration.ts b/src/vibration.ts
index cace1f3..57bab60 100644
--- a/src/vibration.ts
+++ b/src/vibration.ts
@@ -1,21 +1,78 @@
 import { CONFIG } from './config'
+import { dbg } from './debug'
 
 let lastVibrationAt = 0
+let flashFn: (() => void) | undefined
+let unlocked = false
+
+/** Register a visual fallback called when the Vibration API is unavailable. */
+export function setFlashFallback(fn: () => void): void {
+    flashFn = fn
+}
+
+/**
+ * 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
+ * it will honour subsequent calls from rAF / event callbacks.
+ */
+export function unlockVibration(): void {
+    if (!('vibrate' in navigator && typeof navigator.vibrate === 'function')) {
+        dbg('vibrate: API not present')
+        return
+    }
+    navigator.vibrate(1)
+    unlocked = true
+    dbg('vibrate: unlocked')
+    showVibrateHint()
+}
+
+function showVibrateHint(): void {
+    const STORAGE_KEY = 'vibrate-hint-dismissed'
+    if (localStorage.getItem(STORAGE_KEY)) return
+
+    const toast = document.createElement('div')
+    toast.id = 'vibration-hint'
+    toast.textContent = '📳 Tip: turn off Do Not Disturb for haptics'
+    document.body.appendChild(toast)
+
+    function dismiss(): void {
+        localStorage.setItem(STORAGE_KEY, '1')
+        toast.classList.add('fade-out')
+        setTimeout(() => toast.remove(), 420)
+    }
+
+    toast.addEventListener('click', dismiss)
+    setTimeout(dismiss, 5000)
+}
+
+function canVibrate(): boolean {
+    return unlocked && 'vibrate' in navigator && typeof navigator.vibrate === 'function'
+}
 
 /** Fire the Web Vibration API, with a minimum interval to avoid spam. */
 export function vibrate(durationMs: number): void {
-    if (!('vibrate' in navigator)) return
     const now = Date.now()
     if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return
+    // Chrome won't vibrate if the page is backgrounded or display is off
+    if (document.visibilityState !== 'visible') return
     lastVibrationAt = now
-    navigator.vibrate(durationMs)
+    if (canVibrate()) {
+        const ok = navigator.vibrate(durationMs)
+        dbg('vibrate', durationMs, 'ms ->', ok)
+    } else {
+        dbg('vibrate: fallback flash (unlocked:', unlocked, ')')
+        flashFn?.()
+    }
 }
 
 /** Short double-pulse for a shake event. */
 export function vibrateShake(): void {
-    if (!('vibrate' in navigator)) return
     const now = Date.now()
     if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return
     lastVibrationAt = now
-    navigator.vibrate([25, 15, 25])
+    if (canVibrate()) {
+        navigator.vibrate([25, 15, 25])
+    } else {
+        flashFn?.()
+    }
 }
diff --git a/vite.config.ts b/vite.config.ts
index dd6cb2a..2484030 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,5 +1,39 @@
-import { defineConfig } from 'vite'
+import { defineConfig, type Plugin } from 'vite'
 import { qrcode } from 'vite-plugin-qrcode'
+import basicSsl from '@vitejs/plugin-basic-ssl'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+
+function remoteLog(): Plugin {
+    return {
+        name: 'remote-log',
+        configureServer(server) {
+            server.middlewares.use(
+                '/__log',
+                (req: IncomingMessage, res: ServerResponse) => {
+                    if (req.method !== 'POST') {
+                        res.writeHead(405).end()
+                        return
+                    }
+                    let body = ''
+                    req.setEncoding('utf8')
+                    req.on('data', (chunk: string) => { body += chunk })
+                    req.on('end', () => {
+                        try {
+                            const { level, msg } = JSON.parse(body) as { level: string; msg: string }
+                            const prefix = '\x1b[36m[web]\x1b[0m'
+                            if (level === 'error') {
+                                server.config.logger.error(`${prefix} ${msg}`)
+                            } else {
+                                server.config.logger.info(`${prefix} ${msg}`)
+                            }
+                        } catch { /* malformed – ignore */ }
+                        res.writeHead(204).end()
+                    })
+                }
+            )
+        },
+    }
+}
 
 export default defineConfig({
     base: './',
@@ -9,5 +43,5 @@ export default defineConfig({
     server: {
         host: true, // listen on 0.0.0.0 so the phone can reach it
     },
-    plugins: [qrcode()],
+    plugins: [remoteLog(), basicSsl(), qrcode()],
 })