Refactor GameAudio from the single clear cue into a full bank: load swap,
match, combo1..combo5, win, lose once at startup, each into its own
SystemSoundID via load_system_sound. Expose per-event play methods
(play_swap/play_match/play_cascade/play_win/play_lose), each a single
AudioServicesPlaySystemSound. play_cascade selects the ascending clip by
clamping cascade depth through the pure, OS-agnostic cascade_cue_index
(depth<=1 -> combo1, depth>=5 -> combo5) so P10.4 can snapshot it headlessly.
Layer stays purely additive and inline-if-OS==.ios guarded; the clear trigger
is migrated to sfx_cascade(mv.rounds.len) at its board_view call site, with
sfx_swap/match/win/lose shims staged for P10.3 wiring. No framework added
beyond the already-linked AudioToolbox/CoreFoundation (build.sx untouched).
Feasibility spike outcome: iOS audio from sx is feasible with no sx-library
change. System Sound Services is plain C, reached with the same `#foreign`
FFI uikit.sx already uses (UIApplicationMain / dlsym / CACurrentMediaTime);
AudioToolbox + CoreFoundation are linked per-target in build.sx.
Smallest viable SFX: one short CC0 clip (Kenney Interface Sounds, CC0 1.0)
played when a swap clears a match. Purely additive — audio.sx reads/writes
no score/board/move state; the wiring in board_view only adds a call.
- audio.sx: load clear.wav once, AudioServicesPlaySystemSound on clear
- board_view.sx: trigger sfx_clear() on a legal swap that clears (>=1 round)
- main.sx: allocate + init g_audio at boot
- build.sx: link AudioToolbox + CoreFoundation on iOS
- assets/audio/clear.wav (+ one-line CC0 credit in LICENSE.txt)
Verified: ios-sim build links; 18/18 tests pass; sim boot log shows
"[sx] audio: clear cue loaded" (AudioServicesCreateSystemSoundID succeeded,
asset shipped in the bundle and decoded).
Extend the HUD to show the per-level goal (SCORE x / target) alongside
moves. When the model's level_status (P7.1) is won/lost, draw a centered
overlay banner ("YOU WIN!" / "OUT OF MOVES") with a "PLAY AGAIN" restart
button over the dimmed board; the banner appears once any winning/losing
cascade animation settles. Status is read from the model, never recomputed
in the view.
A finished level freezes board-cell input; only the restart button is live.
Its rect is derived from the shared BoardLayout grid (new BannerLayout), so
the hit-test lands exactly on the drawn button. A tap reseeds the same
starting level through board.restart and clears the transient view layers,
returning to a clean in_progress board.
Banner is text + rects only (honours colour/alpha; no draw-time image tint,
issue 0002). New env capture hooks (M3TE_TARGET / M3TE_MOVE_LIMIT /
M3TE_RESTART) force a terminal status / restart for deterministic goldens.
Tests: tests/banner_layout.sx locks the restart button rect <-> hit-test
round-trip headlessly. Goldens p7_win / p7_lose / p7_restart captured on the
iOS simulator.
New gem_anim.sx adds a purely-visual per-gem pose set driven by a single
animation clock: a calm always-on idle breath (scale-pulse + bob, per-gem
phase, ramped in from rest), a selection pop, a landing squash-bounce, and
a clear pop. BoardView draws every settled gem through gem_pose_at /
gem_pose_frame; the move timeline (P6.1) and FX (P6.2) are untouched and the
input-lock semantics are unchanged (idle never locks input).
Determinism: the idle is always-on, so main reads M3TE_ANIM_TIME=<seconds>
to freeze the clock at a chosen phase (t==0 == the resting board, so the
pre-P6.3 goldens reproduce) and M3TE_SELECT=<cellIndex> to force a selection
for capture. tests/gem_pose.sx locks the t==0-rest invariant and the reaction
envelopes headlessly (fails if the idle ramp is dropped).
Goldens (deterministic capture): p6_idle_t0 (resting), p6_idle_mid (pinned
mid-breath), p6_select (selection pop on cell 3,3). Purely visual: no change
to model/score/moves/hit-testing.
Add a purely-visual, transient juice layer over a committed move — score
popups + a tinted particle/flash burst at the clears — with no change to the
model, score, moves, or settled board.
- assets/fx/particle.png: key the painted transparency checkerboard out of the
provided particle art to real alpha (8-connected border flood fill +
smooth luminance falloff that preserves the soft glow), downscaled to a
256x256 RGBA white sparkle. tools/key_particle.sx is the reproducible tool.
- board_fx.sx: BoardFx (live particle bursts + one "+points" popup) and
BoardFxAssets. The engine image path samples texture*white (no draw-time
tint), so the white sprite is tinted per gem colour at LOAD time into one
texture per colour; a burst animates by scale (grow -> shrink) and the soft
texture edges carry the fade. Combos (cascade depth > 1) burst bigger and the
popup is larger + gold. All driven by delta_time and self-pruning.
- board_anim.sx: AnimMove carries the model's cascade.awarded so the popup
shows the real payout without re-deriving any scoring in the view.
- board_view.sx / main.sx: wire BoardFx + the tinted assets, tick each frame,
spawn on a legal commit, and render bursts (clipped to the grid) under the
popups (drawn on top). Input-lock (BoardAnim.active) is untouched; FX never
gate input and may outlast the move slightly before vanishing.
Goldens (iPhone-17-class sim, iOS 26): p6_fx.png (combo: gold "+480" + bursts
mid-cascade), p6_fx_match.png (single match: "+30" + red burst), p6_fx_after.png
(settled board, FX fully gone). Gate: ios-sim build links, 15/15 logic tests
green (scoring/cascade goldens unchanged).
Add a purely-visual animation timeline so the board no longer snaps on a
move. board_anim.sx records, on a value-copy of the pre-move board, the
swap and each cascade round's matched cells + per-column fall provenance,
then BoardView plays it over delta_time: the two swapped gems SLIDE between
cells (and ping out-and-back on an illegal swap), matched gems SCALE OUT,
and survivors FALL into place while refills drop in from above the grid.
The model stays authoritative: plan_and_commit still calls commit_swap on
the real board exactly as before, and the recording replays the identical
primitives from the identical cells + RNG state, so the timeline ends ON
the model's settled board. tests/anim_plan.sx is the determinism guard —
it asserts the committed board, score, moves, and the timeline's final
state all equal an independent commit_swap of the same move, that the
rounds are contiguous, and that an illegal swap records nothing and leaves
the board untouched. All pre-existing logic/cascade goldens stay green.
Evidence (sx-test-metal, iOS 26.0, time-sampled with temporarily-lengthened
durations; committed durations are the short production values):
goldens/p6_anim_swap.png gems sliding between (5,4)/(6,4)
goldens/p6_anim_clear.png matched reds scaling out in row 4
goldens/p6_anim_fall.png gems mid-fall with gaps + refill dropping in
goldens/p6_anim_after.png settled board == model (SCORE 30, MOVES 29/30)
Wire touch input into the model in BoardView.handle_event. A press records
the drag start (new DragInput, heap-allocated so it survives the per-frame
BoardView rebuild between mouse_down and mouse_up); the release resolves the
gesture against the same layout it was drawn with. A swipe — start→end mapped
by swipe_intent to an adjacent-swap intent — is fed straight into
commit_swap: a legal swap applies, cascades (clear→collapse→refill), accrues
score and spends a move; an illegal one reverts, no move. A sub-threshold /
off-board drag carries no intent and falls back to the tap behaviour
(toggle/clear selection). The next frame re-renders board + HUD from the model.
Reuses swipe.sx + board_layout.sx + commit_swap unchanged — this is wiring,
not new legality/cascade logic.
tests/swipe_commit.sx (new golden) drives the full path on the seeded board
(SEED 1337): a rightward swipe (0,0)->(1,0) is illegal (two reds) and reverts
byte-for-byte with no score/move; (5,4)->(6,4) is legal, completes R,R,R on
row 4, awards 30, spends one move.
Sim evidence (iPhone 17, iOS 26.0): goldens/p5_swap_before.png (SCORE 0,
MOVES 30/30) and goldens/p5_swap_after.png (SCORE 30, MOVES 29/30) bracket a
real idb-injected swipe at (276,475)->(327,475) pt = cell (5,4)->(6,4); the
three reds clear and the board matches the model's resolved state.
Tap a gem to select it: BoardView hit-tests the touch to a grid cell and
draws a bright rim + translucent fill over it; tapping the same cell clears
the selection, tapping another moves it, tapping off-board clears it.
Selection only — no swap (that's P5). The HUD renders the live score and
remaining moves (out of the move limit) in the Lato font on a translucent
card above the grid.
The touch→cell geometry is factored into a pure BoardLayout (no GL/stb
imports) that BoardView composes and P5 will reuse for swap endpoints.
tests/hit_test.sx locks point_to_cell as the exact inverse of cell_frame
(every cell center round-trips; off-board taps reject) — headless because
BoardLayout pulls no C imports. goldens/p4_hud.png captures the scene after
a real idb tap at (201,437)pt: the HUD plus a yellow selection rim on the
red gem at cell (col 4, row 3).
Adopt the modules/ui UIPipeline framework (as the chess reference app does)
and replace the P0 placeholder quad with a BoardView (View protocol, modeled
on chess/board_view.sx):
- background.png fills the screen; an 8x8 cell.png grid is centered in the
safe area; each cell's gem is sampled from gems.png by UV column = gem index
(0=red .. 5=purple).
- Drive it from board.sx seeded with 1337 (the board_init golden's seed), so
the on-screen layout matches that snapshot gem-for-gem.
main.sx now hosts the view via UIPipeline (Metal on iOS, GL on desktop) and
heap-allocates the board/asset state behind pointers (UFCS method calls on a
value-typed global mutate a copy, so mutable state must live behind a pointer
as the reference app does).
Vendor the C deps the UI module's image/font path needs (stb_image,
stb_truetype, kb_text_shape, file_utils); their #include "vendors/..." paths
resolve relative to the project root.
Evidence: ios-sim build links clean; tools/run_tests.sh 11/11 pass; running
app captured at goldens/p4_board.png.
Prove the input path end-to-end. Poll platform events in the frame loop
and, on a mouse_down (UIKit touchesBegan, mapped to an Event in uikit.sx),
toggle the centered quad between orange and green. The toggle marks the
vertex buffer dirty; the next frame re-uploads the active color palette
before drawing. Flip on the press only — a tap also delivers mouse_up
(touchesEnded), so toggling on both would net to no visible change.
goldens/p0_input_before.png (orange quad) and goldens/p0_input_after.png
(green quad) bracket a real tap injected at the device-screen center
(201,437 pt) via `idb ui tap` on the booted iOS 26.0 simulator.
Prove the render path end-to-end: bring up UIKit + Metal (already
scaffolded), clear to a solid blue, and draw one centered orange quad via
the GPU protocol's clear+quad path — an MSL pass-through pipeline plus a
6-vertex (2-triangle) NDC buffer, created lazily once the MTLDevice exists.
Geometry is NDC [-0.5, 0.5]² so the quad is the central 50%x50% of the
drawable regardless of device resolution, keeping the screenshot golden
unambiguous. Background (0.10, 0.20, 0.55), quad (1.0, 0.6, 0.0).
goldens/p0_quad.png is the first screenshot golden, captured from the
booted iOS 26.0 simulator after install + launch.
Stand up build.sx (macos + ios/ios-sim targets, bundle id
co.swipelab.m3te, output sx-out/ios/M3te.app, assets dir) and a minimal
main.sx that brings up the platform (UIKit+Metal on iOS, SDL3+GL on
macOS) and renders a solid-clear frame. Add assets/ and goldens/
directories and a .gitignore for build artifacts.
Modeled on game/build.sx and game/main.sx; modules resolve from the
compiler binary with no -L flag.