Final golden sweep + doc finalization for the organic-animation pass. No .sx logic/render change; goldens + README only (CLAUDE.md is local-only/gitignored). Refreshed 8 stale goldens to the SHIPPED render (each verified deterministic): - p6_anim_swap/clear/fall, p6_fx, p6_inputlock_board (M3TE_FX=3 at swap/clear/fall segment phases): last captured at P13.1, so they still showed the PRE-organic flat tweens; now show the merged overshoot / pop-ripple / gravity-accel motion. - p17_fall, p17_stagger, p17_land (M3TE_FX=11 deep cascade): captured incrementally at P17.1/.2/.3 and never updated as later steps changed the shared cascade frame (P17.2 stagger, P17.3 squash, P18.2 burst ripple), so the committed PNGs no longer matched the shipped code (12-20% board-region diff). Re-captured to the final, fully-merged fall motion at their documented phases. README: - Added the previously-undocumented move-timeline frame recipes (swap/clear/fall/ after segment phases + the resting-board goldens), filling the doc gap. - Fixed the now-false P17 prose: p17_fall was described as a "pre-stagger lockstep reference" and p17_stagger as carrying "no squash" — both untrue once the full fall motion shipped. Now describes all three as the final combined motion, each pinned to foreground a different tell (accel / staircase / squash wave). Verified all 28 goldens: 8 refreshed, 20 unchanged (board+HUD region byte-identical over a status-bar/home-indicator crop), 0 removed. t=0 rest, win/lose, HUD, select, idle, FPS, p16/p18 and p11_combo_deep all reproduce. Gate green (ios-sim build + 22/22 logic tests; tests/gem_pose.sx + tests/easing.sx pin the t=0 rest invariant).
38 KiB
m3te
A match-3 game written entirely in the sx language, targeting iOS first.
- Game logic, rendering, input, and UI are all authored in sx.
- Art (palettes, sprite sheets) is produced as real assets.
- Verification gate: sx logic tests pass AND the iOS app builds & launches in the Simulator.
Development is driven by the multi-agent flow (Product Owner → Worker → Reviewer → Observer).
Verification gate
The gate has two halves. Both must pass. The sx compiler used below lives at
/Users/agra/projects/sx/zig-out/bin/sx (override the runner's binary with the
SX env var). Run everything from the repo root.
1. Logic tests
Pure-sx logic tests run under sx and have their stdout + exit code diffed
against committed snapshots in tests/expected/. A failed assertion exits the
process non-zero, so it fails the runner (and the gate).
bash tools/run_tests.sh
- A test is any
tests/<name>.sxthat has atests/expected/<name>.exitmarker;tests/test.sx(theexpectassert helper) has no marker, so it is not itself run. - Regenerate snapshots after an intentional change:
bash tools/run_tests.sh --update.
2. iOS Simulator build + launch
Build the app for the simulator, then install/launch it on an available device and screenshot the rendered scene — the brighter candy match-3 board: a soft pink→lavender→blue gradient background, an 8×8 grid of glossy candy gems on cream cell tiles, and a grape candy HUD card (score / moves) above the grid.
# Build the .app bundle (sx-out/ios/M3te.app):
/Users/agra/projects/sx/zig-out/bin/sx build --target ios-sim main.sx
# Discover an available simulator — do NOT hardcode a udid:
xcrun simctl list devices available
# e.g. capture the first available device's UDID into $udid:
udid=$(xcrun simctl list devices available | grep -Eo '[0-9A-Fa-f-]{36}' | head -1)
# Boot it (skip if already "Booted") and bring the Simulator window up:
xcrun simctl boot "$udid" || true
open -a Simulator
# Install, launch (bundle id co.swipelab.m3te), and screenshot:
xcrun simctl install booted sx-out/ios/M3te.app
xcrun simctl launch booted co.swipelab.m3te
xcrun simctl io booted screenshot /tmp/m3te.png
The screenshot should match goldens/p6_idle_t0.png (the resting candy board),
modulo the status-bar clock — pixel-exact equality is not required; compare the
board + HUD region, not the top status strip. A tap selects a cell; a swipe
between two adjacent gems commits a swap (legal swaps cascade + score, illegal
ones ping back). The deterministic capture hooks below pin a chosen scene so each
golden reproduces from a clean checkout.
The earlier
p0_*goldens (a pre-board orange quad over a blue clear, from the P0 foundation slice) were removed in P13.1: the app no longer renders that scene, so they could only ever mislead.
Deterministic animation capture (P6.3)
The per-gem idle loop (gem_anim.sx) is always-on, so a plain screenshot is
time-dependent. Two environment variables pin the visual state so the board can
be captured reproducibly. The simulator forwards any SIMCTL_CHILD_* variable to
the launched app, so prefix them on the simctl launch:
M3TE_ANIM_TIME=<seconds>freezes the animation clock at that phase.t=0is the resting board — every gem sits at its static pose, so the pre-P6.3 goldens reproduce unchanged. A largert(e.g.1.0) shows the mid-breath idle deformation. The select/land reactions read this same pinned phase.M3TE_SELECT=<cellIndex 0..63>(=row*8 + col) force-selects a cell at startup, so the selection highlight + pop can be captured without a tap.
Every
M3TE_*pin is read once, at app startup. Asimctl launchagainst an already-running copy just foregrounds the existing process (the launch prints the same PID) and the newSIMCTL_CHILD_*value is ignored. So every multi-launch recipe below passes--terminate-running-processon each pinned launch — it kills the running copy first, so the fresh process re-reads the new pin. (xcrun simctl terminate booted co.swipelab.m3tebefore each relaunch does the same.) A changed PID between launches confirms the new pin took.
# Resting board (idle at rest): goldens/p6_idle_t0.png
SIMCTL_CHILD_M3TE_ANIM_TIME=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Mid-breath idle: goldens/p6_idle_mid.png
SIMCTL_CHILD_M3TE_ANIM_TIME=1.0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Selection pop on cell (3,3): goldens/p6_select.png
env SIMCTL_CHILD_M3TE_ANIM_TIME=0.17 SIMCTL_CHILD_M3TE_SELECT=27 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
With no variable set the game runs fully live (the clock advances by
delta_time). tests/gem_pose.sx locks the t==0-rest invariant headlessly.
Level-state capture (P7.2)
The win/lose banner and restart button are driven by the model's level_status
(score vs. goal vs. move budget). Three more env hooks force a terminal status
(or a restart) so the banner / restart states can be screenshot deterministically
without scripting a winning swipe — combine them with M3TE_ANIM_TIME to pin the
idle clock:
M3TE_TARGET=<n>overrides the per-level score goal.0makes the fresh board read won immediately (score 0 ≥ goal 0).M3TE_MOVE_LIMIT=<n>overrides the move budget.0makes it read lost (budget spent below the goal).M3TE_RESTART=<non-zero>runsboard.restartafter the overrides, capturing the freshin_progressboard the restart button produces.
# Win banner + restart over the board: goldens/p7_win.png
env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Lose banner ("OUT OF MOVES") + restart: goldens/p7_lose.png
env SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Fresh in_progress board after restart: goldens/p7_restart.png
env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_RESTART=1 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
While a banner is up the board freezes (only the restart button is live, per
P7.1's finished-level rule); tests/banner_layout.sx locks the restart button's
rect ↔ hit-test round-trip headlessly.
Match-FX capture (P11.1)
The match bursts + score popup (board_fx.sx) only spawn off a committed move,
which the simulator can't script (there is no public touch injection). One more
env hook forces a representative match at startup so the FX can be screenshot
deterministically — combine it with M3TE_ANIM_TIME to freeze the phase:
M3TE_FX=<n>commits the n-th currently-legal swap (1-based, clamped;=1is the first) through the normalplan_and_commitpath, then begins the move timeline + its burst/popup FX. WhileM3TE_ANIM_TIMEis set the move/FX timelines are pinned at that phase (the frame loop holds them frozen), so the burst and floating+pointsrender identically every run. A largerM3TE_ANIM_TIMElands past the timeline, capturing the settled board with the FX fully pruned. Startup-only and guarded by the var, so normal play is untouched.
The legal-swap order is the fixed enumeration in tests/expected/swap_legality.stdout
(row-major, right-before-down). For seed 1337, M3TE_FX=3 is the vertical red
3-match used by the golden, and M3TE_FX=11 is a depth-5 cascade (the deepest
on this seed) used to capture the escalated combo emphasis (next section).
# Punchy match burst + "+30" popup, pinned mid-clear: goldens/p6_fx_match.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same match, later phase — FX fully gone over the settled board (no golden):
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
Escalating combo emphasis (P11.2)
The combo FX escalates with cascade depth (mv.rounds.len), the SAME depth the
cascade SFX (play_cascade) steps up on: a deeper cascade gets a bigger, hotter-
gold +points popup topped by a COMBO xN label, and bursts that grow from the
first round. The depth→emphasis clamp (fx_combo_level) mirrors the cascade cue's
cascade_cue_index exactly (depth ≤ 1 → floor, depth ≥ 5 → ceiling); the
equivalence is locked headlessly by tests/fx_combo.sx.
Capture it with the same M3TE_FX hook — M3TE_FX=11 is a depth-5 cascade on
seed 1337, contrasted against the depth-1 single clear M3TE_FX=3:
# Escalated COMBO x5 + gold "+1050" + bigger burst: goldens/p11_combo_deep.png
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Single clear for contrast — plain white "+30", no COMBO label (goldens/p6_fx_match.png):
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Deep cascade at a later phase — all combo FX gone over the settled board (no golden):
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
The combo emphasis is purely visual and self-pruning: it never gates input
(BoardAnim.active owns gating) and never touches board / score / move state.
Glossier gem & selection feel (P11.3)
The selection highlight (board_view.sx render_selection) is a candy-glossier
overlay: two concentric stroked rings fake a soft outward glow, a warm wash tints
the cell, a bright rim is doubled by a thin inner highlight for a glassy edge, and
a wet sheen rides the selected gem's live pose. The engine can't tint a texture at
draw time (issue 0002), so every layer is a rect/overlay — never a gem-texture
tint. The selection-pop motion still comes from gem_anim, so the t==0 idle
pose is byte-identical to the static sprite (locked by tests/gem_pose.sx); the
gloss is selection-only, so the resting board (no selection) is unchanged.
Capture it with the same P6.3 hooks — no new env var:
# Glossy candy selection on cell (3,3), pinned mid-pop: goldens/p6_select.png
env SIMCTL_CHILD_M3TE_ANIM_TIME=0.17 SIMCTL_CHILD_M3TE_SELECT=27 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same selection at exact rest (no pop) — isolates the overlay:
env SIMCTL_CHILD_M3TE_ANIM_TIME=0 SIMCTL_CHILD_M3TE_SELECT=27 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
The selection gloss is purely visual: it never gates input (BoardAnim.active
owns gating) and never touches board / score / move state.
Organic swap motion (P16.1)
A legal swap no longer slides flatly into place: render_swap drives the two
gems with ease_out_back (the P15.1 overshoot curve), so they shoot a touch PAST
their target cells then settle exactly onto them. The curve pins f(0)=0 and
f(1)=1, so the swap stays purely visual — t==0 is the rest pose and t==1
lands byte-on-cell; the committed move and final board are unchanged. The reused
SWAP_ANIM_DUR (0.16 s) is untouched, so the cascade-cue timing snapshots
(tests/cascade_rounds.sx / cascade_cue.sx) don't churn.
Capture the overshoot with the same M3TE_FX hook, pinned near the peak
(swap-phase t ≈ 0.625, where the gems are ~10 % past target):
# Swapped gems caught PAST their target cells (overshoot): goldens/p16_swap.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.10 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same swap at exact rest (t=0) — gems sit dead-on their pre-swap cells:
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
M3TE_FX=3 is the top-row swap (5,0)↔(6,0) that completes the vertical red
3-match in column 5; at M3TE_ANIM_TIME=0.10 the red lands ~8 % left of col-5
and the green ~12 % right of col-6 (every unswapped gem stays centered).
Organic illegal swap (P16.2)
A rejected swap no longer pings flatly out and back: render_swap now drives the
two gems with bad_swap_bounce (a P15.1 spring-based envelope), so they lunge
toward each other then spring home, overshooting rest by a bounded amount before
settling. The curve pins f(0)=0 and f(1)=0, so the move stays purely visual —
both t=0 and t=1 are the rest pose, the board is byte-identical to pre-swap, no
move or score is spent. The envelope (endpoints, single lunge peak + location,
damped settle) is locked headlessly by tests/easing.sx.
The bounce only plays for an illegal swap, which the sim can't script, so one more
env hook forces a known rejected pair at startup — combine it with M3TE_ANIM_TIME
to freeze the phase:
M3TE_BADSWAP=<n>commits the n-th currently-ILLEGAL orthogonally-adjacent pair — the complement oflegal_swaps, enumerated in the SAME stable row-major order (right-before-down), 1-based + clamped — through the normalplan_and_commitpath (which reverts the illegal swap), then begins the move timeline so the swap-segment bounce can be screenshot. No FX begins (a rejected swap clears nothing). Startup-only and guarded by the var, so normal play is byte-identical with it unset.
The lunge peak is at swap-phase t = BADSWAP_LUNGE_T (0.36), i.e. animation time
0.36 × SWAP_ANIM_DUR (0.16) ≈ 0.0576 s. For seed 1337, M3TE_BADSWAP=41 is the
mid-board horizontal pair (2,3)↔(3,3):
# Springy lunge caught at its extreme (gems nudged toward each other): goldens/p16_badswap.png
env SIMCTL_CHILD_M3TE_BADSWAP=41 SIMCTL_CHILD_M3TE_ANIM_TIME=0.0576 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same rejected pair at exact rest (t=0) — gems sit dead-on their pre-swap cells:
env SIMCTL_CHILD_M3TE_BADSWAP=41 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
Organic fall — gravity accel (P17.1)
Falling gems no longer decelerate into place: render_fall now drives the
per-round drop with ease_in_cubic (the P15.1 accelerate-from-rest curve) instead
of ease_out_cubic, so gems start slow and accelerate down like gravity. The curve
pins f(0)=0 and f(1)=1, so each gem still lands EXACTLY on its destination cell
at the segment's end — the seam to the next round / settled board stays invisible
and move.final is untouched (tests/anim_plan.sx contiguity stays green).
FALL_ANIM_DUR (0.22 s) is unchanged, so the per-round cascade-cue timing
snapshots (tests/cascade_rounds.sx / cascade_cue.sx) don't churn.
The visual tell of the accel: pinned mid-fall, gems are bunched HIGH near their
sources (little distance covered) rather than spread out near landing. Capture it
with the M3TE_FX hook; M3TE_FX=11 is the depth-5 cascade on seed 1337, pinned
inside round 3's fall window [1.38, 1.60) s — at 1.51 we are ~59 % through
that segment in time, yet ease_in_cubic(0.59) ≈ 0.20, so the gems have covered
only ~20 % of their drop and hang caught in the upper rows over the filled lower
board (the old ease_out_cubic(0.59) ≈ 0.93 would have them ~93 % down, all but
landed):
# Gems caught bunched-high mid-fall under gravity accel: goldens/p17_fall.png
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.51 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same cascade past the timeline — fully settled board (no golden):
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
The round-k fall window is [0.30 + 0.36·k, 0.52 + 0.36·k) s on the
swap→(clear,fall)* timeline (swap 0.16, then clear 0.14 + fall 0.22 per round), so
1.51 lands squarely in round 3's fall. The change is render-only — no board.sx
model change, and normal play is byte-identical apart from the fall's motion curve.
Organic fall — per-column stagger (P17.2)
The fall no longer drops a whole row in lockstep: render_fall offsets each
COLUMN's drop START by a small bounded delay (fall_stagger_t), so a refilled row
pours in as a left-to-right cascade. Column col waits FALL_STAGGER_MAX·col/7
(FALL_STAGGER_MAX = 0.30) of the fall window, then falls over the remaining
1 - 0.30, feeding that local progress through ease_in_cubic so each column still
accelerates under gravity within its own window. The LAST column lands EXACTLY at
t=1 and every earlier column strictly before it, so NO gem is ever left mid-air at
the segment end — the seam to the next round / settled board stays invisible and
move.final is untouched (tests/anim_plan.sx contiguity stays green;
tests/easing.sx pins fall_stagger_t's f(0)=0, f(1)=1, monotonicity, and the
mid-fall cascade ordering). FALL_ANIM_DUR (0.22 s) is unchanged, so the per-round
cascade-cue snapshots don't churn.
The cleanest tell is a round where several adjacent columns refill the SAME
distance: in lockstep their gems share one height (a flat row); staggered, they
form a diagonal. On seed 1337, M3TE_FX=11 round 4 refills columns 2–7 by one
cell each (its fall window [1.74, 1.96) s); at 1.91 the leading column has just
landed while the trailing column is still ~⅔ cell high, so the top row reads as a
left-to-right staircase instead of a flat band:
# Refilled row pouring in as a staggered cascade: goldens/p17_stagger.png
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.91 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same cascade past the timeline — every gem landed exactly on the model board:
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
The change is render-only — no board.sx model change, and normal play is
byte-identical apart from the fall's per-column timing.
Organic fall — landing squash-&-settle (P17.3)
Each landing gem now flattens wide-and-short on impact then wobbles back to rest
(P15.1's squash_envelope), applied WITHIN the fall so EVERY cascade round bounces,
staggered per column — not only the final whole-move settle as before. render_fall
ages a per-column bounce from each column's touch-down instant (fall_landing_frac ·
FALL_ANIM_DUR), so a gem still in the air is drawn unsquashed and only a gem that
has reached its cell flattens; the squash carries across the fall→clear seam
(render_clear continues the previous round's bounce) and across the final
render_anim → render_gems seam (the settle stamp is back-dated per column so
land_squash resumes exactly where the fall left it — ONE bounce, no double-pop).
land_squash is now LAND_SQUASH_A · squash_envelope(tl/LAND_DUR), so the per-round
fall bounce and the settle bounce are the same single envelope; amplitude is the
tasteful ~13 % peak (LAND_SQUASH_A = 0.18). Durations are unchanged, so the
cascade-cue snapshots don't churn; M3TE_ANIM_TIME=0 still reproduces
goldens/p6_idle_t0.png exactly (a resting board carries no landing stamp).
The visual tell: pin a round's fall just before it ends and the leading columns sit
landed-and-squashed (wide-short) while the trailing columns are still airborne — a
staggered squash wave. On seed 1337, M3TE_FX=11 round 4 (the refill round) at
1.94 shows columns 2–4 landed and flattened with columns 5–7 still pouring in
(every round behaves identically — round 2 [1.02,1.24) at 1.21 and round 3
[1.38,1.60) near 1.58 bounce the same way, so the bounce is NOT limited to the
last settle):
# Staggered landing squash mid-pour (leading cols flattened, trailing airborne):
# goldens/p17_land.png
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.94 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same cascade past the timeline — fully settled, bounce decayed to rest (no golden):
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
All three fall goldens were re-captured in P19.2 to the final, fully-merged fall
motion (gravity accel + per-column stagger + landing squash), each pinned to
foreground a different tell of it: goldens/p17_fall.png (round 3 at 1.51) catches
the columns bunched HIGH under the gravity accel before any has landed (so it carries
no squash); goldens/p17_stagger.png (round 4 at 1.91) catches the left-to-right
staircase as the leading column just touches down; and this golden (round 4 at 1.94)
catches the squash wave once the leading columns have landed-and-flattened while the
trailing ones still pour in. The change is render-only — no board.sx model change,
and a resting board is untouched.
Organic combine — anticipation pop on clear (P18.1)
Matched gems no longer just pop-then-shrink flatly: clear_pop_scale now shapes the
clear as a candy pop in three beats over its local 0..1 — a tiny anticipation
squash dip (a "gather" ~8 % below rest), a snappy overshoot up to ~1.40× via P15.1's
ease_out_back, then an accelerating collapse to nothing (ease_in_quad). The
endpoints stay LOCKED — t==0 → 1.0 (rest) and t==1 → 0.0 (gone) — so the seam to
the model board is clean and M3TE_ANIM_TIME=0 still reproduces the resting board;
the soft particle burst / +points popup (board_fx.sx) compose on top.
tests/gem_pose.sx pins the new envelope (locked rest endpoints, the anticipation
dip below rest, the overshoot above 1, and the strictly monotonic post-peak
collapse). CLEAR_ANIM_DUR (0.14 s) is unchanged, so the per-round cascade-cue
timing snapshots (tests/cascade_rounds.sx / cascade_cue.sx) don't churn.
The pop peaks at clear-phase local t ≈ 0.37; for M3TE_FX=3 (the seed-1337
vertical red 3-match in column 5, rows 0–2) the clear window is [0.16, 0.30) s.
Because P18.2 staggers each matched gem's pop START (see below), the 0.21 capture
no longer catches the three gems together at this shared peak — it catches them at
DIFFERENT points on this same curve, a ripple: the top gem collapsing, the middle
rising toward its overshoot, and the bottom still at rest (full size), all composed
with the burst and "+30" popup:
# Per-gem candy-pop shape, staggered across the match by P18.2 (composed w/ burst):
# goldens/p18_pop.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.21 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same match at exact rest (t=0) — board sits at its resting pose, no pop:
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
goldens/p6_fx_match.png (the P11.1 burst+popup reference, pinned at 0.22) was
refreshed for the new pop shape (and re-refreshed in P18.2 for the stagger — pinned
mid-clear, the committed frame now shows the ripple, not a uniform pop). This step is
the per-gem pop SHAPE only; the per-gem STAGGER of the explosions follows in P18.2.
The change is render-only — no board.sx model change, and normal play is
byte-identical apart from the clear's pop curve.
Organic combine — staggered clear ripple (P18.2)
The matched gems no longer all explode at once: within a clearing round each gem's
pop (and its burst) START is offset by a small BOUNDED delay so the cells detonate
as a RIPPLE. clear_ripple_t(t, u) mirrors fall_stagger_t's (t-delay)/window:
a gem's normalized rank u ∈ [0,1] (its diagonal col+row position within the
round's matched cells, lowest = 0) delays its pop START by CLEAR_STAGGER_MAX·u
(0.45 of the clear window), then plays the P18.1 clear_pop_scale curve over the
remaining 1 − CLEAR_STAGGER_MAX. The rank is normalized PER ROUND (not across the
board) so even a 3-match ripples across the full stagger budget. It is BOUNDED:
every matched gem still reaches local 1 (scale 0, fully cleared) by the clear
segment's end — the last-to-start gem (u=1) lands exactly at t==1 — so no gem is
left mid-pop at the seam to the fall. The bursts (board_fx.sx) carry the SAME
per-gem delay so they ripple in lockstep with the pops. tests/easing.sx pins the
envelope (locked f(0,·)=0 / f(1,·)=1 endpoints, bounded completion by t==1,
monotonicity, and the rank ordering).
INTRA-ROUND VISUAL ONLY: the per-round cascade audio (P10.10) is untouched — one
ascending cue per round at the round's clear, NOT per gem — and the model is
unchanged (same cells cleared, same final board). CLEAR_ANIM_DUR (0.14 s) is
unchanged, so the cascade-cue snapshots (tests/cascade_rounds.sx / cascade_cue.sx)
don't churn and M3TE_ANIM_TIME=0 still reproduces the resting board.
For M3TE_FX=3 (the seed-1337 vertical red 3-match in column 5, rows 0–2) the clear
window is [0.16, 0.30) s; at M3TE_ANIM_TIME=0.22 the ripple is at its clearest —
the TOP gem is collapsing, the MIDDLE is mid-burst, and the BOTTOM is still full-size
(not yet started):
# Staggered clear ripple (top collapsing / middle bursting / bottom not yet):
# goldens/p18_stagger.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Past the timeline — all three cells cleared per the model, board continues:
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
goldens/p18_pop.png (M3TE_FX=3 at 0.21), goldens/p6_fx_match.png (M3TE_FX=3
at 0.22), and goldens/p11_combo_deep.png (M3TE_FX=11 at 0.22) were refreshed
— each was pinned mid-clear, so each now shows the staggered ripple instead of the
prior simultaneous pop.
FPS counter — dev overlay (P20.1)
A small FPS readout for gauging frame cost while tuning the animations. It is a
dev overlay, OFF by default: only the M3TE_FPS env pin turns it on, so default
play and every committed golden stay byte-identical (with M3TE_FPS unset the
rendered scene is unchanged — goldens/p6_idle_t0.png reproduces exactly).
M3TE_FPS=<non-zero>renders the FPS counter in the top-left corner (inside the safe area, clear of the centered notch / Dynamic Island and the centered HUD). The rate is computed from the per-framedelta_timeas an exponential moving average (FPS_DT_SMOOTH = 0.9) so the digits don't jitter. Read once at startup like every otherM3TE_*pin;=0or unset leaves it off. Purely a render overlay — no board / score / move / animation state changes, and it never gates input.delta_timeis real wall-clock even whenM3TE_ANIM_TIMEpins the animation, so the counter stays live while the rest of the scene is frozen.
# FPS counter over the resting board: goldens/p20_fps.png
# (the FPS digits are DYNAMIC — only the FPS text varies run-to-run; the rest of the
# scene is pinned at M3TE_ANIM_TIME=0, byte-identical to goldens/p6_idle_t0.png)
env SIMCTL_CHILD_M3TE_FPS=1 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# On device, pass the same flag through devicectl's environment instead:
# xcrun devicectl device process launch --environment M3TE_FPS=1 ... co.swipelab.m3te
goldens/p20_fps.png is the only golden that captures this overlay; because the FPS
digits are dynamic, compare the FPS text's PRESENCE in the top-left corner, not its
exact value. Every other golden is captured with M3TE_FPS unset and is unaffected.
Move-timeline frame goldens (P6.1 / P6.2)
The board-motion timeline (board_anim.sx: swap slide → matched-gem clear →
collapse/refill fall) and the match-FX layer (board_fx.sx) were first locked by a
set of frame goldens captured off ONE committed move. They use the same M3TE_FX
hook as the FX captures above — M3TE_FX=3 is the seed-1337 vertical red 3-match (a
single round) — each pinned with M3TE_ANIM_TIME to a phase of the swap→clear→fall→
settled timeline. Re-captured in P19.2 so each now shows the organic motion merged
in P16–P18 (swap overshoot, anticipation-pop clear ripple, gravity-accel fall); the
canonical per-feature organic goldens live in the P16/P17/P18 sections above, and
these are the generic timeline frames at their segment midpoints.
# Swap segment midpoint — the swapped gems caught PAST target (ease_out_back
# overshoot): goldens/p6_anim_swap.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.08 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Clear segment midpoint — matched gems mid pop-ripple, composed with the burst and
# "+30" popup. The SAME committed-move frame backs all three of:
# goldens/p6_anim_clear.png == goldens/p6_fx.png == goldens/p6_inputlock_board.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.23 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Fall segment midpoint — the refilled column caught bunched high under gravity
# accel: goldens/p6_anim_fall.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.41 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Settled board past the timeline — FX fully pruned, the move's final board. The
# SAME settled frame backs all three of:
# goldens/p6_anim_after.png == goldens/p5_swap_after.png == goldens/p6_fx_after.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
p6_anim_clear / p6_fx / p6_inputlock_board are one and the same frame — the
timeline's clear segment, the burst/popup FX, and the input-locked in-flight board
are all the same committed-move moment — as are the three settled *_after goldens.
The resting-board goldens — goldens/p4_board.png, goldens/p4_hud.png,
goldens/p9_polish.png, and goldens/p5_swap_before.png (the swap's start pose) —
are the seed-1337 board at rest, captured with no move committed:
# Resting candy board / HUD (no move): p4_board.png, p4_hud.png, p9_polish.png,
# p5_swap_before.png
SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
They match goldens/p6_idle_t0.png over the board+HUD region (the t==0 rest
invariant, tests/gem_pose.sx); only the status-bar clock and the bottom
home-indicator chrome vary per grab, never the board or HUD.
Audio bank (P10) — final model
The SFX bank (audio.sx) is a purely additive layer over iOS System Sound
Services (AudioToolbox, reached by #foreign FFI exactly as the platform reaches
UIKit/Metal). Each cue is loaded once at startup into its own SystemSoundID;
playback is a single C call. The bank only plays when every cue loaded (loaded
gate), so a partial/failed bank mutes rather than playing a stale id. It never
reads or mutates score / board / move state — board_view and the frame loop just
tell it an event happened.
Provenance — the user-provided "Triple Treat SFX" pack
The nine shipped cues are best-fit selections from the user-supplied Triple Treat
SFX pack (Triple_Treat_SFX.zip, ~30 MB, 280 Unity-style files). The pack itself
is not committed — only the selected, converted cues live in assets/audio/;
the source archive is kept outside the repo (in ~/Downloads). Full per-cue notes
are in assets/audio/LICENSE.txt. The shipped game never re-synthesizes anything;
it only loads the finished WAVs.
| cue | game event | pack source (under Triple Treat SFX/) |
|---|---|---|
swap.wav |
every committed swipe (subtle) | Transition SFX/Swipe FX 1-RCM.wav |
match.wav |
first clear of a legal move (pop) | Pop:Bubble SFX/Pop FX 5-RCM.wav |
combo1.wav |
cascade round 1 (dullest) | Match SFX/Match FX 2-RCM.wav (~1.7 kHz) |
combo2.wav |
cascade round 2 | Match SFX/Match FX 4-RCM.wav (~2.1 kHz) |
combo3.wav |
cascade round 3 | Match SFX/Match FX 6-RCM.wav (~3.2 kHz) |
combo4.wav |
cascade round 4 | Match SFX/Match FX 7-RCM.wav (~4.7 kHz) |
combo5.wav |
cascade round ≥5 (brightest) | Match SFX/Match FX 3-RCM.wav (~6.8 kHz) |
win.wav |
level won (stinger) | Success:Power-Up SFX/Power Up FX 1-RCM.wav |
lose.wav |
level lost (stinger) | Fail SFX/Fail FX 2-RCM.wav |
The Match-FX set does not cleanly pitch-ascend, so combo1..5 are ordered by
ascending spectral brightness (centroid 1.68 < 2.09 < 3.18 < 4.70 < 6.77 kHz)
so a deeper cascade reads as more exciting.
Per-round ascending cascade
The cascade plays one ascending cue per cascade round — round 1 → combo1,
round 2 → combo2, … clamped at combo5 — not a single end-of-move sound. Each
cue fires on the move's animation timeline, edge-triggered as that round's clear
begins: the frame loop (main.sx) diffs cascade_rounds_started(elapsed, rounds)
against BoardAnim.cascade_fired and plays the next combo cue per newly-cleared
round (sfx_cascade). The depth→index clamp (cascade_cue_index: depth ≤ 1 → 0,
depth ≥ 5 → 4) is pure + headless, snapshot-tested by tests/cascade_cue.sx and
tests/cascade_rounds.sx. A single match (one round) plays only the match pop —
no combo cue. The swap cue plays for any committed gesture (legal or the reverted
ping-back); a legal move adds the match pop on its first clearing round; the
win/lose stinger fires once, edge-triggered, as the banner comes up.
Format / level spec
Every cue is delivered in exactly the form System Sound Services loads directly: WAVE / mono / 44100 Hz / signed-16-bit PCM. The pack sources are 24-bit / 48 kHz / stereo; each was down-mixed to mono, trimmed to its punchy window, eased in with a short fade, rounded out with a cosine fade-out tail, and peak-normalized to a gentle −15 dBFS (the user rejected aggressive SFX twice). The candy character of the pack is preserved — the cues are not re-synthesized.
Capturing the cue ordering
Every play is NSLog'd, so a playthrough's cue order is readable from the device
log. The deep cascade (M3TE_FX=11, depth-5 on seed 1337) must be launched live
(no M3TE_ANIM_TIME pin) so the timeline advances and the per-round cues fire in
sequence:
# Deep cascade, live — fires combo1..combo5 one per round on the timeline:
SIMCTL_CHILD_M3TE_FX=11 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
xcrun simctl spawn booted log show --last 20s \
--predicate 'eventMessage CONTAINS "[sx] audio"' --style compact
# Win / lose stingers (also live so the banner edge-triggers the cue). Each pinned
# launch passes --terminate-running-process: the M3TE_* pins are read only at
# startup, so relaunching a still-running copy reuses its PID and the new pin is
# ignored — without it the lose launch reuses the win process and only `cue win`
# ever prints. A changed PID between the two launches confirms each pin took:
SIMCTL_CHILD_M3TE_TARGET=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te # cue win
SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te # cue lose
Asset regeneration
The shipped game only loads finished assets; the steps below rebuild them and are
never run at play time. Refresh any affected goldens/ after an art change.
Audio (the SFX bank)
The shipped cues are not synthesized — each is a real Triple Treat SFX pack
clip selected per game event (per-cue source in assets/audio/LICENSE.txt). To
re-derive a cue from the pack, take its source file and apply the same DSP the
shipped bank used: down-mix to mono, trim to the punchy onset window (≤ ~600 ms),
ease in with a short fade, round out with a cosine fade-out tail, peak-normalize to
a gentle −15 dBFS, then re-wrap to the canonical container with afconvert
(afconvert -f WAVE -d LEI16@44100 -c 1 in.wav out.wav → WAVE / LEI16 / 44100 /
mono). The combo ladder is five real Match FX cues ordered by ascending spectral
brightness (P10.9, combo1→combo5); the cascade plays one cue per round on the
animation timeline (P10.10). Verify the result read-only with tools/measure_pitch.py
(it never writes a WAV) — it prints each clip's dominant frequency, and clear.wav's
~784 Hz CC0 reference is the documented baseline. The 30 MB pack and its .meta /
__MACOSX cruft are not committed.
There is intentionally no procedural-synthesis regeneration script: the cues are the curated pack clips, so the original P10.1 note-frequency synthesizer (
tools/synth_audio.py) was removed — running it would have clobbered the curated WAVs with synthetic audio.
Image art
Real art is produced with codex's built-in imagegen tool through codex exec,
then sips-normalized to each asset's exact dimensions and source format:
| asset | dims (px) | format | role |
|---|---|---|---|
assets/board/background.png |
863×1822 | PNG | full-view candy gradient backdrop |
assets/board/cell.png |
128×128 | PNG | one grid cell tile |
assets/gems/gems.png |
768×128 | PNG | 6 gem columns (a gem's UV column = its index) |
assets/fx/particle.png |
256×256 | PNG | soft match-burst sprite (tinted per gem) |
# 1. Generate (codex's imagegen tool, driven non-interactively):
codex exec "use the imagegen tool to render <prompt for the asset>"
# 2. Normalize to the exact per-asset dims + format, e.g. the gem sheet:
sips -z 128 768 --setProperty format png <generated>.png --out assets/gems/gems.png
After any art change, re-capture the affected goldens with the deterministic hooks
above (M3TE_ANIM_TIME / M3TE_SELECT / M3TE_FX / M3TE_BADSWAP / M3TE_TARGET /
M3TE_MOVE_LIMIT / M3TE_RESTART / M3TE_FPS) and state per golden whether it was refreshed,
left unchanged, or removed.