← All decisions

Fixed a real latent bug: click-to-spawn passed raw viewport pixels through as world coordinates, unconverted

merge-gameinputcoordinate-conversionffi

Context

Phase 4 of the approved merge-puzzler plan. While researching the plan, re-reading glemy/game.gleam's loop turned up a real, currently-live bug: `io.mouse_position()` returns MouseEvent.clientX/clientY -- CSS pixels relative to the browser viewport -- and this was passed straight through as the new entity's world-space spawn position with zero conversion. Harmless-looking in the free-for-all sandbox (nothing checked spawn position correctness), but load-bearing and definitely wrong for a game that needs accurate horizontal aiming, made worse by index.html scaling the canvas display size 8x via CSS (64px internal buffer -> 512px displayed) with no accounting for that anywhere in the click handler.

Options considered

Convert both x and y from click position to world space now — rejected
Phase 5 (already approved, next in the plan) replaces click-to-spawn-at-cursor entirely with a fixed spawn height and a cursor-x-only preview/drop mechanic -- a y conversion here would be thrown-away work fixed for a code path that's about to be deleted.
Fix only the x conversion now (bounds.x_from_canvas_pixel), leave y as the raw unconverted click value until Phase 5 replaces it outright — chosen
x is exactly what Phase 5's cursor-follow/drop-position mechanic actually needs preserved and correct; y is dead weight about to be replaced by a constant. Minimal, no wasted work.
Where to put the getBoundingClientRect() bridging: glemy/io_ffi.mjs vs glemy/game_ffi.mjs -- chosen: game_ffi.mjs — chosen
getBoundingClientRect() doesn't exist on OffscreenCanvas, so it's genuinely untestable under gleam test --target javascript (Deno) -- exactly the same category as game_ffi.mjs's existing requestFrame/getCanvasElement (both already accepted as untestable, decision 0015). Putting it in io_ffi.mjs instead would drag that module's currently-full gleam test coverage down for no benefit, violating the FFI-minimization standing rule (decision 0016).

Decision

Added glemy/pe/bounds.x_from_canvas_pixel(pixel_x, canvas_width, bounds) -> Float: pure, target-agnostic linear rescale from a canvas-local CSS-pixel x-coordinate into world-space x, fully gleam test-covered with plain numbers (no FFI, no clamping -- callers needing a spawn position kept inside the box already rely on bounds.bounce/downstream clamping). Added one new FFI export, game_ffi.mjs's canvasBoundingRectLeftAndWidth(canvas), wrapped by glemy/game.gleam's canvas_bounding_rect_left_and_width -- returns getBoundingClientRect().left/.width as a plain #(Float, Float) tuple (no new Gleam record type needed). Wired into game.gleam's loop: click.x - rect_left gives a canvas-local pixel, then bounds.x_from_canvas_pixel converts it to world-x using rect_width (the canvas's actual displayed CSS width) as the scale reference -- correctly accounting for index.html's 8x CSS scale-up without needing to hardcode that factor anywhere. y is left as the raw, still-unconverted click.y, explicitly commented as deliberately out of scope pending Phase 5.

Verification

gleam test: 6 new bounds_test.gleam cases (left/right/center/linearity/non-zero-origin/no-clamping) plus all pre-existing tests, 112 Erlang / 146 JavaScript passing, zero regressions. Real-browser, end-to-end verification via an extended tools/browser_check.ts: a real Playwright-driven click at a controlled 15%-across-canvas position (chosen to avoid coinciding with main()'s starting-scene entities at world-x 30/50/70 or bounds corners 0/100, so the check can't trivially pass by coincidence) is confirmed to spawn a real entity landing within +/-3.0 of the correctly-converted world-x (15.0) -- verified by capturing actual WebGPU uniform-buffer float contents (GPUBuffer.getMappedRange/unmap patched to snapshot every 2-float uniform written, since pixel readback from a headless-presented canvas is a documented, already-known-broken path in this tool). This surfaced and fixed a real instrumentation bug along the way: calling getMappedRange() a second time from inside a patched unmap() throws in real Chromium ('overlaps with previously returned range'), discovered only by actually running the script and inspecting the thrown errors, not assumed from the spec text -- fixed by patching getMappedRange itself to stash the original reference instead of re-requesting it. Also fixed a stale, pre-existing bug this investigation surfaced in the same file: the entity-count formula's divisor (buffersThisFrame - 3) / 2 was left over from before Phase 3 added a third per-entity uniform buffer (color) and was silently under-reporting entity counts; corrected to / 3.

Consequences

Click-to-spawn's x position is now correct in a real browser at any CSS canvas scale factor, not just accidentally close at some sizes. glemy/pe/bounds.x_from_canvas_pixel is the reusable building block Phase 5's cursor-follow/preview/drop mechanic is built directly on top of. tools/browser_check.ts now also verifies real coordinate-conversion correctness end-to-end, not just that the game loop runs and click-to-spawn increases entity count -- a stronger regression guard for any future change touching this path.

References