Zoom Watcher

Aggressive multi-layer diagnostic for the PWA “game gets stuck zoomed in” bug. Monitors browser zoom level across every layer the bug could be hiding behind, surfaces drift live on an on-screen overlay, and ships one Sentry event per run when drift is detected — so we can diagnose without ever asking the player to “check DevTools.”

Source: src/starship-survivors/engine/diagnostics/zoom-watcher.ts.

Why it exists

STARSHIPSURVIVORS-bug history (Sentry) accumulated reports of players whose game appeared zoomed in and would not recover. The bug spans several independent zoom surfaces — visual viewport pinch, device pixel ratio shift, canvas CSS size, engine camera zoom, body scroll offset — and any of them can latch into a non-100% state. The watcher detects player setups (or in-session events) that may cause visual / hitbox issues and reports a full state snapshot to Sentry so we can pinpoint which layer is broken.

What it watches

Every sample captures all seven layers in one snapshot:

  1. visualViewportscale, width, height, offsetLeft, offsetTop (pinch-zoom, meta tag interpretation).
  2. windowinnerWidth, innerHeight, devicePixelRatio.
  3. document.documentElementclientWidth, clientHeight (CSS pixels).
  4. #rootgetBoundingClientRect() width/height and computed transform.
  5. Canvas — buffer width/height, inline style.width/style.height, computed transform.
  6. Engine cameracamera.zoom, camera.targetZoom, plus game.phase.
  7. Screen / OSscreen.orientation.type, screen.width, screen.height. Body scroll (scrollX, scrollY) is also captured — non-zero means the viewport got pushed.

Sampling cadence

  • Baseline: captured 500 ms after startZoomWatcher() is called, after initial layout settles.
  • Polling: setInterval 1 Hz.
  • Event-triggered: resize, orientationchange, focus, blur, visibilitychange, gesturestart, gesturechange, gestureend, plus visualViewport.resize and visualViewport.scroll. Each event also drops a Sentry breadcrumb (category: zoom-watcher) carrying vvScale, cameraZoom, innerWidth, canvasCssW so a later Sentry report can reconstruct what happened just before the drift.

Drift detection

_detectDrift() compares current snapshot against baseline. Tolerances suppress URL-bar / 1 px / 1 % noise; anything beyond that is reported as a labeled drift string:

ConditionThresholdDrift label
visualViewport.scale ≠ 1abs deviation > 0.01visualViewport.scale=<n>
Body scrolledany non-zero scrollX/scrollYbody scrolled (x,y)
#root transform changednot none and ≠ baseline#root transform=<v>
Canvas transform changednot none and ≠ baselinecanvas transform=<v>
Canvas buffer:CSS ratio shiftabs delta > 0.1 from baseline ratiocanvas buf:css ratio <a>→<b>
DPR shift mid-sessionabs delta > 0.05dpr <a>→<b>
Canvas CSS wider than viewportcanvasCssW > innerWidth + 2canvas css W ... > innerWidth ...
Engine camera zoomed in hardcamera.zoom > 1.5camera.zoom=<n>
Document overflowdocElementWidth > innerWidth + 4doc.clientWidth ... > inner ...

Multiple drifts are joined with |.

Sentry reporting

Rate-limited to one Sentry event per run (_warnedOnce). The first instance carries enough state to diagnose; further events would be noise. The report attaches:

  • tag: bug = pwa_zoom_drift
  • level: warning
  • extra.drift — the joined drift label
  • extra.baseline — full baseline snapshot
  • extra.current — full current snapshot
  • extra.userAgentnavigator.userAgent
  • extra.isPWA — result of matchMedia('(display-mode: standalone)')

Message: Zoom drift: <drift>.

dumpZoomStateToSentry(reason) fires an on-demand report (tag pwa_zoom_manual_dump) without requiring drift — used by the “RECOVER FROM STUCK” UI button so players who feel the bug but pass the heuristics still get a snapshot uploaded.

Live overlay

A fixed-position green-on-black monospace panel pinned to the lower-left corner (z-index 99999, pointer-events: none). Hidden by default; toggleZoomOverlay() flips it. Auto-shows the moment drift is detected so a screenshot captures the live values without the player needing to toggle anything.

Header turns red with a ⚠ DRIFT banner when drift is active; otherwise ✓ stable in green. Body shows all seven layers in five compact lines.

Recovery — resetAllZoomLayers()

The “nuclear option” called by the RECOVER FROM STUCK button. Touches every layer so the user can be unstuck regardless of which one is broken:

  1. Viewport meta toggle — switch to maximum-scale=10, user-scalable=yes for one requestAnimationFrame, then restore the locked initial-scale=1, maximum-scale=1, user-scalable=no config. The transition is what forces the UA to re-evaluate scale; a plain wipe-and-restore is a known no-op on stuck Android visual viewports (v5.113.3 post-mortem in Supabase).
  2. window.scrollTo(0, 0) in case the body got pushed.
  3. Clear inline transforms on #root and the canvas.
  4. Reset engine cameracamera.zoom = 1, camera.targetZoom = 1.
  5. Dispatch synthetic resize event to force canvas re-layout.
  6. Refresh baseline and clear _warnedOnce so the next drift report compares to the recovered state.

Returns a summary string (zoom-reset: vv=<a>→<b> cam=<a>→<b> canvas=<a>→<b>) so the UI can show what actually changed.

Public API

ExportPurpose
startZoomWatcher()Boot baseline + polling + event listeners. Idempotent.
toggleZoomOverlay()Show / hide the live overlay. Returns new visibility.
dumpZoomStateToSentry(reason)On-demand Sentry dump without requiring drift.
resetAllZoomLayers()Nuclear reset across all seven layers. Returns summary.