← All decisions

HTML overlay UI: score and game-over text, format computed in Gleam, only non-branching DOM writes in FFI

merge-gameuiffihtml

Context

Phase 7 of the approved merge-puzzler plan: the game now has a real score (Phase 2) and a real lose condition (Phase 6), but nothing on screen shows either -- the WebGPU canvas only ever draws entities, never text. Needed a minimal way to surface both.

Options considered

Render score/game-over text onto the WebGPU canvas itself (e.g. a bitmap font drawn as additional quads) — rejected
Real, nontrivial new rendering work (font atlas, glyph layout) for something a plain HTML element already does for free, with better accessibility and zero shader complexity. Nothing about this project's stack requires everything to go through WebGPU.
Plain HTML overlay elements (#glemy-score, #glemy-game-over) written to via minimal FFI, string formatting done in Gleam — chosen
Matches this project's standing FFI-minimization rule (decision 0016): the only thing that's genuinely irreducible to Gleam is the DOM write itself (there's no `document`/`textContent` on the Erlang target or under Deno's OffscreenCanvas). Formatting the actual text stays in Gleam, fully gleam test-covered, so the FFI functions are pure non-branching property assignments with nothing to get wrong.

Decision

glemy/game.gleam gained format_score(score: Int) -> String (deliberately left untagged, not @target(javascript), even though only the JS-only loop calls it -- plain string formatting has no target-specific need, so it stays gleam test-covered on both Erlang and JavaScript). Two new minimal FFI wrappers, set_score_text(text: String) -> Nil and set_game_over(visible: Bool, message: String) -> Nil, call straight through to game_ffi.mjs's setScoreText/setGameOver -- each just a textContent assignment (and, for game-over, a `hidden` boolean assignment), no branching logic in the JS at all. glemy/game.gleam's loop calls both every frame, unconditionally, off the freshly ticked Model (score, game_over) -- idempotent re-writes are harmless and avoid needing any extra edge-detection state. index.html gained #glemy-score (starts "Score: 0") and #glemy-game-over (starts `hidden`), styled minimally alongside the existing hint text.

Verification

135 Erlang / 169 JavaScript tests passing (up from 132/166), all additive: three new format_score tests (zero, a multi-digit positive value, a single digit) run on both targets since the function itself is target-agnostic. Real-browser verification via tools/browser_check.ts: confirmed #glemy-score reads exactly "Score: 0" and #glemy-game-over is still hidden on a freshly loaded page, before any drop or loss -- direct, real-DOM proof that glemy/game.gleam's loop is actually reaching game_ffi.mjs's setScoreText/setGameOver every frame, not just that the pure Gleam formatting is correct in isolation (which format_score's own gleam test coverage already established separately).

Consequences

Any future on-page text (e.g. a restart hint, a tier-preview label) has an established, minimal pattern to follow: format in Gleam, write via a single non-branching FFI call. No score/game-over text verification was attempted through pixel readback, since tools/browser_check.ts's own prior investigation (decision 0021) already established that headless Chromium can't read back canvas pixels here -- irrelevant anyway, since this text lives in the DOM, not the canvas.

References