← All decisions

Added a visible danger-line indicator: a CSS overlay, not a WebGPU-rendered entity

merge-gameuidanger-linepredictability

Context

Identified during the prior session's feature-gap research (alongside the drop-preview fix, decision 0030) as a real, standard genre convention this project was missing: pe.gleam has tracked a danger_line_y (the world-y above which the lose-condition timer starts) since Phase 6, but nothing ever rendered where that line actually is -- a player can only discover it by losing. Continuing autonomously per the user's instruction to keep developing without per-step check-ins, addressed this next since it's the same category of gap as decision 0030 (real state, never made visible) and directly supports predictability, which the last several decisions (0029, 0030, 0032) have all been targeting from different angles.

Options considered

Render the danger line as part of the WebGPU entity pipeline -- e.g. a row of small synthetic circles (matching the drop-preview's pe.preview_entity trick) or a new line-drawing shader path — rejected
The danger line's position is static for the entire life of a running game (bounds never change after main() constructs the initial Model) -- recomputing and re-uploading GPU draw calls for it every single frame, the way real per-entity circles necessarily are, would be pure waste for something that's a one-time layout fact. A new line-drawing shader path would also mean real new WebGPU/FFI surface area (violating decision 0016's FFI-minimality rule) for a purely static, decorative element that doesn't need per-frame GPU execution to be correct.
Hardcode the CSS overlay's position (e.g. `top: 5%`) directly in index.html, independent of pe.gleam's actual danger_line_margin_from_top constant — rejected
Would create exactly the kind of silent-drift risk this project's standing practice avoids elsewhere (e.g. decision 0020's 'tier.color is the single source of truth' reasoning): if danger_line_margin_from_top is ever tuned, a hardcoded CSS percentage would silently stop matching the real lose-condition line, showing the wrong thing with no compiler or test to catch it.
A plain CSS-only overlay (`#glemy-danger-line`, absolutely positioned over a new `#glemy-canvas-wrap` container), with its `top` percentage computed once from the real Model/Bounds via a new pure Core function (`bounds.y_fraction_from_top`) and written through a single non-branching FFI call at startup (matching the existing `set_score_text`/`set_game_over` pattern, decision 0016) — chosen
Reuses the existing minimal-FFI, compute-in-Core pattern this project already has two working examples of, costs zero new WebGPU/shader surface area, and only needs to run once (at main()'s startup) rather than every frame, since the line's position is static. bounds.y_fraction_from_top is the direct mirror of the module's own existing x_from_canvas_pixel (world-space <-> screen-space conversion, same domain, opposite direction and axis), keeping pe/bounds.gleam's role consistent rather than introducing a new module for one function.

Decision

Added pub fn bounds.y_fraction_from_top(world_y, bounds) -> Float to pe/bounds.gleam (pure, fully gleam test-covered: at-top/at-bottom/midpoint/linearity/non-zero-origin/no-clamping cases, mirroring x_from_canvas_pixel's own existing test suite exactly). Made pe.danger_line_y pub (previously private) so game.gleam can compute the same y a real game-over decision uses, not a second hand-copied value. Added game_ffi.mjs's setDangerLinePosition (a single non-branching style.top write) wrapped by game.gleam's set_danger_line_position, called once in main() right after constructing the initial Model. index.html gained a #glemy-canvas-wrap positioning container and a #glemy-danger-line absolutely-positioned dashed-border div (pointer-events: none, so it can't intercept real clicks meant for the canvas underneath it).

Verification

152 Erlang / 175 JavaScript gleam test cases passing on both targets (6 new tests for y_fraction_from_top). Extended tools/browser_check.ts with a real-browser assertion that #glemy-danger-line's actual computed style.top reads exactly "5%" after main() runs (matching bounds 0..100 and danger_line_margin_from_top 5.0) -- confirms the real Gleam-to-FFI-to-DOM path actually executes, not just that the pure math is correct in isolation. Re-ran the check after the change: still passes cleanly, zero page errors, click-to-spawn and world-x conversion still work correctly (the new wrapper div doesn't interfere with the canvas's own getBoundingClientRect()-based click handling).

Consequences

A player can now see exactly where the lose condition triggers before ever reaching it, rather than discovering the danger line only by losing. The line's position would automatically stay correct if danger_line_margin_from_top or bounds are ever changed, since it's computed from the same source the real game-over logic uses, not a second hardcoded value. This pattern (a one-time-computed, FFI-set CSS position for a static world-space fact) is now the template for any future static overlay this project might want, distinct from pe.preview_entity's pattern (a per-frame, GPU-rendered synthetic entity) for anything that actually needs to track moving state.

References