[object Object]

← back to Wild Orbs Opus

Verify the touch + cross-browser claims; fix the pointer-capture bug they exposed

e78346c56836a3c640246392b33e6d91da173e92 · 2026-09-08 17:07:27 -0700 · Steve Abrams

The README claimed touch and cross-engine support that was never actually
tested. Testing it found a real bug: cv.setPointerCapture() ran first in the
pointerdown handler and throws NotFoundError when the pointer is already gone
(fast taps, browser gesture cancellation). Thrown from the top of the handler it
aborted everything after it, so fire and PULSE inputs were silently dropped on
touch devices. Moved it last and wrapped it in try/catch.

Also: the pulse HUD hint read 'PULSE (SHIFT)' on phones, which have no Shift key.

test/browsers.mjs: boots and plays the game in Chromium, Firefox and WebKit,
detonates all 13 orb behaviours in each, verifies the WebAudio graph and
localStorage path, then runs a touch pass on an emulated iPhone (stick steers +
thrusts, right side fires, on-screen PULSE fires, thumb UI stays hidden for mouse
users). All green in all three engines.

Fixed a race in the wave-clear assertion: it cleared orbs while a spawnGuard
countdown was still in flight, which suppresses the clear detector, so the test
intermittently waited forever. 57/57 across four consecutive runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP13P5ZKs7oWJQ3pjbvpnG

Files touched

Diff

commit e78346c56836a3c640246392b33e6d91da173e92
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 8 17:07:27 2026 -0700

    Verify the touch + cross-browser claims; fix the pointer-capture bug they exposed
    
    The README claimed touch and cross-engine support that was never actually
    tested. Testing it found a real bug: cv.setPointerCapture() ran first in the
    pointerdown handler and throws NotFoundError when the pointer is already gone
    (fast taps, browser gesture cancellation). Thrown from the top of the handler it
    aborted everything after it, so fire and PULSE inputs were silently dropped on
    touch devices. Moved it last and wrapped it in try/catch.
    
    Also: the pulse HUD hint read 'PULSE (SHIFT)' on phones, which have no Shift key.
    
    test/browsers.mjs: boots and plays the game in Chromium, Firefox and WebKit,
    detonates all 13 orb behaviours in each, verifies the WebAudio graph and
    localStorage path, then runs a touch pass on an emulated iPhone (stick steers +
    thrusts, right side fires, on-screen PULSE fires, thumb UI stays hidden for mouse
    users). All green in all three engines.
    
    Fixed a race in the wave-clear assertion: it cleared orbs while a spawnGuard
    countdown was still in flight, which suppresses the clear detector, so the test
    intermittently waited forever. 57/57 across four consecutive runs.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01XP13P5ZKs7oWJQ3pjbvpnG
---
 README.md         |   7 +++
 index.html        |   7 ++-
 package.json      |   1 +
 test/browsers.mjs | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/smoke.mjs    |   9 ++-
 5 files changed, 199 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 8cc6d11..41668b8 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,7 @@ bestiary, touch and gamepad support.
 
 ```bash
 npm test              # headless: 57 assertions
+npm run test:browsers # Chromium + Firefox + WebKit + emulated-phone touch
 npm run test:headed   # watch it run, writes screenshots to test/shots/
 npm run profile       # per-phase render timings + sustained fps
 ```
@@ -100,5 +101,11 @@ console error or uncaught exception. The framerate assertion measures the enviro
 own rAF ceiling first, because headed Playwright is vsync-capped at 30fps and headless
 uses a software rasteriser; a hardcoded fps number would be meaningless in both.
 
+`test/browsers.mjs` boots the real game in all three engines — Blink, Gecko and
+WebKit — plays it, detonates all 13 behaviours in each, checks the WebAudio graph
+and `localStorage` path, then runs a touch pass on an emulated iPhone: virtual
+stick steers and thrusts, right side fires, the on-screen PULSE button works, and
+the thumb controls stay hidden for mouse users. All green in every engine.
+
 Playwright is resolved from the global install via symlinks in `node_modules/`
 (gitignored); `npm i -D playwright` also works.
diff --git a/index.html b/index.html
index ac55310..40bda2a 100644
--- a/index.html
+++ b/index.html
@@ -789,7 +789,6 @@ cv.addEventListener('pointerdown', e=>{
   touch.on = true;
   const x = e.clientX, y = e.clientY;
   if(G.state !== 'play') return;
-  cv.setPointerCapture && cv.setPointerCapture(e.pointerId);
 
   const b = hitButton(x,y);
   if(b){
@@ -802,6 +801,10 @@ cv.addEventListener('pointerdown', e=>{
   } else if(touch.fireId===-1){
     touch.fireId = e.pointerId;
   }
+  // Capture last and defensively: it throws NotFoundError when the pointer is
+  // already gone (fast taps, browser gesture cancellation). Thrown from the top
+  // of this handler it silently swallowed the input that followed it.
+  try { cv.setPointerCapture && cv.setPointerCapture(e.pointerId); } catch(err){}
 }, {passive:true});
 cv.addEventListener('pointermove', e=>{
   if(e.pointerId===touch.stickId){ touch.cx=e.clientX; touch.cy=e.clientY; }
@@ -2576,7 +2579,7 @@ function drawHUD(){
     ctx.fill();
   }
   ctx.font='700 9px "Trebuchet MS", sans-serif';
-  ctx.fillStyle='#8b6f9e'; ctx.fillText('PULSE (SHIFT)', 22, 118);
+  ctx.fillStyle='#8b6f9e'; ctx.fillText(touch.on ? 'PULSE' : 'PULSE (SHIFT)', 22, 118);
 
   // active power meters
   let my = 138;
diff --git a/package.json b/package.json
index 2bb905a..e65981b 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
   "scripts": {
     "start": "open index.html",
     "test": "node test/smoke.mjs",
+    "test:browsers": "node test/browsers.mjs",
     "test:headed": "node test/smoke.mjs --headed --shots",
     "profile": "node test/profile.mjs"
   },
diff --git a/test/browsers.mjs b/test/browsers.mjs
new file mode 100644
index 0000000..ce73a3d
--- /dev/null
+++ b/test/browsers.mjs
@@ -0,0 +1,179 @@
+/**
+ * Cross-engine + touch verification.
+ *
+ * The README claims this runs everywhere and supports touch; this proves it.
+ * Boots the real game in Chromium (Blink), Firefox (Gecko) and WebKit (Safari's
+ * engine), plays it, exercises the touch path on an emulated phone, and fails on
+ * any console error or uncaught exception in any engine.
+ *
+ * Usage: node test/browsers.mjs
+ */
+import { chromium, firefox, webkit, devices } from 'playwright';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import path from 'node:path';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const GAME = pathToFileURL(path.join(__dirname, '..', 'index.html')).href;
+
+let failed = 0;
+const check = (name, ok, detail) => {
+  if (!ok) failed++;
+  console.log(`${ok ? '  ok  ' : ' FAIL '} ${name}${detail ? '  — ' + detail : ''}`);
+};
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function waitFor(page, fn, timeout = 10000) {
+  const t0 = Date.now();
+  for (;;) {
+    try { if (await page.evaluate(fn)) return true; } catch (e) { /* mid-navigation */ }
+    if (Date.now() - t0 > timeout) return false;
+    await sleep(120);
+  }
+}
+
+/* ---------------- desktop pass, one per engine ---------------- */
+async function desktopPass(name, launcher) {
+  let browser;
+  try { browser = await launcher.launch(); }
+  catch (e) { check(`${name}: launch`, false, e.message.split('\n')[0]); return; }
+
+  const errors = [];
+  const page = await browser.newPage({ viewport: { width: 1100, height: 720 } });
+  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', e => errors.push(e.message));
+
+  await page.goto(GAME);
+  await sleep(700);
+
+  check(`${name}: boots + attract mode runs`,
+    await waitFor(page, () => typeof G !== 'undefined' && G.time > 20 && orbs.length > 0));
+
+  await page.keyboard.press('Enter');
+  check(`${name}: game starts`, await waitFor(page, () => G.state === 'play' && orbs.length > 0));
+
+  // fly, turn and shoot
+  await page.keyboard.down('ArrowUp');
+  await page.keyboard.down('ArrowLeft');
+  await page.keyboard.down('Space');
+  await sleep(1500);
+  await page.keyboard.up('ArrowUp'); await page.keyboard.up('ArrowLeft'); await page.keyboard.up('Space');
+  check(`${name}: ship flies and fires`, await page.evaluate(() => G.shots > 3 && G.frames > 30));
+
+  // every wild behaviour in this engine (canvas APIs differ: ellipse, setLineDash,
+  // createRadialGradient with r0>0, composite ops)
+  const before = errors.length;
+  await page.evaluate(() => {
+    ship.inv = 1e9;
+    for (const k of ORB_KEYS) for (const tier of [4, 3, 1]) {
+      const o = makeOrb(innerWidth * 0.4, innerHeight * 0.5, tier, k);
+      orbs.push(o); destroyOrb(o);
+    }
+  });
+  await sleep(2200);
+  check(`${name}: all 13 orb behaviours render`, errors.length === before, errors[before]);
+
+  // audio graph actually built (WebKit is fussy about AudioContext)
+  const audio = await page.evaluate(() => ({ ready: Snd.ready, ctxState: Snd.ctx ? Snd.ctx.state : 'none' }));
+  check(`${name}: WebAudio graph built`, audio.ready === true, `state=${audio.ctxState}`);
+
+  // localStorage persistence path
+  const stored = await page.evaluate(() => { Store.set('probe', 42); return Store.get('probe', 0); });
+  check(`${name}: persistence works`, stored === 42);
+
+  check(`${name}: zero console errors`, errors.length === 0, errors.slice(0, 2).join(' | '));
+  await browser.close();
+}
+
+/* ---------------- touch pass on an emulated phone ---------------- */
+async function touchPass() {
+  const browser = await chromium.launch();
+  const ctx = await browser.newContext({ ...devices['iPhone 13'] });
+  const errors = [];
+  const page = await ctx.newPage();
+  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', e => errors.push(e.message));
+
+  await page.goto(GAME);
+  await sleep(700);
+
+  const vp = page.viewportSize();
+  check('touch: portrait phone layout boots', await waitFor(page, () => G.time > 20));
+
+  // start via the on-screen button (no keyboard on a phone)
+  await page.tap('#startBtn');
+  check('touch: Launch button starts the game', await waitFor(page, () => G.state === 'play'));
+
+  // left half = virtual stick. Drag from centre-left outward and hold.
+  const a0 = await page.evaluate(() => ship.a);
+  await page.touchscreen.tap(vp.width * 0.25, vp.height * 0.7);   // registers touch.on
+  await sleep(100);
+
+  // a real drag: down, move, hold, up (touchscreen.tap can't hold, so use raw CDP-free
+  // pointer events through dispatchEvent on the canvas)
+  await page.evaluate(({ w, h }) => {
+    const opts = (id, x, y) => ({ pointerId: id, pointerType: 'touch', clientX: x, clientY: y, bubbles: true });
+    cv.dispatchEvent(new PointerEvent('pointerdown', opts(1, w * 0.25, h * 0.7)));
+    cv.dispatchEvent(new PointerEvent('pointermove', opts(1, w * 0.25 + 60, h * 0.7 - 60)));
+  }, { w: vp.width, h: vp.height });
+  await sleep(900);
+
+  const steered = await page.evaluate(() => ({
+    a: ship.a, thrusting: ship.thrust > 0.2, stick: touch.stickId !== -1, on: touch.on
+  }));
+  check('touch: virtual stick registers', steered.stick && steered.on);
+  check('touch: stick steers the ship', Math.abs(steered.a - a0) > 0.05, `heading ${a0.toFixed(2)} -> ${steered.a.toFixed(2)}`);
+  check('touch: stick thrusts', steered.thrusting, `thrust=${(await page.evaluate(() => ship.thrust)).toFixed(2)}`);
+
+  // right side fires
+  const shots0 = await page.evaluate(() => G.shots);
+  await page.evaluate(({ w, h }) => {
+    const opts = (id, x, y) => ({ pointerId: id, pointerType: 'touch', clientX: x, clientY: y, bubbles: true });
+    cv.dispatchEvent(new PointerEvent('pointerdown', opts(2, w * 0.8, h * 0.45)));
+  }, { w: vp.width, h: vp.height });
+  await sleep(600);
+  check('touch: right side fires', await page.evaluate(s => G.shots > s, shots0));
+
+  // release everything
+  await page.evaluate(() => {
+    [1, 2].forEach(id => cv.dispatchEvent(new PointerEvent('pointerup', { pointerId: id, pointerType: 'touch', bubbles: true })));
+  });
+  await sleep(200);
+  check('touch: release stops input', await page.evaluate(() => touch.stickId === -1 && touch.fireId === -1));
+
+  // on-screen PULSE / WARP buttons
+  const btns = await page.evaluate(() => touchButtons().map(b => ({ id: b.id, x: b.x, y: b.y })));
+  check('touch: on-screen buttons are on-screen',
+    btns.every(b => b.x > 0 && b.x < vp.width && b.y > 0 && b.y < vp.height),
+    btns.map(b => `${b.id}@${Math.round(b.x)},${Math.round(b.y)}`).join(' '));
+
+  await page.evaluate(() => { G.pulses = 2; waves.length = 0; });
+  const pulseBtn = btns.find(b => b.id === 'pulse');
+  await page.evaluate(({ x, y }) => {
+    cv.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 5, pointerType: 'touch', clientX: x, clientY: y, bubbles: true }));
+  }, pulseBtn);
+  check('touch: PULSE button fires the bomb', await page.evaluate(() => waves.some(w => w.dmg === 3)));
+
+  // a mouse must NOT summon the thumb controls
+  const mouseCtx = await browser.newContext({ viewport: { width: 1000, height: 700 } });
+  const mp = await mouseCtx.newPage();
+  await mp.goto(GAME); await sleep(500);
+  await mp.keyboard.press('Enter'); await sleep(300);
+  await mp.mouse.click(500, 400);
+  await sleep(200);
+  check('touch UI stays hidden for mouse users', await mp.evaluate(() => touch.on === false));
+
+  check('touch: zero console errors', errors.length === 0, errors.slice(0, 2).join(' | '));
+  await browser.close();
+}
+
+/* ---------------- run ---------------- */
+console.log('cross-engine + touch verification\n');
+for (const [name, l] of [['chromium', chromium], ['firefox ', firefox], ['webkit  ', webkit]]) {
+  await desktopPass(name, l);
+  console.log('');
+}
+await touchPass();
+
+console.log('\n' + '─'.repeat(58));
+console.log(failed ? `${failed} check(s) FAILED` : 'all cross-engine + touch checks passed');
+process.exit(failed ? 1 : 0);
diff --git a/test/smoke.mjs b/test/smoke.mjs
index df0defc..e588b5f 100644
--- a/test/smoke.mjs
+++ b/test/smoke.mjs
@@ -208,12 +208,17 @@ async function waitFor(page, fn, timeout = 12000, arg) {
 
   /* ---------- 7. wave progression ---------- */
   {
-    const w0 = await page.evaluate(() => { G.wave = 4; orbs.length = 0; hunters.length = 0; return G.wave; });
+    // spawnGuard must be cleared too: with a countdown already in flight the
+    // wave-clear detector is suppressed and the next spawn refills the field
+    // without ever incrementing, so the test would wait forever.
+    const w0 = await page.evaluate(() => {
+      G.wave = 4; G.spawnGuard = 0; orbs.length = 0; hunters.length = 0; return G.wave;
+    });
     const advanced = await waitFor(page, () => G.wave > 4 && orbs.length > 0);
     const w1 = await page.evaluate(() => G.wave);
     check('wave clear advances + respawns', advanced, `wave ${w0} -> ${w1}`);
     // wave 5 is an elder wave
-    await page.evaluate(() => { G.wave = 4; orbs.length = 0; });
+    await page.evaluate(() => { G.wave = 4; G.spawnGuard = 0; orbs.length = 0; });
     check('elder orb spawns on wave 5',
       await waitFor(page, () => G.wave === 5 && orbs.some(o => o.elder)));
   }

← 81c6680 Nova shockwaves chain; lock the cascade bounds in tests  ·  back to Wild Orbs Opus  ·  Split out as the standalone Claude Opus arena entry 8a61163 →