Canvas Gradient Guards

installCanvasGradientGuards() is a pre-mount safety bootstrap that monkey-patches CanvasRenderingContext2D.prototype.createRadialGradient and createLinearGradient (plus the OffscreenCanvasRenderingContext2D variants when available) so non-finite numeric inputs no longer crash the renderer. Source: src/starship-survivors/engine/diagnostics/canvas-guards.ts. Boot site: src/main.tsx.

Why it exists

Sentry issue STARSHIPSURVIVORS-Y (v5.163.2, 2026-05-03 09:56 UTC) caught TypeError: The provided double value is non-finite thrown from ctx.createRadialGradient(...) mid-frame. The bridge frame-error catch block kept rescheduling requestAnimationFrame, so the game appeared hard-frozen on the last good frame while audio (separate path) kept playing — exactly the user-reported symptom: blue-screen overlay, no transition, music keeps playing. After ~37s the user tapped RECOVER FROM STUCK, which tripped the known Rapier WASM “recursive use of an object” cleanup crash.

The codebase has 50+ direct calls to createRadialGradient / createLinearGradient across bridge.ts, hud.ts, draw.ts, and vfx/*. Most are unguarded against NaN / Infinity. The Canvas API is an external boundary — by project rules (“crash on bad data” applies inside the app; defensive coding belongs at boundaries) this is the right place to harden.

What it does

  1. Wraps the prototype methods on CanvasRenderingContext2D and (if present) OffscreenCanvasRenderingContext2D. The wrappers are tagged __guarded so a second install call is a no-op.
  2. Sanitises args before delegating to the original method:
    • Coordinates (x0, y0, x1, y1) — non-finite values become 0.
    • Inner radius r0 — non-finite or negative becomes 0.
    • Outer radius r1 — non-finite or negative becomes 0.001.
    • If r0 >= r1 after sanitisation, r1 is bumped to r0 + 0.001 to avoid Chrome’s IndexSizeError.
  3. Reports the first 3 unique-stack hits per session to Sentry + Supabase via logDiag as event_type: 'canvas_gradient_non_finite'. Payload includes the raw args, the substituted args, the JS stack, and a snapshot of likely culprits (game phase, level, ship X/Y, viewport width/height, devicePixelRatio) read off window.__dev / window.__diag if the engine populated them. Dedupe is by a hash of stack lines 2–6 so a per-frame loop reports once, not 60×/sec.
  4. Exposes window.__canvasGuard = CanvasGuardStats ({ totalHits, reported }) for live console inspection during a play test.

Net effect: any non-finite gradient input returns a valid (effectively transparent / degenerate) CanvasGradient instead of throwing, the renderer continues, and Sentry receives enough info to locate the producing callsite on the next iteration.

Boot order

installCanvasGradientGuards() runs as the first statement in src/main.tsx, before Sentry.init, before createRoot(...).render(<App />), and before any preload phase. This ordering matters: the patch must be active before the React tree mounts and before any module-init code touches a 2D context. Boot sequence in main.tsx:

  1. installCanvasGradientGuards() — monkey-patch prototypes.
  2. Sentry.init({...}) if VITE_SENTRY_DSN is set, else logDiag({ event_type: 'sentry_init_skipped' }).
  3. createRoot(document.getElementById('root')!).render(<App />).

If a future refactor reorders these, the guard becomes useless for any gradient created during module init.

Sentry quota

Cap is MAX_REPORTS = 3 per session, enforced before logDiag is called. Dedupe by stack signature happens before the counter increments, so three distinct callsites can each report once. totalHits keeps counting silently after that — the counter is visible at window.__canvasGuard.totalHits for in-session inspection but doesn’t burn Sentry budget.

  • Source: src/starship-survivors/engine/diagnostics/canvas-guards.ts
  • Boot site: src/main.tsx (line 13)
  • Telemetry sink: src/starship-survivors/engine/telemetry/diag.ts (logDiag)
  • Sentry issue: STARSHIPSURVIVORS-Y (v5.163.2 freeze, 2026-05-03)
  • Related symptom: bridge frame-error catch reschedules RAF on throw → silent freeze
  • Downstream crash: Rapier WASM “recursive use of an object” on RECOVER FROM STUCK cleanup