fuzzyworld

bubbles / commit

got the basics working

fc6e1fbf8176f95b1e4d3b1bf316641c1d07f0af · Pavel Safronov · 2026-04-08 18:06:53 -0700 · parents dcc4757ffe4b

got the basics working
 index.html                     | 127 ++++++++++++++++++++++
 package.json                   |  10 +-
 pnpm-lock.yaml                 | 232 +++++++++++++++++++++++++++++++++++++++++
 src/App.ts                     |  25 +++++
 src/icons/registry.ts          |   7 +-
 src/input/TiltInput.ts         |  17 +--
 src/input/TouchInput.ts        |  16 +--
 src/main.ts                    |  63 ++++++++++-
 src/physics/RigidBody.ts       |   8 ++
 src/physics/Water.ts           |  61 ++++++++---
 src/physics/World.ts           |  64 ++++++++----
 src/rendering/IconRenderer.ts  |  15 +--
 src/rendering/Renderer.ts      |  35 ++++++-
 src/rendering/WaterRenderer.ts |   3 +
 vite.config.ts                 |   2 +
 15 files changed, 611 insertions(+), 74 deletions(-)

diff --git a/index.html b/index.html
index 5d05ecc..b1294de 100644
--- a/index.html
+++ b/index.html
@@ -37,6 +37,13 @@
             touch-action: none;
         }
 
+        /* CSS fallback: if orientation lock API didn't work, rotate everything back to portrait */
+        @media screen and (orientation: landscape) {
+            #rotate-overlay {
+                display: flex !important;
+            }
+        }
+
         #app {
             display: flex;
             flex-direction: column;
@@ -80,12 +87,132 @@
             text-decoration: underline;
             text-underline-offset: 2px;
         }
+
+        /* ── Start screen ───────────────────────────────────────────────── */
+        #start-screen {
+            position: fixed;
+            inset: 0;
+            z-index: 100;
+            background: #0f172a;
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            justify-content: center;
+            gap: 32px;
+            padding: 40px;
+            transition: opacity 0.4s, visibility 0.4s;
+        }
+
+        #start-screen.hidden {
+            opacity: 0;
+            visibility: hidden;
+            pointer-events: none;
+        }
+
+        #start-screen .logo {
+            width: 90px;
+            height: 90px;
+            opacity: 0.85;
+        }
+
+        #start-screen h1 {
+            font-size: 28px;
+            font-weight: 700;
+            letter-spacing: 0.04em;
+            color: #e2e8f0;
+            margin: 0;
+        }
+
+        #start-screen p {
+            font-size: 15px;
+            color: rgba(226, 232, 240, 0.5);
+            text-align: center;
+            max-width: 260px;
+            line-height: 1.6;
+            margin: 0;
+        }
+
+        #start-btn {
+            margin-top: 8px;
+            padding: 16px 48px;
+            font-size: 17px;
+            font-weight: 600;
+            font-family: inherit;
+            color: #0f172a;
+            background: #38bdf8;
+            border: none;
+            border-radius: 999px;
+            cursor: pointer;
+            touch-action: manipulation;
+            -webkit-tap-highlight-color: transparent;
+            transition: background 0.15s, transform 0.1s;
+        }
+
+        #start-btn:active {
+            background: #7dd3fc;
+            transform: scale(0.97);
+        }
+
+        #reset-btn {
+            position: absolute;
+            top: 12px;
+            right: 12px;
+            width: 40px;
+            height: 40px;
+            border: none;
+            border-radius: 10px;
+            background: rgba(255, 255, 255, 0.09);
+            color: rgba(226, 232, 240, 0.6);
+            cursor: pointer;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            transition: background 0.15s, color 0.15s;
+            -webkit-tap-highlight-color: transparent;
+            touch-action: manipulation;
+        }
+
+        #reset-btn:hover,
+        #reset-btn:active {
+            background: rgba(255, 255, 255, 0.18);
+            color: rgba(226, 232, 240, 0.95);
+        }
+
+        #reset-btn svg {
+            width: 20px;
+            height: 20px;
+            fill: currentColor;
+        }
     </style>
 </head>
 
 <body>
     <div id="app">
+        <!-- Landscape blocker -->
+        <div id="rotate-overlay"
+            style="display:none;position:fixed;inset:0;z-index:9999;background:#0f172a;flex-direction:column;align-items:center;justify-content:center;gap:16px;font-size:15px;color:rgba(226,232,240,0.7);text-align:center;padding:24px">
+            <svg width="48" height="48" viewBox="0 0 24 24" fill="rgba(226,232,240,0.5)">
+                <path
+                    d="M16.48 2.52c3.27 1.55 5.61 4.72 5.97 8.48h1.5C23.44 4.84 18.29 0 12 0l-.66.03 3.81 3.81 1.33-1.32zm-6.25-.77c-.59-.59-1.54-.59-2.12 0L1.75 8.11c-.59.59-.59 1.54 0 2.12l12.02 12.02c.59.59 1.54.59 2.12 0l6.36-6.36c.59-.59.59-1.54 0-2.12L10.23 1.75zm4.6 19.44L2.81 9.17l6.36-6.36 12.02 12.02-6.36 6.36zm-7.57.29C4.01 19.92 1.67 16.75 1.31 13H-.19C.56 19.16 5.71 24 12 24l.66-.03-3.81-3.81-1.33 1.32z" />
+            </svg>
+            <span>Please rotate back to portrait</span>
+        </div>
+
+        <!-- Start screen -->
+        <div id="start-screen">
+            <img class="logo" src="favicon.ico" alt="Bubbles logo" width="90" height="90" />
+            <h1>Bubbles</h1>
+            <p>Your phone is a glass of water.<br>Tilt to slosh. Tap to drop things in.</p>
+            <button id="start-btn">Start</button>
+        </div>
+
         <canvas id="canvas"></canvas>
+        <button id="reset-btn" title="Reset" aria-label="Reset simulation">
+            <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+                <path
+                    d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
+            </svg>
+        </button>
         <p id="hint">tap to drop · tilt to slosh</p>
         <footer>
             <a href="https://gitlab.com/PavelSafronov/bubbles" target="_blank" rel="noopener">gitlab</a>
diff --git a/package.json b/package.json
index c53eae4..03cb05b 100644
--- a/package.json
+++ b/package.json
@@ -4,17 +4,21 @@
     "private": true,
     "description": "Silly water glass web app — tilt your phone to slosh water, tap to drop things in",
     "scripts": {
-        "dev": "vite",
+        "dev": "vite --host",
         "build": "tsc --noEmit && vite build",
         "preview": "vite preview",
         "gen-assets": "tsx scripts/generate-assets.ts"
     },
-    "dependencies": {},
     "devDependencies": {
         "@types/node": "^22.0.0",
+        "@types/qrcode": "^1.5.6",
         "sharp": "^0.33.5",
         "tsx": "^4.19.2",
         "typescript": "^5.7.3",
-        "vite": "^6.2.5"
+        "vite": "^6.2.5",
+        "vite-plugin-qrcode": "^0.4.1"
+    },
+    "dependencies": {
+        "qrcode": "^1.5.4"
     }
 }
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 94a0232..a171847 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,10 +7,17 @@ settings:
 importers:
 
   .:
+    dependencies:
+      qrcode:
+        specifier: ^1.5.4
+        version: 1.5.4
     devDependencies:
       '@types/node':
         specifier: ^22.0.0
         version: 22.19.17
+      '@types/qrcode':
+        specifier: ^1.5.6
+        version: 1.5.6
       sharp:
         specifier: ^0.33.5
         version: 0.33.5
@@ -23,6 +30,9 @@ importers:
       vite:
         specifier: ^6.2.5
         version: 6.4.2(@types/node@22.19.17)(tsx@4.21.0)
+      vite-plugin-qrcode:
+        specifier: ^0.4.1
+        version: 0.4.1(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0))
 
 packages:
 
@@ -602,6 +612,24 @@ packages:
   '@types/node@22.19.17':
     resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
 
+  '@types/qrcode@1.5.6':
+    resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
+  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'}
@@ -616,10 +644,20 @@ packages:
     resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
     engines: {node: '>=12.5.0'}
 
+  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.25.12:
     resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
     engines: {node: '>=18'}
@@ -639,22 +677,54 @@ packages:
       picomatch:
         optional: 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-arrayish@0.3.4:
     resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
 
+  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'}
+
   nanoid@3.3.11:
     resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
     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==}
 
@@ -662,10 +732,26 @@ packages:
     resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
     engines: {node: '>=12'}
 
+  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==}
 
@@ -679,6 +765,9 @@ packages:
     engines: {node: '>=10'}
     hasBin: true
 
+  set-blocking@2.0.0:
+    resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
   sharp@0.33.5:
     resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -690,6 +779,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'}
+
   tinyglobby@0.2.16:
     resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
     engines: {node: '>=12.0.0'}
@@ -710,6 +807,14 @@ packages:
   undici-types@6.21.0:
     resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
 
+  uqr@0.1.3:
+    resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==}
+
+  vite-plugin-qrcode@0.4.1:
+    resolution: {integrity: sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==}
+    peerDependencies:
+      vite: '>=3'
+
   vite@6.4.2:
     resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
     engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -750,6 +855,24 @@ packages:
       yaml:
         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':
@@ -1069,6 +1192,24 @@ snapshots:
     dependencies:
       undici-types: 6.21.0
 
+  '@types/qrcode@1.5.6':
+    dependencies:
+      '@types/node': 22.19.17
+
+  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
@@ -1085,8 +1226,14 @@ snapshots:
       color-convert: 2.0.1
       color-string: 1.9.1
 
+  decamelize@1.2.0: {}
+
   detect-libc@2.1.2: {}
 
+  dijkstrajs@1.0.3: {}
+
+  emoji-regex@8.0.0: {}
+
   esbuild@0.25.12:
     optionalDependencies:
       '@esbuild/aix-ppc64': 0.25.12
@@ -1149,27 +1296,64 @@ snapshots:
     optionalDependencies:
       picomatch: 4.0.4
 
+  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-arrayish@0.3.4: {}
 
+  is-fullwidth-code-point@3.0.0: {}
+
+  locate-path@5.0.0:
+    dependencies:
+      p-locate: 4.1.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: {}
 
   picomatch@4.0.4: {}
 
+  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:
@@ -1205,6 +1389,8 @@ snapshots:
 
   semver@7.7.4: {}
 
+  set-blocking@2.0.0: {}
+
   sharp@0.33.5:
     dependencies:
       color: 4.2.3
@@ -1237,6 +1423,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
+
   tinyglobby@0.2.16:
     dependencies:
       fdir: 6.5.0(picomatch@4.0.4)
@@ -1256,6 +1452,13 @@ snapshots:
 
   undici-types@6.21.0: {}
 
+  uqr@0.1.3: {}
+
+  vite-plugin-qrcode@0.4.1(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)):
+    dependencies:
+      uqr: 0.1.3
+      vite: 6.4.2(@types/node@22.19.17)(tsx@4.21.0)
+
   vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0):
     dependencies:
       esbuild: 0.25.12
@@ -1268,3 +1471,32 @@ snapshots:
       '@types/node': 22.19.17
       fsevents: 2.3.3
       tsx: 4.21.0
+
+  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/src/App.ts b/src/App.ts
index 3b9ca34..4f1a293 100644
--- a/src/App.ts
+++ b/src/App.ts
@@ -1,5 +1,7 @@
 import { World } from './physics/World.js';
 import { Water } from './physics/Water.js';
+import { RigidBody } from './physics/RigidBody.js';
+import { Vector2 } from './physics/Vector2.js';
 import { Renderer } from './rendering/Renderer.js';
 import { TiltInput } from './input/TiltInput.js';
 import { TouchInput } from './input/TouchInput.js';
@@ -11,6 +13,7 @@ export class App {
     private readonly renderer: Renderer;
     private readonly tilt: TiltInput;
     private readonly touch: TouchInput;
+    private qrBody: import('./physics/RigidBody.js').RigidBody | undefined;
 
     private rafId = 0;
     private lastTime = 0;
@@ -37,11 +40,33 @@ export class App {
         this.touch = new TouchInput(canvas, this.world, () => this.requestPermissionOnce());
     }
 
+    /** Call after the QR canvas has been generated to spawn the QR body. */
+    spawnQRBody(qrTexture: HTMLCanvasElement): void {
+        const r = 40;
+        const x = this.world.glassX + this.world.glassW / 2;
+        const y = this.world.glassY + r + 4;
+        this.qrBody = new RigidBody({
+            position: new Vector2(x, y),
+            radius: r,
+            density: 0.5,
+            label: '',
+            texture: qrTexture,
+            permanent: true,
+        });
+        this.world.addBody(this.qrBody);
+    }
+
     start(): void {
         this.lastTime = performance.now();
         this.rafId = requestAnimationFrame(this.loop);
     }
 
+    reset(): void {
+        // Keep only the permanent QR body
+        this.world.bodies = this.world.bodies.filter(b => b.permanent);
+        this.world.water.resetFill(0.55);
+    }
+
     stop(): void {
         cancelAnimationFrame(this.rafId);
         this.renderer.destroy();
diff --git a/src/icons/registry.ts b/src/icons/registry.ts
index a953619..0061e47 100644
--- a/src/icons/registry.ts
+++ b/src/icons/registry.ts
@@ -7,14 +7,13 @@ export interface IconDef {
 }
 
 export const ICONS: IconDef[] = [
-    { label: '🏓', density: 0.10, radius: 22 }, // ping pong — bobs wildly
-    { label: '🦆', density: 0.50, radius: 28 }, // duck — floats high
+    { label: '⚽', density: 0.10, radius: 22 }, // soccer ball — bobs wildly
+    { label: '🪵', density: 0.50, radius: 28 }, // wood log — floats high
     { label: '🍎', density: 0.80, radius: 26 }, // apple — floats
     { label: '🍋', density: 0.85, radius: 24 }, // lemon — floats
     { label: '🧊', density: 0.92, radius: 26 }, // ice — barely floats
-    { label: '🐟', density: 1.05, radius: 26 }, // fish — barely sinks
     { label: '🪨', density: 2.60, radius: 24 }, // rock — sinks
-    { label: '💎', density: 3.50, radius: 22 }, // gem — sinks
+    { label: '🪙', density: 3.50, radius: 22 }, // coin — sinks
     { label: '⚓', density: 7.50, radius: 26 }, // anchor — sinks fast
     { label: '🔩', density: 7.80, radius: 20 }, // bolt — sinks fast
 ];
diff --git a/src/input/TiltInput.ts b/src/input/TiltInput.ts
index 6d50c26..fcd509a 100644
--- a/src/input/TiltInput.ts
+++ b/src/input/TiltInput.ts
@@ -58,16 +58,19 @@ export class TiltInput {
     }
 
     private readonly handleOrientation = (e: DeviceOrientationEvent): void => {
-        if (e.gamma === null || e.beta === null) return;
-        // gamma: left/right tilt (-90…+90), beta: front/back (-180…+180)
-        // We use gamma as lateral tilt (maps to x gravity) and clamp to ±45°
-        const gammaRad = (Math.min(45, Math.max(-45, e.gamma)) * Math.PI) / 180;
-        const betaRad = (Math.min(90, Math.max(-90, e.beta)) * Math.PI) / 180;
+        if (e.gamma === null) return;
+        // gamma: left/right tilt (-90…+90) when phone is held upright in portrait.
+        // We derive BOTH gravity components from gamma so that:
+        //   gamma=0  → gravity=(0, 980)  — phone upright, water flat
+        //   gamma=30 → gravity=(490, 849) — tilted right, water shifts right
+        // Using beta for gravity.y was wrong: when upright beta≈90°, cos(90°)≈0
+        // which zeroed out vertical gravity entirely.
+        const gammaDeg = Math.min(88, Math.max(-88, e.gamma));
+        const gammaRad = (gammaDeg * Math.PI) / 180;
 
-        // Project onto x/y gravity components
         this._gravity = new Vector2(
             Math.sin(gammaRad) * 980,
-            Math.cos(betaRad) * 980,
+            Math.cos(gammaRad) * 980,
         );
     };
 
diff --git a/src/input/TouchInput.ts b/src/input/TouchInput.ts
index 46c9288..8da71d8 100644
--- a/src/input/TouchInput.ts
+++ b/src/input/TouchInput.ts
@@ -25,8 +25,10 @@ export class TouchInput {
 
     private spawnAt(clientX: number, clientY: number): void {
         const rect = this.canvas.getBoundingClientRect();
-        const canvasX = (clientX - rect.left) * (this.canvas.width / rect.width);
-        const canvasY = (clientY - rect.top) * (this.canvas.height / rect.height);
+        // World coordinates are CSS pixels (the renderer scales by DPR via ctx.scale),
+        // so we just subtract the canvas offset — no DPR multiplication needed.
+        const canvasX = clientX - rect.left;
+        const canvasY = clientY - rect.top;
 
         // Clamp to glass interior
         const gx = this.world.glassX;
@@ -36,10 +38,9 @@ export class TouchInput {
         const icon = ICONS[Math.floor(Math.random() * ICONS.length)]!;
         const r = icon.radius;
 
+        // Clamp spawn position to glass interior
         const spawnX = Math.max(gx + r, Math.min(gx + gw - r, canvasX));
-        // Spawn near top of glass (just below the surface or at touch Y if that's lower)
-        const topOfGlass = gy + r;
-        const spawnY = Math.min(canvasY, topOfGlass + 20);
+        const spawnY = Math.max(gy + r, Math.min(gy + this.world.glassH - r, canvasY));
 
         const body = new RigidBody({
             position: new Vector2(spawnX, spawnY),
@@ -48,9 +49,8 @@ export class TouchInput {
             label: icon.label,
         });
 
-        // Give a small random horizontal nudge for fun
-        body.velocity.x = (Math.random() - 0.5) * 120;
-        body.velocity.y = 60;
+        body.velocity.x = (Math.random() - 0.5) * 80;
+        body.velocity.y = 0;
         body.angularVelocity = (Math.random() - 0.5) * 4;
 
         this.world.addBody(body);
diff --git a/src/main.ts b/src/main.ts
index 2f834e3..c289bbb 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,7 +1,68 @@
 import { App } from './App.js';
+import QRCode from 'qrcode';
 
 const canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
+const startScreen = document.getElementById('start-screen') as HTMLDivElement;
+const startBtn = document.getElementById('start-btn') as HTMLButtonElement;
 if (!canvas) throw new Error('No canvas element found');
 
+const isIOS = /iP(hone|od|ad)/.test(navigator.userAgent);
+const supportsFullscreen = document.fullscreenEnabled
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    || (document as any).webkitFullscreenEnabled;
+
+let orientationLocked = false;
+
+async function tryLockOrientation(): Promise<void> {
+    try {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        await (screen.orientation as any).lock('portrait');
+        orientationLocked = true;
+    } catch { /* not supported — CSS landscape overlay handles it */ }
+}
+
+async function enterFullscreen(): Promise<void> {
+    if (!isIOS && supportsFullscreen) {
+        try {
+            await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
+        } catch { /* ignore */ }
+    }
+    await tryLockOrientation();
+}
+
+// Re-apply lock if fullscreen change drops it
+document.addEventListener('fullscreenchange', () => {
+    if (document.fullscreenElement && !orientationLocked) {
+        void tryLockOrientation();
+    }
+});
+
+// Re-apply lock if OS overrides it mid-session
+screen.orientation.addEventListener('change', () => {
+    if (orientationLocked) void tryLockOrientation();
+});
+
+// ── Start button ──────────────────────────────────────────────────────────
+
 const app = new App(canvas);
-app.start();
+const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement;
+
+resetBtn.addEventListener('click', () => app.reset());
+
+// ── QR body — generate once and spawn into the glass ──────────────────────
+void (async () => {
+    const qrCanvas = document.createElement('canvas');
+    await QRCode.toCanvas(qrCanvas, window.location.href, {
+        width: 200,
+        margin: 1,
+        color: { dark: '#000000', light: '#ffffff' },
+    });
+    app.spawnQRBody(qrCanvas);
+})();
+
+startBtn.addEventListener('click', async () => {
+    await enterFullscreen();
+    startScreen.classList.add('hidden');
+    app.start();
+}, { once: true });
+
diff --git a/src/physics/RigidBody.ts b/src/physics/RigidBody.ts
index bd12b4b..3f65edd 100644
--- a/src/physics/RigidBody.ts
+++ b/src/physics/RigidBody.ts
@@ -7,6 +7,10 @@ export interface RigidBodyOptions {
     density: number;
     /** Display label — emoji or short string */
     label: string;
+    /** Optional pre-rendered texture drawn instead of label */
+    texture?: HTMLCanvasElement;
+    /** Permanent bodies are never evicted by the 12-body cap */
+    permanent?: boolean;
 }
 
 export class RigidBody {
@@ -15,6 +19,8 @@ export class RigidBody {
     readonly radius: number;
     readonly density: number;
     readonly label: string;
+    readonly texture: HTMLCanvasElement | undefined;
+    readonly permanent: boolean;
     /** Rotation angle in radians, for visual wobble */
     angle: number = 0;
     angularVelocity: number = 0;
@@ -29,6 +35,8 @@ export class RigidBody {
         this.radius = opts.radius;
         this.density = opts.density;
         this.label = opts.label;
+        this.texture = opts.texture;
+        this.permanent = opts.permanent ?? false;
         this.id = RigidBody._nextId++;
     }
 
diff --git a/src/physics/Water.ts b/src/physics/Water.ts
index e78bc26..2d43269 100644
--- a/src/physics/Water.ts
+++ b/src/physics/Water.ts
@@ -45,27 +45,33 @@ export class Water {
      */
     step(dt: number, lateralG: number, verticalG: number): void {
         const n = this.columns;
-        const k = 280;         // spring stiffness between columns
-        const damping = 0.985; // velocity damping per step
-        const lateralK = 0.08; // how strongly lateral gravity pushes water
+        const k = 280;          // spring stiffness — drives wave propagation
+        const kTilt = 45;       // restoring strength toward equilibrium tilt (must be strong enough to actually reach it)
+        const damping = 0.978;  // velocity damping per step
 
-        // Compute target water level shift due to lateral gravity
-        // A positive lateralG means gravity pulls right → right columns get more water
-        const shift = lateralG * lateralK * dt;
+        // Equilibrium surface: when tilted, the surface should slope at angle
+        // slope = lateralG / verticalG (hydrostatic equilibrium).
+        // When the phone approaches landscape (verticalG → 0), slope → ∞ capped below.
+        const hAvg = this.averageHeight;
+        const slope = verticalG > 5 ? lateralG / verticalG : (lateralG > 0 ? 10 : -10);
+        // Cap slope — at slope=8 with a typical glassW the high side already overflows
+        const slopeCapped = Math.max(-8, Math.min(8, slope));
 
-        // Apply spring forces + damping
         for (let i = 0; i < n; i++) {
             const left = i > 0 ? this.heights[i - 1]! : this.heights[i]!;
             const right = i < n - 1 ? this.heights[i + 1]! : this.heights[i]!;
             const cur = this.heights[i]!;
-            this.velocities[i] = (this.velocities[i]! + k * (left + right - 2 * cur) * dt) * damping;
-        }
 
-        // Apply lateral gravity as a linear tilt on velocities
-        for (let i = 0; i < n; i++) {
-            // Columns on the right get a nudge upward, left get downward — net mass shift
-            const normalised = (i / (n - 1)) - 0.5; // -0.5 … +0.5
-            this.velocities[i] = (this.velocities[i]! + normalised * shift * verticalG);
+            // Wave spring (Laplacian — drives oscillation, tends toward flat)
+            const springAcc = k * (left + right - 2 * cur) * dt;
+
+            // Tilt restoring force: drives toward the equilibrium sloped surface.
+            // hEq for column i: average height ± slope * distance from centre.
+            const colCentre = (i / (n - 1)) - 0.5; // -0.5 … +0.5
+            const hEq = hAvg + colCentre * this.glassW * slopeCapped;
+            const tiltAcc = kTilt * (hEq - cur) * dt;
+
+            this.velocities[i] = (this.velocities[i]! + springAcc + tiltAcc) * damping;
         }
 
         // Integrate heights
@@ -109,6 +115,33 @@ export class Water {
         return sum / this.columns;
     }
 
+    /**
+     * Apply a velocity disturbance to columns near localX (px from glass left).
+     * Positive velocityY = pushing down (body entering water), negative = body leaving.
+     * influence controls the horizontal spread in columns.
+     */
+    disturb(localX: number, radius: number, velocityY: number): void {
+        const colW = this.columnWidth;
+        const centerCol = localX / colW;
+        const spreadCols = (radius / colW) * 1.5;
+        const strength = velocityY * 0.018; // tuned so fast splashes are dramatic but not wild
+
+        for (let i = 0; i < this.columns; i++) {
+            const dist = Math.abs(i - centerCol);
+            if (dist > spreadCols * 2) continue;
+            const falloff = Math.exp(-(dist * dist) / (spreadCols * spreadCols));
+            this.velocities[i] = (this.velocities[i]! - strength * falloff);
+        }
+    }
+
+    /** Reset to a flat surface at the given fill fraction (default 0.55). */
+    resetFill(fillFrac: number = 0.55): void {
+        this.heights.fill(this.glassH * fillFrac);
+        const vel = this.velocities;
+        vel.fill(0);
+        this.volume = this.computeVolume();
+    }
+
     /** Resize glass dimensions (e.g. on canvas resize), scaling heights proportionally. */
     resize(newGlassW: number, newGlassH: number): void {
         const scaleH = newGlassH / this.glassH;
diff --git a/src/physics/World.ts b/src/physics/World.ts
index b5a08fa..47ac49b 100644
--- a/src/physics/World.ts
+++ b/src/physics/World.ts
@@ -28,7 +28,9 @@ export class World {
     addBody(body: RigidBody): void {
         this.bodies.push(body);
         if (this.bodies.length > MAX_BODIES) {
-            this.bodies.shift(); // remove oldest
+            // Evict the oldest non-permanent body
+            const idx = this.bodies.findIndex(b => !b.permanent);
+            if (idx !== -1) this.bodies.splice(idx, 1);
         }
     }
 
@@ -62,6 +64,9 @@ export class World {
             // Integrate position
             body.position.addMut(body.velocity.scale(dt));
 
+            // --- Disturb water surface based on body movement ---
+            this.disturbWater(body);
+
             // Angular
             body.angularVelocity *= ANGULAR_DAMPING;
             body.angle += body.angularVelocity * dt;
@@ -76,6 +81,11 @@ export class World {
                 this.resolveBodyCollision(this.bodies[i]!, this.bodies[j]!);
             }
         }
+
+        // Remove non-permanent bodies that have fully escaped above the glass rim
+        this.bodies = this.bodies.filter(b =>
+            b.permanent || (b.position.y + b.radius) > this.glassY
+        );
     }
 
     private submergedFraction(body: RigidBody): number {
@@ -103,23 +113,25 @@ export class World {
 
     private constrainToGlass(body: RigidBody): void {
         const r = body.radius;
-        // Convert to local glass coords
         let lx = body.position.x - this.glassX;
         let ly = body.position.y - this.glassY;
 
-        // Left wall
-        if (lx - r < 0) {
-            lx = r;
-            body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION;
-            body.velocity.y *= FRICTION;
-            body.angularVelocity += body.velocity.y * 0.01;
-        }
-        // Right wall
-        if (lx + r > this.glassW) {
-            lx = this.glassW - r;
-            body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION;
-            body.velocity.y *= FRICTION;
-            body.angularVelocity -= body.velocity.y * 0.01;
+        // Only enforce walls below the rim (ly >= 0); above the rim bodies can fly free
+        if (ly + r > 0) {
+            // Left wall
+            if (lx - r < 0) {
+                lx = r;
+                body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION;
+                body.velocity.y *= FRICTION;
+                body.angularVelocity += body.velocity.y * 0.01;
+            }
+            // Right wall
+            if (lx + r > this.glassW) {
+                lx = this.glassW - r;
+                body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION;
+                body.velocity.y *= FRICTION;
+                body.angularVelocity -= body.velocity.y * 0.01;
+            }
         }
         // Floor
         if (ly + r > this.glassH) {
@@ -127,16 +139,28 @@ export class World {
             body.velocity.y = -Math.abs(body.velocity.y) * RESTITUTION;
             body.velocity.x *= FRICTION;
         }
-        // Ceiling (shouldn't normally happen, but keep objects inside)
-        if (ly - r < 0) {
-            ly = r;
-            body.velocity.y = Math.abs(body.velocity.y) * RESTITUTION;
-        }
 
         body.position.x = lx + this.glassX;
         body.position.y = ly + this.glassY;
     }
 
+    private disturbWater(body: RigidBody): void {
+        const localX = body.position.x - this.glassX;
+        const localY = body.position.y - this.glassY;
+        const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX)));
+
+        // Only disturb when the body center is within one radius of the surface
+        const distToSurface = Math.abs(localY - surfaceY);
+        if (distToSurface > body.radius * 1.5) return;
+
+        // Scale impulse by how fast the body is crossing the surface (Y component)
+        // and how much of it is in the surface zone
+        const velY = body.velocity.y;
+        if (Math.abs(velY) < 10) return; // ignore very slow drift
+
+        this.water.disturb(localX, body.radius, velY);
+    }
+
     private resolveBodyCollision(a: RigidBody, b: RigidBody): void {
         const delta = b.position.sub(a.position);
         const dist = delta.magnitude();
diff --git a/src/rendering/IconRenderer.ts b/src/rendering/IconRenderer.ts
index ba8d967..d9a4888 100644
--- a/src/rendering/IconRenderer.ts
+++ b/src/rendering/IconRenderer.ts
@@ -20,7 +20,7 @@ export class IconRenderer {
             const bx = body.position.x;
             const by = body.position.y;
 
-            // Determine if center is below water surface
+            // Determine if center is below water surface (for refraction offset)
             const localX = bx - this.glassX;
             const surfaceY = this.glassY + this.water.getSurfaceY(Math.max(0, localX));
             const submerged = by > surfaceY;
@@ -29,23 +29,10 @@ export class IconRenderer {
             ctx.translate(bx + (submerged ? getRefractionDx(bx) : 0), by);
             ctx.rotate(body.angle);
 
-            // Draw shadow / glow ring
-            ctx.beginPath();
-            ctx.arc(0, 0, r, 0, Math.PI * 2);
-            if (submerged) {
-                ctx.fillStyle = 'rgba(0, 80, 120, 0.25)';
-            } else {
-                ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
-            }
-            ctx.fill();
-
             // Draw emoji
             ctx.font = `${size}px serif`;
             ctx.textAlign = 'center';
             ctx.textBaseline = 'middle';
-            if (submerged) {
-                ctx.globalAlpha = 0.82;
-            }
             ctx.fillText(body.label, 0, 0);
 
             ctx.restore();
diff --git a/src/rendering/Renderer.ts b/src/rendering/Renderer.ts
index dcffbcc..82aba31 100644
--- a/src/rendering/Renderer.ts
+++ b/src/rendering/Renderer.ts
@@ -78,6 +78,12 @@ export class Renderer {
         this.waterRend.update(dt);
     }
 
+    /** Register a pre-rendered QR canvas to be drawn on top of water. */
+    setQRTexture(tex: HTMLCanvasElement): void {
+        this._qrTexture = tex;
+    }
+    private _qrTexture: HTMLCanvasElement | undefined;
+
     draw(): void {
         const ctx = this.ctx;
         const W = this.canvas.width / (window.devicePixelRatio || 1);
@@ -90,12 +96,35 @@ export class Renderer {
         ctx.fillStyle = '#0f172a';
         ctx.fillRect(0, 0, W, H);
 
-        // Clip to glass interior → draw water
+        // Clip to glass interior
         this.glass.beginClip();
-        this.waterRend.draw();
 
-        // Draw icons (refraction handled inside)
+        // Icons first (behind water)
         this.iconRend.draw(this.world.bodies, (x) => this.waterRend.getRefractionDx(x));
+
+        // Water on top of icons
+        this.waterRend.draw();
+
+        // Textured bodies (QR card) drawn on top of water, inside glass clip
+        for (const body of this.world.bodies) {
+            if (!body.texture) continue;
+            const ctx = this.ctx;
+            const s = body.radius * 2;
+            ctx.save();
+            ctx.translate(body.position.x, body.position.y);
+            ctx.rotate(body.angle);
+            // White card background
+            ctx.fillStyle = '#ffffff';
+            const r = 4;
+            ctx.beginPath();
+            ctx.roundRect(-s / 2, -s / 2, s, s, r);
+            ctx.fill();
+            // QR image centred with small padding
+            const pad = s * 0.06;
+            ctx.drawImage(body.texture, -s / 2 + pad, -s / 2 + pad, s - pad * 2, s - pad * 2);
+            ctx.restore();
+        }
+
         this.glass.endClip();
 
         // Glass outline on top
diff --git a/src/rendering/WaterRenderer.ts b/src/rendering/WaterRenderer.ts
index 8862cd7..e6845f1 100644
--- a/src/rendering/WaterRenderer.ts
+++ b/src/rendering/WaterRenderer.ts
@@ -49,6 +49,7 @@ export class WaterRenderer {
         }
 
         // Close to bottom-right then bottom-left
+        ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1));
         ctx.lineTo(gx + w.glassW, gy + gh);
         ctx.closePath();
 
@@ -84,6 +85,7 @@ export class WaterRenderer {
                 ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy);
             }
         }
+        ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1));
         ctx.lineTo(gx + w.glassW, gy + gh);
         ctx.closePath();
         ctx.clip();
@@ -107,6 +109,7 @@ export class WaterRenderer {
                 ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy);
             }
         }
+        ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1));
         ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)';
         ctx.lineWidth = 2;
         ctx.stroke();
diff --git a/vite.config.ts b/vite.config.ts
index 0d60b09..5a966dc 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,6 +1,8 @@
 import { defineConfig } from 'vite';
+import { qrcode } from 'vite-plugin-qrcode';
 
 export default defineConfig({
+    plugins: [qrcode()],
     publicDir: 'static',
     build: {
         outDir: 'dist',