← All decisions

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

merge-gameaudioffi-minimalitypredictability

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

Change pe.tick's return type to #(Model, List(SoundEvent)) instead of just Model — rejected
The most 'obviously correct' shape in isolation, but a genuinely breaking API change touching every one of pe.tick's ~30+ call sites across pe_test.gleam/game_test.gleam/game.gleam for a feature whose actual need (communicate this-tick's events to the Shell) is just as well served by a smaller, additive change -- not worth the churn.
Let glemy/game infer sound events by comparing model.score/model.entities before and after calling pe.tick, without any new pe-side field — rejected
Duplicates logic tick already computes internally (exactly when can_drop is True, exactly when a merge raised score) in a second place with its own chance to drift out of sync -- e.g. if a future change adds another way score can increase, the Shell's inferred detection would silently break without pe.gleam itself changing.
Add Model.sound_events: List(SoundEvent), recomputed fresh (not accumulated) every tick call from facts tick already has (can_drop, and updated.score > model.score for a merge), read by the Shell exactly like score/game_over already are — chosen
Minimal, additive, and consistent with this project's established migration pattern for new Model fields (resting_time, next_tier). pe stays fully audio-agnostic (SoundEvent describes *what happened*, not *what sound to play*) -- glemy/game.gleam's play_sound_event is the one place that translation happens, matching the Core/Shell split ARCHITECTURE.md documents. Caught and fixed a real edge case during implementation: the game_over freeze branch had to explicitly clear sound_events (not just return model as-is), or a Dropped/Merged event from the exact tick that ended the game would keep reading as 'just happened' and replay its sound on every subsequent frozen frame.
Load real audio files (.mp3/.wav) for the drop/merge sounds — rejected
This project has zero third-party dependencies and no asset pipeline (decisions 0013/0015's standing 'maximize type safety, minimize dependencies' practice) -- adding an assets/ directory, file-loading FFI, and licensing/sourcing real sound files for two short blips is real, avoidable complexity next to a couple of Web Audio oscillator nodes, which are already sufficient to produce a short, percussive tone directly in code.

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.

References