← All decisions

Fixed silent black-screen failures: dev server now sends no-cache, and the game loop reports errors visibly instead of dying silently

dev-servererror-handlingcachingblack-screen

Context

User report: the game showed only a black screen in a normal browser window, but worked correctly in incognito mode and in the VS Code Simple Browser. Investigation: `deno run jsr:@std/http/file-server` (this project's documented dev-server command, docs/development-plan.md section 3) sends no `Cache-Control` header at all -- only `ETag`/`Last-Modified` -- confirmed directly with `curl -I` against a running instance. A normal browser tab (persistent disk cache, and this session had rebuilt the JS output many times across many phases of work in the same session) can serve a stale, version-skewed copy of one `.mjs` file while the rest of the module graph is fresh -- a real, previously-plausible failure mode this project's fast iteration pace makes more likely than usual. Incognito windows (fresh, isolated cache; most browsers also disable extensions there by default) and a separate browser (VS Code's Simple Browser, a different cache partition and extension set entirely) would both legitimately dodge this. Separately, and regardless of root cause: `game_ffi.mjs`'s `requestFrame` had no error handling at all -- any exception anywhere in a frame's work (a stale-cache version mismatch, a real bug, a transient WebGPU hiccup) silently stopped the requestAnimationFrame loop forever, with the only trace being an uncaught exception in the DevTools console -- which a player has no reason to have open. This combination (a real, if not the only possible, cache-staleness vector, plus zero visible failure reporting) was the actual, fixable target: even if the user's specific case turns out to be something else entirely (e.g. a browser extension interfering with WebGPU, which cannot be verified without direct access to their browser), the game will now report *something* on screen instead of a silent black rectangle either way.

Options considered

Only add cache-busting (no-cache headers) and consider it done — rejected
Doesn't help if the actual cause turns out to be something else (a browser extension, a WebGPU driver issue specific to that profile) -- and even for the cache case itself, a fix that prevents recurrence without also making the *current* failure diagnosable leaves anyone hitting a different cause with the exact same undiagnosable black screen.
Cache-busting (dev server --header flag) AND surface any game-loop error directly on the page (new #glemy-error element, synchronous try/catch plus an unhandledrejection listener in game_ffi.mjs, plus a window 'error' listener registered before the module script even loads) — chosen
Addresses the concrete, verified cache-staleness mechanism directly, and -- independently -- makes every future silent failure of this shape immediately diagnosable on-page rather than requiring DevTools access, regardless of its actual root cause. Defense in depth: a fix for the specific hypothesis, plus a fix for the general failure mode the hypothesis is one instance of.

Decision

docs/development-plan.md's documented dev-server command (section 3) and tools/browser_check.ts's spawned dev server both gained `--header "Cache-Control: no-cache"`. game_ffi.mjs's requestFrame now wraps each frame's callback in try/catch (synchronous failures) and a module-level `globalThis.addEventListener("unhandledrejection", ...)` covers the asynchronous case (each frame's actual work is a fire-and-forget promise chain that returns before it necessarily resolves) -- both write a clear, human-readable message into a new `#glemy-error` element in index.html and log the full error to the console. index.html also registers a `window.addEventListener("error", ...)` in a plain (non-module) script tag before the module script loads, so failures *during* module load/evaluation itself (before the game loop, or game_ffi.mjs's own handlers, even exist yet) are also caught. `globalThis`, not `window`, is used throughout game_ffi.mjs specifically because this module also loads under Deno (`gleam test --target javascript`), where `window` doesn't exist (Deno 2) -- a real bug caught only by actually running the JS test suite after the first version of this fix, not by reasoning alone.

Verification

144 Erlang / 178 JavaScript tests passing, unaffected (this is JS-runner-only code with no automated test coverage of its own, matching game_ffi.mjs's existing documented exception for genuinely browser/Deno-irreducible glue -- decision 0015). Directly verified the `window` vs `globalThis` bug empirically: the first version of this fix (using `window.addEventListener` at module scope) broke the entire JavaScript test suite immediately (`window global is not available in Deno 2`), caught by actually running `gleam test --target javascript`, not assumed safe. Re-ran tools/browser_check.ts (itself updated to use the same `--header` flag) after the fix -- still passes cleanly with zero page errors, confirming the new error-handling machinery doesn't itself introduce any regression in the working case.

Consequences

Any future uncaught error in the game loop -- from any cause, not just cache staleness -- now produces a visible, actionable on-page message instead of an indistinguishable black screen. This does not, and cannot, rule out browser-extension interference as the specific cause of the originally reported case, since that can't be verified without direct access to the user's browser/extensions; the dev-server and error-reporting fixes are what's actionable from the code side regardless.

References