Generalize pe's SoundEvent into GameEvent, returned as #(Model, List(GameEvent)) from tick rather than carried as a Model field
Context
docs/technical-architecture.md §2.2 (decision 0042) committed to representing genre-specific game-rule outcomes as GameEvent data returned from pe.tick, rather than embedded as first-class concepts inside genre-agnostic physics logic -- tracked as roadmap item RM-016. Auditing the actual current code (pe.gleam, game.gleam) against that commitment found the mechanism already existed in spirit: SoundEvent (Dropped, Merged) already notified glemy/game of per-tick outcomes for it to react to, and pe already had no concept of what a reaction actually was (audio vs. visual -- Merged already triggers both a sound and a visual flash, despite the type being named only for sound). What didn't match the committed shape: events were carried as a sound_events field on Model (persisted state) rather than returned alongside it from tick as a tuple, and the type's own name (SoundEvent) was narrower than its real, already-multi-purpose use.
Options considered
Decision
Renamed pe.SoundEvent to pe.GameEvent (constructors Dropped/Merged unchanged, both already generically named). Removed sound_events as a Model field entirely. Changed pe.tick's signature from `tick(model, dt, input) -> Model` to `tick(model, dt, input) -> #(Model, List(GameEvent))`, computed fresh on every call (the game_over-frozen branch now returns `#(model, [])` directly rather than needing to explicitly clear a stale carried-over field -- a real bug class the old shape needed a manual convention to avoid, the new shape can't have at all). Threaded the change through glemy/game.gleam: tick_and_render now returns a 3-tuple (Model, List(GameEvent), render Result) instead of 2, since it must stay fully gleam test-covered under Deno (no AudioContext/DOM) while loop -- the one place GameEvent actually becomes an FFI call -- consumes the returned events instead of reading them off next_model. Updated all call sites across pe_test.gleam (30 Model literals, ~25 tick call sites) and game_test.gleam accordingly, including renaming the handful of event-specific test names/comments from "sound_events" to "game_events" language to match.
Verification
gleam test and gleam test --target javascript both pass with identical counts to before this change (164 Erlang / 187 JavaScript) -- confirming no test coverage was lost in the mechanical migration, only the shape changed. deno task check-warnings passes (49/49 matching the existing baseline, no new warnings). deno task browser-check passes for real (183 real requestAnimationFrame frames, a real click-and-hold grew the entity count 4 -> 5, a real entity spawned near the expected world-x, zero page errors) -- confirming the actual running game (sound/visual feedback included, since play_sound_event is exercised by real Dropped/Merged events flowing through loop) still works end-to-end after the refactor, not just that it type-checks.
Consequences
pe.tick now matches the exact signature docs/technical-architecture.md §2.2 committed to, closing RM-016. A second, genre-distinct game can react to what pe.tick reports happened (via GameEvent) without pe needing to know what that reaction is -- the actual prerequisite §2.2 named. Model still carries genre-specific fields (tier/score/danger-line state) alongside genre-agnostic ones (entities/bounds/gravity); pe.gleam's own Model doc comment now states this explicitly, including exactly why extracting them further is deliberately deferred to RM-027's trigger rather than left as a silent gap. tick_and_render's return type grew from a 2-tuple to a 3-tuple, a real, minor call-site churn cost for every current and future caller -- accepted as the honest cost of exposing per-frame events at the one layer (glemy/game) that can actually decide what to do with them.