Added drop/merge sound effects: synthesized via Web Audio, not audio files -- and a new Model.sound_events field so the decision of what happened stays pure and testable
Context
Continuing autonomously through the feature-gap backlog: every researched real Suika-style clone has sound effects (a drop thud, a merge pop), and this project had none. Before touching FFI/audio at all, the actual design question was how tick (a pure function) should communicate 'a sound-worthy event happened' to game.gleam's Shell layer without either (a) making tick impure/FFI-aware, which would break its full gleam test coverage and cross the Core/Shell boundary ARCHITECTURE.md just documented, or (b) making the Shell re-derive 'did a drop/merge happen' by diffing Model before/after, which duplicates tick's own internal logic in a second, easy-to-drift-out-of-sync place.
Options considered
Decision
pe.gleam gained SoundEvent (Dropped | Merged) and Model.sound_events: List(SoundEvent), recomputed each tick from can_drop (already computed) and updated.score > model.score (update is the only thing that changes score, so a rise means at least one merge happened -- no need to change update's own return type). The game_over-frozen branch explicitly clears sound_events rather than passing model through unchanged. game_ffi.mjs gained a lazily-created shared AudioContext (singleton, same reasoning as gpu_ffi.mjs's shared GPUDevice) and playDropSound/playMergeSound, each a short oscillator+gain-envelope tone (220Hz/0.08s for a drop, 660Hz/0.12s for a merge -- higher-pitched so the two are distinguishable by ear), using scheduled gain ramps (setValueAtTime + exponentialRampToValueAtTime) rather than direct value assignment, which would introduce an audible click artifact of its own. game.gleam wraps both as play_drop_sound/play_merge_sound, translates a SoundEvent to the right call via play_sound_event, and loop now does list.each(next_model.sound_events, play_sound_event) every frame.
Verification
161 Erlang / 184 JavaScript gleam test cases passing on both targets (6 new: no-events, dropped-only, cooldown-blocks-dropped, merged-only, both-events-same-tick, and the game-over-clears-stale-events regression case that implementation surfaced). tools/browser_check.ts extended with an OscillatorNode.start() call counter (instrumented the same way the existing WebGPU buffer counters are, not mocked) confirming at least one real oscillator was created and started after a real click-and-hold drop -- can't assert on the actual audible sound without a real output device, but this confirms the real Gleam-to-FFI-to-Web-Audio path executes without throwing, which zero-page-errors alone wouldn't have caught if the API were called with wrong arguments. Re-ran the check after the change: still passes cleanly.
Consequences
Drops and merges now have audible feedback, matching standard genre convention, with zero new dependencies or asset files. Model.sound_events establishes a reusable pattern (a this-tick-only, non-accumulated Model field) for any future per-frame event a caller needs to react to without changing tick's own return type. The two tones are placeholder-simple (a single oscillator each) -- richer sound design (layered tones, per-tier pitch variation on merge) remains a real, separate follow-up if wanted, not bundled in speculatively here.