← All decisions

Generalize pe's SoundEvent into GameEvent, returned as #(Model, List(GameEvent)) from tick rather than carried as a Model field

architecturepegame-loopevent-drivenrefactor

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

Also extract current_tier/next_tier/preview_x/cooldown_remaining/danger_timer/game_over/score out of Model into a separate genre-specific state structure in this same pass — rejected
This is the deeper half of what full genre-agnosticism would eventually need, but there is still exactly one game -- guessing at the right generic Model shape now, with no second genre's real requirements to validate it against, risks the same mistake already made and reversed once with glemy/cs (decision 0031: structure built ahead of a real, demonstrated need). docs/technical-architecture.md §2.3 already defines the correct trigger for this specific work: when a second, genre-distinct game actually begins development (tracked as RM-027). Scoped this change to exactly what technical-architecture.md §2.2 committed to -- the event-notification mechanism -- and left the Model-field question for that later, better-informed point.
Keep the SoundEvent name and only change tick's return shape to a tuple — rejected
SoundEvent was already inaccurate before this change (Merged triggers a visual canvas flash via trigger_merge_flash, not just a sound) -- fixing the return shape without fixing the name would leave a real, now-more-visible naming lie in place, since the doc comment for the renamed type needed to state plainly what it actually represents ("purely for a caller to react to," not audio specifically) to justify the GameEvent name honestly.

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.

References