fuzzyworld

nuts / commit

added sounds

aa6e5a990499327e71832a8f761b302a39504e62 · Pavel Safronov · 2026-04-12 20:53:21 -0700 · parents a2e3ea1a6303

added sounds
 package.json                          |   1 +
 pnpm-lock.yaml                        | 153 ++++++++++++++++++++++++++++++++++
 src/config.ts                         |   3 +
 src/main.ts                           |  77 +++++++++--------
 src/persistence.ts                    |   7 +-
 src/physics.ts                        |  40 ++++++++-
 src/sound.ts                          | 113 +++++++++++++++++++++++++
 src/sounds/impactGlass_medium_000.ogg | Bin 0 -> 8400 bytes
 src/sounds/impactGlass_medium_001.ogg | Bin 0 -> 8605 bytes
 src/sounds/impactGlass_medium_002.ogg | Bin 0 -> 8341 bytes
 src/sounds/impactMetal_heavy_000.ogg  | Bin 0 -> 6110 bytes
 src/sounds/impactMetal_heavy_001.ogg  | Bin 0 -> 7279 bytes
 src/sounds/impactMetal_heavy_002.ogg  | Bin 0 -> 5634 bytes
 src/sounds/impactMining_000.ogg       | Bin 0 -> 12035 bytes
 src/sounds/impactMining_001.ogg       | Bin 0 -> 11481 bytes
 src/sounds/impactMining_002.ogg       | Bin 0 -> 11235 bytes
 src/sounds/impactPunch_heavy_000.ogg  | Bin 0 -> 11617 bytes
 src/sounds/impactPunch_heavy_001.ogg  | Bin 0 -> 10861 bytes
 src/sounds/impactPunch_heavy_002.ogg  | Bin 0 -> 9025 bytes
 src/sounds/impactSoft_heavy_000.ogg   | Bin 0 -> 6570 bytes
 src/sounds/impactSoft_heavy_001.ogg   | Bin 0 -> 6689 bytes
 src/sounds/impactSoft_heavy_002.ogg   | Bin 0 -> 6749 bytes
 src/sounds/impactSoft_medium_000.ogg  | Bin 0 -> 5351 bytes
 src/sounds/impactSoft_medium_001.ogg  | Bin 0 -> 5506 bytes
 src/sounds/impactSoft_medium_002.ogg  | Bin 0 -> 5358 bytes
 src/sounds/impactTin_medium_000.ogg   | Bin 0 -> 6509 bytes
 src/sounds/impactTin_medium_001.ogg   | Bin 0 -> 6603 bytes
 src/sounds/impactTin_medium_002.ogg   | Bin 0 -> 6156 bytes
 src/sounds/impactWood_medium_000.ogg  | Bin 0 -> 6575 bytes
 src/sounds/impactWood_medium_001.ogg  | Bin 0 -> 6555 bytes
 src/sounds/impactWood_medium_002.ogg  | Bin 0 -> 6607 bytes
 src/ui.ts                             |  13 +++
 vite.config.ts                        |  81 +++++++++++++++++-
 33 files changed, 448 insertions(+), 40 deletions(-)

diff --git a/package.json b/package.json
index 24cdaa7..4748406 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
         "@types/node": "^25.6.0",
         "@types/qrcode": "^1.5.6",
         "@vitejs/plugin-basic-ssl": "^2.3.0",
+        "ffmpeg-static": "^5.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 e198ed1..fdca09a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,6 +27,9 @@ importers:
       '@vitejs/plugin-basic-ssl':
         specifier: ^2.3.0
         version: 2.3.0(vite@5.4.21(@types/node@25.6.0))
+      ffmpeg-static:
+        specifier: ^5.3.0
+        version: 5.3.0
       sharp:
         specifier: ^0.34.5
         version: 0.34.5
@@ -45,6 +48,10 @@ importers:
 
 packages:
 
+  '@derhuerst/http-basic@8.2.4':
+    resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
+    engines: {node: '>=6.0.0'}
+
   '@emnapi/runtime@1.9.2':
     resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
 
@@ -610,6 +617,9 @@ packages:
   '@types/matter-js@0.19.8':
     resolution: {integrity: sha512-W2ZWG58Lijv/4v768NgpeyFqqiOyslmAU7qqM1Lhz4XBoUgGtZtPz4CjcOKYtqHIak14dvPldslQhltqLTWwsw==}
 
+  '@types/node@10.17.60':
+    resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
+
   '@types/node@25.6.0':
     resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
 
@@ -622,6 +632,10 @@ packages:
     peerDependencies:
       vite: ^6.0.0 || ^7.0.0 || ^8.0.0
 
+  agent-base@6.0.2:
+    resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+    engines: {node: '>= 6.0.0'}
+
   ansi-regex@5.0.1:
     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
     engines: {node: '>=8'}
@@ -630,10 +644,16 @@ packages:
     resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
     engines: {node: '>=8'}
 
+  buffer-from@1.1.2:
+    resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
   camelcase@5.3.1:
     resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
     engines: {node: '>=6'}
 
+  caseless@0.12.0:
+    resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
+
   cliui@6.0.0:
     resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
 
@@ -644,6 +664,19 @@ packages:
   color-name@1.1.4:
     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
 
+  concat-stream@2.0.0:
+    resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
+    engines: {'0': node >= 6.0}
+
+  debug@4.4.3:
+    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
   decamelize@1.2.0:
     resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
     engines: {node: '>=0.10.0'}
@@ -658,6 +691,10 @@ packages:
   emoji-regex@8.0.0:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
 
+  env-paths@2.2.1:
+    resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+    engines: {node: '>=6'}
+
   esbuild@0.21.5:
     resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
     engines: {node: '>=12'}
@@ -668,6 +705,10 @@ packages:
     engines: {node: '>=18'}
     hasBin: true
 
+  ffmpeg-static@5.3.0:
+    resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
+    engines: {node: '>=16'}
+
   find-up@4.1.0:
     resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
     engines: {node: '>=8'}
@@ -684,6 +725,16 @@ packages:
   get-tsconfig@4.13.7:
     resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
 
+  http-response-object@3.0.2:
+    resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
+
+  https-proxy-agent@5.0.1:
+    resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+    engines: {node: '>= 6'}
+
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
   is-fullwidth-code-point@3.0.0:
     resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
     engines: {node: '>=8'}
@@ -695,6 +746,9 @@ packages:
   matter-js@0.19.0:
     resolution: {integrity: sha512-v2huwvQGOHTGOkMqtHd2hercCG3f6QAObTisPPHg8TZqq2lz7eIY/5i/5YUV8Ibf3mEioFEmwibcPUF2/fnKKQ==}
 
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
   nanoid@3.3.11:
     resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -712,6 +766,9 @@ packages:
     resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
     engines: {node: '>=6'}
 
+  parse-cache-control@1.0.1:
+    resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
+
   path-exists@4.0.0:
     resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
     engines: {node: '>=8'}
@@ -727,11 +784,19 @@ packages:
     resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
     engines: {node: ^10 || ^12 || >=14}
 
+  progress@2.0.3:
+    resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+    engines: {node: '>=0.4.0'}
+
   qrcode@1.5.4:
     resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
     engines: {node: '>=10.13.0'}
     hasBin: true
 
+  readable-stream@3.6.2:
+    resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+    engines: {node: '>= 6'}
+
   require-directory@2.1.1:
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
     engines: {node: '>=0.10.0'}
@@ -747,6 +812,9 @@ packages:
     engines: {node: '>=18.0.0', npm: '>=8.0.0'}
     hasBin: true
 
+  safe-buffer@5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
   semver@7.7.4:
     resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
     engines: {node: '>=10'}
@@ -767,6 +835,9 @@ packages:
     resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
     engines: {node: '>=8'}
 
+  string_decoder@1.3.0:
+    resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
   strip-ansi@6.0.1:
     resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
     engines: {node: '>=8'}
@@ -779,6 +850,9 @@ packages:
     engines: {node: '>=18.0.0'}
     hasBin: true
 
+  typedarray@0.0.6:
+    resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
+
   typescript@5.9.3:
     resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
     engines: {node: '>=14.17'}
@@ -790,6 +864,9 @@ packages:
   uqr@0.1.3:
     resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==}
 
+  util-deprecate@1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
   vite-plugin-qrcode@0.4.1:
     resolution: {integrity: sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==}
     peerDependencies:
@@ -846,6 +923,13 @@ packages:
 
 snapshots:
 
+  '@derhuerst/http-basic@8.2.4':
+    dependencies:
+      caseless: 0.12.0
+      concat-stream: 2.0.0
+      http-response-object: 3.0.2
+      parse-cache-control: 1.0.1
+
   '@emnapi/runtime@1.9.2':
     dependencies:
       tslib: 2.8.1
@@ -1173,6 +1257,8 @@ snapshots:
 
   '@types/matter-js@0.19.8': {}
 
+  '@types/node@10.17.60': {}
+
   '@types/node@25.6.0':
     dependencies:
       undici-types: 7.19.2
@@ -1185,14 +1271,24 @@ snapshots:
     dependencies:
       vite: 5.4.21(@types/node@25.6.0)
 
+  agent-base@6.0.2:
+    dependencies:
+      debug: 4.4.3
+    transitivePeerDependencies:
+      - supports-color
+
   ansi-regex@5.0.1: {}
 
   ansi-styles@4.3.0:
     dependencies:
       color-convert: 2.0.1
 
+  buffer-from@1.1.2: {}
+
   camelcase@5.3.1: {}
 
+  caseless@0.12.0: {}
+
   cliui@6.0.0:
     dependencies:
       string-width: 4.2.3
@@ -1205,6 +1301,17 @@ snapshots:
 
   color-name@1.1.4: {}
 
+  concat-stream@2.0.0:
+    dependencies:
+      buffer-from: 1.1.2
+      inherits: 2.0.4
+      readable-stream: 3.6.2
+      typedarray: 0.0.6
+
+  debug@4.4.3:
+    dependencies:
+      ms: 2.1.3
+
   decamelize@1.2.0: {}
 
   detect-libc@2.1.2: {}
@@ -1213,6 +1320,8 @@ snapshots:
 
   emoji-regex@8.0.0: {}
 
+  env-paths@2.2.1: {}
+
   esbuild@0.21.5:
     optionalDependencies:
       '@esbuild/aix-ppc64': 0.21.5
@@ -1268,6 +1377,15 @@ snapshots:
       '@esbuild/win32-ia32': 0.27.7
       '@esbuild/win32-x64': 0.27.7
 
+  ffmpeg-static@5.3.0:
+    dependencies:
+      '@derhuerst/http-basic': 8.2.4
+      env-paths: 2.2.1
+      https-proxy-agent: 5.0.1
+      progress: 2.0.3
+    transitivePeerDependencies:
+      - supports-color
+
   find-up@4.1.0:
     dependencies:
       locate-path: 5.0.0
@@ -1282,6 +1400,19 @@ snapshots:
     dependencies:
       resolve-pkg-maps: 1.0.0
 
+  http-response-object@3.0.2:
+    dependencies:
+      '@types/node': 10.17.60
+
+  https-proxy-agent@5.0.1:
+    dependencies:
+      agent-base: 6.0.2
+      debug: 4.4.3
+    transitivePeerDependencies:
+      - supports-color
+
+  inherits@2.0.4: {}
+
   is-fullwidth-code-point@3.0.0: {}
 
   locate-path@5.0.0:
@@ -1290,6 +1421,8 @@ snapshots:
 
   matter-js@0.19.0: {}
 
+  ms@2.1.3: {}
+
   nanoid@3.3.11: {}
 
   p-limit@2.3.0:
@@ -1302,6 +1435,8 @@ snapshots:
 
   p-try@2.2.0: {}
 
+  parse-cache-control@1.0.1: {}
+
   path-exists@4.0.0: {}
 
   picocolors@1.1.1: {}
@@ -1314,12 +1449,20 @@ snapshots:
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
+  progress@2.0.3: {}
+
   qrcode@1.5.4:
     dependencies:
       dijkstrajs: 1.0.3
       pngjs: 5.0.0
       yargs: 15.4.1
 
+  readable-stream@3.6.2:
+    dependencies:
+      inherits: 2.0.4
+      string_decoder: 1.3.0
+      util-deprecate: 1.0.2
+
   require-directory@2.1.1: {}
 
   require-main-filename@2.0.0: {}
@@ -1357,6 +1500,8 @@ snapshots:
       '@rollup/rollup-win32-x64-msvc': 4.60.1
       fsevents: 2.3.3
 
+  safe-buffer@5.2.1: {}
+
   semver@7.7.4: {}
 
   set-blocking@2.0.0: {}
@@ -1400,6 +1545,10 @@ snapshots:
       is-fullwidth-code-point: 3.0.0
       strip-ansi: 6.0.1
 
+  string_decoder@1.3.0:
+    dependencies:
+      safe-buffer: 5.2.1
+
   strip-ansi@6.0.1:
     dependencies:
       ansi-regex: 5.0.1
@@ -1414,12 +1563,16 @@ snapshots:
     optionalDependencies:
       fsevents: 2.3.3
 
+  typedarray@0.0.6: {}
+
   typescript@5.9.3: {}
 
   undici-types@7.19.2: {}
 
   uqr@0.1.3: {}
 
+  util-deprecate@1.0.2: {}
+
   vite-plugin-qrcode@0.4.1(vite@5.4.21(@types/node@25.6.0)):
     dependencies:
       uqr: 0.1.3
diff --git a/src/config.ts b/src/config.ts
index 2f5a9c4..4647804 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -26,4 +26,7 @@ export const CONFIG = {
 
     // Orientation gravity
     orientationMaxTilt: 80,   // degrees before clamping
+
+    // Debug
+    logToVite: true,  // POST logs to Vite dev server (/__log) — useful for phone debugging
 } as const
diff --git a/src/main.ts b/src/main.ts
index 66344c3..1542fff 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,6 +4,7 @@ import { createRenderer } from './renderer'
 import { requestMotionPermission, initMotion } from './motion'
 import { initUI, removeStartOverlay, type ItemKind, type GravityMode } from './ui'
 import { unlockVibration, setVibrateEnabled, setVibrateItems, setVibrateEdges } from './vibration'
+import { preloadSounds } from './sound'
 import { dbg, dbgError } from './debug'
 import { checkCompat, showUnsupportedWall } from './compat'
 import {
@@ -57,6 +58,7 @@ async function main(): Promise<void> {
     let currentKind: ItemKind = 'random'
     const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
     let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL]
+    let refillEnabled = true
 
     // ── Restore persisted settings ────────────────────────────────────────
     const savedSettings = loadSettings()
@@ -87,6 +89,7 @@ async function main(): Promise<void> {
             ) as ItemKind[]
             if (valid.length > 0) randomPool = valid
         }
+        if (savedSettings.refill === false) refillEnabled = false
     }
 
     // ── Restore nut positions (share URL takes priority over snapshot) ────────
@@ -135,6 +138,11 @@ async function main(): Promise<void> {
     }
 
     // ── UI ───────────────────────────────────────────────────────────────────
+
+    function save(): void {
+        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled })
+    }
+
     initUI({
         initialRadius: currentNutRadius,
         initialLocked: tapsLocked,
@@ -144,42 +152,20 @@ async function main(): Promise<void> {
         initialVibrateEdges: vibrateEdgesEnabled,
         initialKind: currentKind,
         initialRandomPool: randomPool,
-        onSizeChange: (radius) => {
-            currentNutRadius = radius
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
-        },
+        initialRefill: refillEnabled,
+        onSizeChange: (radius) => { currentNutRadius = radius; save() },
         onClear: () => {
             physics.clearNuts()
             clearSnapshot()
         },
-        onLockChange: (locked) => {
-            tapsLocked = locked
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
-        },
-        onKindChange: (kind) => {
-            currentKind = kind
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
-        },
-        onGravityModeChange: (mode) => {
-            gravityMode = mode
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
-        },
-        onVibrateChange: (enabled) => {
-            vibrateEnabled = enabled
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
-        },
-        onVibrateItemsChange: (enabled) => {
-            vibrateItemsEnabled = enabled
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
-        },
-        onVibrateEdgesChange: (enabled) => {
-            vibrateEdgesEnabled = enabled
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
-        },
-        onRandomPoolChange: (pool) => {
-            randomPool = pool
-            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
-        },
+        onLockChange: (locked) => { tapsLocked = locked; save() },
+        onKindChange: (kind) => { currentKind = kind; save() },
+        onGravityModeChange: (mode) => { gravityMode = mode; save() },
+        onVibrateChange: (enabled) => { vibrateEnabled = enabled; save() },
+        onVibrateItemsChange: (enabled) => { vibrateItemsEnabled = enabled; save() },
+        onVibrateEdgesChange: (enabled) => { vibrateEdgesEnabled = enabled; save() },
+        onRandomPoolChange: (pool) => { randomPool = pool; save() },
+        onRefillChange: (enabled) => { refillEnabled = enabled; save() },
         onSettingsOpen: () => { settingsPaused = true },
         onSettingsClose: () => { settingsPaused = false },
         getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
@@ -215,6 +201,30 @@ async function main(): Promise<void> {
         }
     }
 
+    function spawnRefill(kind: string, radius: number): void {
+        // Try a few random positions along the top third of the screen
+        for (let attempt = 0; attempt < 20; attempt++) {
+            const x = radius + Math.random() * (cssWidth - radius * 2)
+            const y = radius + Math.random() * (cssHeight * 0.35)
+            if (!hasSpace(x, y, radius)) continue
+            if (kind === 'balloon') physics.addBalloon(x, y, radius)
+            else if (kind === 'rock') physics.addRock(x, y, radius)
+            else if (kind === 'gem') physics.addGem(x, y, radius)
+            else if (kind === 'metalnut') physics.addMetalNut(x, y, radius)
+            else if (kind === 'stressball') physics.addStressBall(x, y, radius)
+            else if (kind === 'coin') physics.addCoin(x, y, radius)
+            else physics.addNut(x, y, radius)
+            return
+        }
+    }
+
+    // ── Refill hook ──────────────────────────────────────────────────────────
+    physics.onRemoved = (kind, radius) => {
+        if (!refillEnabled) return
+        // Delay slightly so the removal settles before we add a new body
+        setTimeout(() => spawnRefill(kind, radius), 600)
+    }
+
 
     // ── Pointer: tap/drag to add; tap/drag-through to erase ─────────────────
     // Mode is decided on pointerdown: if we hit an existing item we erase,
@@ -314,6 +324,7 @@ async function main(): Promise<void> {
                 dbgError('requestMotionPermission failed', e)
             }
             unlockVibration()  // must be called from gesture handler
+            preloadSounds()    // kick off audio fetch after first gesture (autoplay policy)
             await enterFullscreen()
             startMotion()
             removeStartOverlay()
@@ -426,7 +437,7 @@ async function main(): Promise<void> {
     // Also save on tab close / navigation
     window.addEventListener('pagehide', () => {
         saveSnapshot(physics.nuts, cssWidth, cssHeight)
-        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool })
+        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled })
     })
 }
 
diff --git a/src/persistence.ts b/src/persistence.ts
index 0a47fd1..41e572f 100644
--- a/src/persistence.ts
+++ b/src/persistence.ts
@@ -1,8 +1,8 @@
-const DATA_VERSION = 2
+const DATA_VERSION = 3
 
 const KEYS = {
-    settings: 'nuts-settings-v2',
-    snapshot: 'nuts-snapshot-v2',
+    settings: 'nuts-settings-v3',
+    snapshot: 'nuts-snapshot-v3',
 } as const
 
 // ── Settings ───────────────────────────────────────────────────────────────
@@ -16,6 +16,7 @@ export interface PersistedSettings {
     vibrateEdges?: boolean
     kind?: string
     randomPool?: string[]
+    refill?: boolean
 }
 
 export function loadSettings(): PersistedSettings | null {
diff --git a/src/physics.ts b/src/physics.ts
index ec62e3a..4459af3 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -1,6 +1,7 @@
 import Matter from 'matter-js'
 import { CONFIG } from './config'
 import { vibrate, vibrateWall } from './vibration'
+import { playImpact, type ImpactKind } from './sound'
 import { dbg } from './debug'
 import { ROCK_TEMPLATES } from './rockTemplates'
 
@@ -25,6 +26,7 @@ export interface NutBody {
 export interface PhysicsWorld {
     engine: Matter.Engine
     nuts: NutBody[]
+    onRemoved: ((kind: NutBody['kind'], radius: number) => void) | null
     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
@@ -236,6 +238,18 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
     })
 
     // ── Collision haptics ──────────────────────────────────────────────────
+
+    function impactSoundKind(labelA: string, labelB = ''): ImpactKind {
+        const has = (l: string) => labelA === l || labelB === l
+        if (has('rock')) return 'rock'
+        if (has('metalnut')) return 'metalnut'
+        if (has('coin')) return 'coin'
+        if (has('gem')) return 'gem'
+        if (has('stressball')) return 'stressball'
+        if (has('balloon')) return 'balloon'
+        return 'nut'
+    }
+
     Events.on(engine, 'collisionStart', (event: Matter.IEventCollision<Matter.Engine>) => {
         for (const pair of event.pairs) {
             const aWall = pair.bodyA.label === 'wall'
@@ -308,19 +322,26 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                 // Body-body collision
                 dbg('nut-nut speed:', speed.toFixed(2))
                 if (speed > CONFIG.impactThreshold) {
+                    const sndKind = impactSoundKind(pair.bodyA.label, pair.bodyB.label)
+                    const nbA = nutBodyMap.get(pair.bodyA)
+                    const nbB = nutBodyMap.get(pair.bodyB)
+                    const avgRadius = ((nbA?.radius ?? 60) + (nbB?.radius ?? 60)) / 2
                     if (aRock || bRock) {
-                        // rock collision: earthy thud
                         const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1)
                         vibrate(Math.round(12 + t * 28))
+                        playImpact(sndKind, 0.15 + t * 0.85, avgRadius)
                     } else if (aGem && bGem) {
                         const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1)
                         vibrate(Math.round(8 + t * 18))
+                        playImpact(sndKind, 0.15 + t * 0.85, avgRadius)
                     } else if (gemInvolved) {
                         const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1)
                         vibrate(Math.round(10 + t * 22))
+                        playImpact(sndKind, 0.15 + t * 0.85, avgRadius)
                     } else {
                         const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
                         vibrate(Math.round(10 + t * 30))
+                        playImpact(sndKind, 0.15 + t * 0.85, avgRadius)
                     }
                 }
             } else if (aNut || bNut) {
@@ -328,17 +349,22 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                 const nutBody = aWall ? pair.bodyB : pair.bodyA
                 if (!ITEM_LABELS.has(nutBody.label as never)) continue
                 const wallSpeed = nutBody.speed
+                const itemRadius = nutBodyMap.get(nutBody)?.radius ?? 60
                 dbg('wall hit speed:', wallSpeed.toFixed(2))
                 if (wallSpeed > CONFIG.impactThreshold) {
+                    const sndKind = impactSoundKind(nutBody.label)
                     if (nutBody.label === 'rock') {
                         const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1)
                         vibrateWall(Math.round(15 + t * 30))
+                        playImpact(sndKind, 0.1 + t * 0.7, itemRadius)
                     } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') {
                         const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1)
                         vibrateWall(Math.round(15 + t * 35))
+                        playImpact(sndKind, 0.1 + t * 0.7, itemRadius)
                     } else {
                         const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1)
                         vibrateWall(Math.round(30 + t * (CONFIG.maxVibrationMs - 30)))
+                        playImpact(sndKind, 0.1 + t * 0.7, itemRadius)
                     }
                 }
             }
@@ -419,6 +445,8 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             Composite.remove(engine.world, nut.body)
             nuts.splice(idx, 1)
             vibrate(70)  // satisfying pop
+            playImpact('balloon_pop', 0.9, nut.radius)
+            world.onRemoved?.(nut.kind, nut.radius)
         }
     }
 
@@ -447,7 +475,11 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             const fragRadius = (nut.radius / Math.sqrt(count)) * 0.95
 
             for (let i = 0; i < count; i++) {
-                if (fragRadius < CONFIG.nutMinRadius * 0.5) continue
+                // Fragment too small — fully disintegrated
+                if (fragRadius < CONFIG.nutMinRadius * 0.5) {
+                    world.onRemoved?.(nut.kind, nut.radius)
+                    continue
+                }
                 const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.5
                 const dist = nut.radius * 0.35
                 const fx = pos.x + Math.cos(spreadAngle) * dist
@@ -518,9 +550,10 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Engine.clear(engine)
     }
 
-    return {
+    const world: PhysicsWorld = {
         engine,
         get nuts() { return nuts },
+        onRemoved: null,
         addNut,
         addGem,
         addBalloon,
@@ -536,4 +569,5 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         step,
         destroy,
     }
+    return world
 }
diff --git a/src/sound.ts b/src/sound.ts
new file mode 100644
index 0000000..0e863a0
--- /dev/null
+++ b/src/sound.ts
@@ -0,0 +1,113 @@
+// Sound effects — Kenney Impact Sounds (CC0 1.0)
+// Uses Web Audio API with pre-decoded AudioBuffers so multiple sounds can
+// play simultaneously (polyphonic). HTMLAudio would serialize on mobile.
+import { CONFIG } from './config'
+
+export type ImpactKind =
+    | 'nut'
+    | 'gem'
+    | 'metalnut'
+    | 'rock'
+    | 'stressball'
+    | 'balloon'
+    | 'balloon_pop'
+    | 'coin'
+
+const BASE: Record<ImpactKind, string> = {
+    nut: '/sounds/impactWood_medium_',
+    gem: '/sounds/impactGlass_medium_',
+    metalnut: '/sounds/impactMetal_heavy_',
+    rock: '/sounds/impactMining_',
+    stressball: '/sounds/impactSoft_heavy_',
+    balloon: '/sounds/impactSoft_medium_',
+    balloon_pop: '/sounds/impactPunch_heavy_',
+    coin: '/sounds/impactTin_medium_',
+}
+
+const VARIANTS = 3
+const COOLDOWN_MS = 60  // min ms between sounds of the same kind
+
+let ctx: AudioContext | null = null
+// url → decoded buffer (null while fetch is in flight)
+const buffers = new Map<string, AudioBuffer | null>()
+
+function getCtx(): AudioContext {
+    if (!ctx) ctx = new AudioContext()
+    return ctx
+}
+
+function allUrls(): string[] {
+    return Object.values(BASE).flatMap(base =>
+        Array.from({ length: VARIANTS }, (_, v) =>
+            `${base}${String(v).padStart(3, '0')}.mp3`
+        )
+    )
+}
+
+async function loadBuffer(url: string): Promise<void> {
+    if (buffers.has(url)) return
+    buffers.set(url, null)  // mark in-flight
+    try {
+        const res = await fetch(url)
+        const ab = await res.arrayBuffer()
+        const decoded = await getCtx().decodeAudioData(ab)
+        buffers.set(url, decoded)
+    } catch (e) {
+        remoteLog(`load failed ${url}: ${e}`)
+        buffers.delete(url)
+    }
+}
+
+/** Preload all sounds. Must be called from a user-gesture handler (unlocks AudioContext). */
+export function preloadSounds(): void {
+    if (ctx?.state === 'suspended') ctx.resume()
+    for (const url of allUrls()) loadBuffer(url)
+    remoteLog('preloadSounds called')
+}
+
+const lastPlay = new Map<ImpactKind, number>()
+
+/**
+ * Play an impact sound for the given kind, scaled by `intensity` (0–1) and
+ * `radius` (px). Larger radius → louder and deeper; smaller → quieter and higher.
+ * Silently ignored if the same kind was played too recently.
+ */
+export function playImpact(kind: ImpactKind, intensity: number, radius = 60): void {
+    const now = performance.now()
+    if ((now - (lastPlay.get(kind) ?? 0)) < COOLDOWN_MS) return
+    lastPlay.set(kind, now)
+
+    const variant = Math.floor(Math.random() * VARIANTS)
+    const url = `${BASE[kind]}${String(variant).padStart(3, '0')}.mp3`
+    const buf = buffers.get(url)
+    remoteLog(`${kind} r=${Math.round(radius)} vol=${intensity.toFixed(2)} buf=${buf ? 'ready' : 'missing'} → ${url}`)
+    if (!buf) return
+
+    // Pitch: map radius to playbackRate on a log scale.
+    // Reference 60px → rate 1.0. Half radius → ~1.5× (higher); double → ~0.67× (deeper).
+    const REF_RADIUS = 60
+    const playbackRate = Math.pow(REF_RADIUS / Math.max(radius, 4), 0.42)
+
+    // Volume: larger items are louder (sqrt keeps it from getting too extreme)
+    const sizeGain = Math.pow(radius / REF_RADIUS, 0.4)
+    const volume = Math.min(Math.max(intensity * sizeGain, 0), 1)
+
+    const ac = getCtx()
+    const gain = ac.createGain()
+    gain.gain.value = volume
+    gain.connect(ac.destination)
+    const src = ac.createBufferSource()
+    src.buffer = buf
+    src.playbackRate.value = playbackRate
+    src.connect(gain)
+    src.start()
+}
+
+function remoteLog(msg: string): void {
+    if (!CONFIG.logToVite) return
+    fetch('/__log', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ level: 'info', msg: `[sound] ${msg}` }),
+    }).catch(() => { /* dev server not available */ })
+}
diff --git a/src/sounds/impactGlass_medium_000.ogg b/src/sounds/impactGlass_medium_000.ogg
new file mode 100644
index 0000000..e056cdb
Binary files /dev/null and b/src/sounds/impactGlass_medium_000.ogg differ
diff --git a/src/sounds/impactGlass_medium_001.ogg b/src/sounds/impactGlass_medium_001.ogg
new file mode 100644
index 0000000..16ebe09
Binary files /dev/null and b/src/sounds/impactGlass_medium_001.ogg differ
diff --git a/src/sounds/impactGlass_medium_002.ogg b/src/sounds/impactGlass_medium_002.ogg
new file mode 100644
index 0000000..8f5efe5
Binary files /dev/null and b/src/sounds/impactGlass_medium_002.ogg differ
diff --git a/src/sounds/impactMetal_heavy_000.ogg b/src/sounds/impactMetal_heavy_000.ogg
new file mode 100644
index 0000000..d4432f4
Binary files /dev/null and b/src/sounds/impactMetal_heavy_000.ogg differ
diff --git a/src/sounds/impactMetal_heavy_001.ogg b/src/sounds/impactMetal_heavy_001.ogg
new file mode 100644
index 0000000..e510d3e
Binary files /dev/null and b/src/sounds/impactMetal_heavy_001.ogg differ
diff --git a/src/sounds/impactMetal_heavy_002.ogg b/src/sounds/impactMetal_heavy_002.ogg
new file mode 100644
index 0000000..c757121
Binary files /dev/null and b/src/sounds/impactMetal_heavy_002.ogg differ
diff --git a/src/sounds/impactMining_000.ogg b/src/sounds/impactMining_000.ogg
new file mode 100644
index 0000000..3346d1a
Binary files /dev/null and b/src/sounds/impactMining_000.ogg differ
diff --git a/src/sounds/impactMining_001.ogg b/src/sounds/impactMining_001.ogg
new file mode 100644
index 0000000..b5475fa
Binary files /dev/null and b/src/sounds/impactMining_001.ogg differ
diff --git a/src/sounds/impactMining_002.ogg b/src/sounds/impactMining_002.ogg
new file mode 100644
index 0000000..5bd4f35
Binary files /dev/null and b/src/sounds/impactMining_002.ogg differ
diff --git a/src/sounds/impactPunch_heavy_000.ogg b/src/sounds/impactPunch_heavy_000.ogg
new file mode 100644
index 0000000..55e70b5
Binary files /dev/null and b/src/sounds/impactPunch_heavy_000.ogg differ
diff --git a/src/sounds/impactPunch_heavy_001.ogg b/src/sounds/impactPunch_heavy_001.ogg
new file mode 100644
index 0000000..6655951
Binary files /dev/null and b/src/sounds/impactPunch_heavy_001.ogg differ
diff --git a/src/sounds/impactPunch_heavy_002.ogg b/src/sounds/impactPunch_heavy_002.ogg
new file mode 100644
index 0000000..8573e89
Binary files /dev/null and b/src/sounds/impactPunch_heavy_002.ogg differ
diff --git a/src/sounds/impactSoft_heavy_000.ogg b/src/sounds/impactSoft_heavy_000.ogg
new file mode 100644
index 0000000..1c321ef
Binary files /dev/null and b/src/sounds/impactSoft_heavy_000.ogg differ
diff --git a/src/sounds/impactSoft_heavy_001.ogg b/src/sounds/impactSoft_heavy_001.ogg
new file mode 100644
index 0000000..d7fbb0f
Binary files /dev/null and b/src/sounds/impactSoft_heavy_001.ogg differ
diff --git a/src/sounds/impactSoft_heavy_002.ogg b/src/sounds/impactSoft_heavy_002.ogg
new file mode 100644
index 0000000..4748c69
Binary files /dev/null and b/src/sounds/impactSoft_heavy_002.ogg differ
diff --git a/src/sounds/impactSoft_medium_000.ogg b/src/sounds/impactSoft_medium_000.ogg
new file mode 100644
index 0000000..bf1027b
Binary files /dev/null and b/src/sounds/impactSoft_medium_000.ogg differ
diff --git a/src/sounds/impactSoft_medium_001.ogg b/src/sounds/impactSoft_medium_001.ogg
new file mode 100644
index 0000000..a8c6434
Binary files /dev/null and b/src/sounds/impactSoft_medium_001.ogg differ
diff --git a/src/sounds/impactSoft_medium_002.ogg b/src/sounds/impactSoft_medium_002.ogg
new file mode 100644
index 0000000..5409c00
Binary files /dev/null and b/src/sounds/impactSoft_medium_002.ogg differ
diff --git a/src/sounds/impactTin_medium_000.ogg b/src/sounds/impactTin_medium_000.ogg
new file mode 100644
index 0000000..1f3acf7
Binary files /dev/null and b/src/sounds/impactTin_medium_000.ogg differ
diff --git a/src/sounds/impactTin_medium_001.ogg b/src/sounds/impactTin_medium_001.ogg
new file mode 100644
index 0000000..c45507c
Binary files /dev/null and b/src/sounds/impactTin_medium_001.ogg differ
diff --git a/src/sounds/impactTin_medium_002.ogg b/src/sounds/impactTin_medium_002.ogg
new file mode 100644
index 0000000..18d83d3
Binary files /dev/null and b/src/sounds/impactTin_medium_002.ogg differ
diff --git a/src/sounds/impactWood_medium_000.ogg b/src/sounds/impactWood_medium_000.ogg
new file mode 100644
index 0000000..97735ff
Binary files /dev/null and b/src/sounds/impactWood_medium_000.ogg differ
diff --git a/src/sounds/impactWood_medium_001.ogg b/src/sounds/impactWood_medium_001.ogg
new file mode 100644
index 0000000..a314921
Binary files /dev/null and b/src/sounds/impactWood_medium_001.ogg differ
diff --git a/src/sounds/impactWood_medium_002.ogg b/src/sounds/impactWood_medium_002.ogg
new file mode 100644
index 0000000..d5cf1d0
Binary files /dev/null and b/src/sounds/impactWood_medium_002.ogg differ
diff --git a/src/ui.ts b/src/ui.ts
index 2b4d3e8..2210b39 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -28,6 +28,7 @@ export interface UICallbacks {
     initialVibrateEdges: boolean
     initialKind: ItemKind
     initialRandomPool: ItemKind[]
+    initialRefill: boolean
     onSizeChange: (radius: number) => void
     onClear: () => void
     onLockChange: (locked: boolean) => void
@@ -37,6 +38,7 @@ export interface UICallbacks {
     onVibrateItemsChange: (enabled: boolean) => void
     onVibrateEdgesChange: (enabled: boolean) => void
     onRandomPoolChange: (pool: ItemKind[]) => void
+    onRefillChange: (enabled: boolean) => void
     onSettingsOpen: () => void
     onSettingsClose: () => void
     getShareURL: () => string
@@ -255,9 +257,20 @@ export function initUI(callbacks: UICallbacks): void {
     }
     poolSection.appendChild(poolGrid)
 
+    // ── Refill toggle ────────────────────────────────────────────────────────
+    const refillBtn = document.createElement('button')
+    refillBtn.className = 'action-btn' + (callbacks.initialRefill ? ' active' : '')
+    refillBtn.textContent = callbacks.initialRefill ? 'Refill: On' : 'Refill: Off'
+    refillBtn.addEventListener('click', () => {
+        const on = refillBtn.classList.toggle('active')
+        refillBtn.textContent = on ? 'Refill: On' : 'Refill: Off'
+        callbacks.onRefillChange(on)
+    })
+
     // Insert before the close button
     const closeBtn2 = settingsPanel.querySelector('#close-settings') as HTMLButtonElement
     closeBtn2.parentElement!.insertBefore(poolSection, closeBtn2)
+    closeBtn2.parentElement!.insertBefore(refillBtn, closeBtn2)
 
     copyLinkBtn.addEventListener('click', () => {
         const url = callbacks.getShareURL()
diff --git a/vite.config.ts b/vite.config.ts
index 2484030..fe40fe6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,6 +2,85 @@ 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'
+import { execFileSync } from 'node:child_process'
+import { readdirSync, readFileSync, existsSync, mkdirSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+
+const require = createRequire(import.meta.url)
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const ffmpeg: string = require('ffmpeg-static')
+
+const SOUNDS_SRC = new URL('./src/sounds', import.meta.url).pathname
+
+// The specific files sound.ts references (category prefix only, 000–002 appended)
+const SOUND_CATEGORIES = [
+    'impactWood_medium',
+    'impactGlass_medium',
+    'impactMetal_heavy',
+    'impactMining',
+    'impactSoft_heavy',
+    'impactSoft_medium',
+    'impactPunch_heavy',
+    'impactTin_medium',
+]
+
+function oggToMp3(src: string, dest: string): void {
+    mkdirSync(dirname(dest), { recursive: true })
+    execFileSync(ffmpeg, ['-i', src, '-q:a', '4', '-y', dest], { stdio: 'pipe' })
+}
+
+function soundFiles(): string[] {
+    return SOUND_CATEGORIES.flatMap(cat =>
+        [0, 1, 2].map(v => `${cat}_${String(v).padStart(3, '0')}`)
+    )
+}
+
+function sounds(): Plugin {
+    // Temp dir for dev-server cache
+    const cache = join(tmpdir(), 'nuts-sounds')
+    mkdirSync(cache, { recursive: true })
+
+    return {
+        name: 'sounds',
+
+        // Build: convert + emit each MP3 as an asset
+        async generateBundle() {
+            for (const name of soundFiles()) {
+                const src = join(SOUNDS_SRC, `${name}.ogg`)
+                if (!existsSync(src)) continue
+                const tmp = join(cache, `${name}.mp3`)
+                if (!existsSync(tmp)) oggToMp3(src, tmp)
+                this.emitFile({
+                    type: 'asset',
+                    fileName: `sounds/${name}.mp3`,
+                    source: readFileSync(tmp),
+                })
+            }
+        },
+
+        // Dev: convert on first request, then serve from cache
+        configureServer(server) {
+            server.middlewares.use('/sounds/', (req: IncomingMessage, res: ServerResponse) => {
+                const mp3Name = (req.url ?? '').replace(/^\//, '')
+                if (!mp3Name.endsWith('.mp3')) { res.writeHead(404).end(); return }
+                const oggName = mp3Name.replace(/\.mp3$/, '.ogg')
+                const src = join(SOUNDS_SRC, oggName)
+                const dest = join(cache, mp3Name)
+                if (!existsSync(src)) { res.writeHead(404).end(); return }
+                try {
+                    if (!existsSync(dest)) oggToMp3(src, dest)
+                    const data = readFileSync(dest)
+                    res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'Content-Length': data.length })
+                    res.end(data)
+                } catch (e) {
+                    res.writeHead(500).end(String(e))
+                }
+            })
+        },
+    }
+}
 
 function remoteLog(): Plugin {
     return {
@@ -43,5 +122,5 @@ export default defineConfig({
     server: {
         host: true, // listen on 0.0.0.0 so the phone can reach it
     },
-    plugins: [remoteLog(), basicSsl(), qrcode()],
+    plugins: [sounds(), remoteLog(), basicSsl(), qrcode()],
 })