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:
- visualViewport —
scale,width,height,offsetLeft,offsetTop(pinch-zoom, meta tag interpretation). - window —
innerWidth,innerHeight,devicePixelRatio. - document.documentElement —
clientWidth,clientHeight(CSS pixels). #root—getBoundingClientRect()width/height and computedtransform.- Canvas — buffer
width/height, inlinestyle.width/style.height, computedtransform. - Engine camera —
camera.zoom,camera.targetZoom, plusgame.phase. - Screen / OS —
screen.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:
setInterval1 Hz. - Event-triggered:
resize,orientationchange,focus,blur,visibilitychange,gesturestart,gesturechange,gestureend, plusvisualViewport.resizeandvisualViewport.scroll. Each event also drops a Sentry breadcrumb (category: zoom-watcher) carryingvvScale,cameraZoom,innerWidth,canvasCssWso 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:
| Condition | Threshold | Drift label |
|---|---|---|
visualViewport.scale ≠ 1 | abs deviation > 0.01 | visualViewport.scale=<n> |
| Body scrolled | any non-zero scrollX/scrollY | body scrolled (x,y) |
#root transform changed | not none and ≠ baseline | #root transform=<v> |
| Canvas transform changed | not none and ≠ baseline | canvas transform=<v> |
| Canvas buffer:CSS ratio shift | abs delta > 0.1 from baseline ratio | canvas buf:css ratio <a>→<b> |
| DPR shift mid-session | abs delta > 0.05 | dpr <a>→<b> |
| Canvas CSS wider than viewport | canvasCssW > innerWidth + 2 | canvas css W ... > innerWidth ... |
| Engine camera zoomed in hard | camera.zoom > 1.5 | camera.zoom=<n> |
| Document overflow | docElementWidth > innerWidth + 4 | doc.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_driftlevel: warningextra.drift— the joined drift labelextra.baseline— full baseline snapshotextra.current— full current snapshotextra.userAgent—navigator.userAgentextra.isPWA— result ofmatchMedia('(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:
- Viewport meta toggle — switch to
maximum-scale=10, user-scalable=yesfor onerequestAnimationFrame, then restore the lockedinitial-scale=1, maximum-scale=1, user-scalable=noconfig. 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). window.scrollTo(0, 0)in case the body got pushed.- Clear inline transforms on
#rootand the canvas. - Reset engine camera —
camera.zoom = 1,camera.targetZoom = 1. - Dispatch synthetic
resizeevent to force canvas re-layout. - Refresh baseline and clear
_warnedOnceso 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
| Export | Purpose |
|---|---|
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. |
Related
- Sentry pipeline — how diagnostic events are routed.
- Telemetry events — the broader event taxonomy.
- Frame pacing — interacts with canvas / DPR settings.