Mechanical sweep of all .sx sources, plan docs, and tests/expected snapshots for the sx language rename (s8/s16/s32/s64 -> i8/i16/i32/i64). Verified: tools/run_tests.sh 23/23. Note: the ios-sim build has 2 pre-existing 'restart' dot-call errors from the sx opt-in UFCS change (sx a47ea14) — independent of this rename (present pre-sweep); migrated in the follow-up commit.
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), wait for the board to render, then
# screenshot. The wait is REQUIRED: screenshotting immediately after `launch` grabs
# the blank white launch screen, before the scene has drawn. ~2 s is ample; poll the
# screenshot if you want it tighter.
xcrun simctl install booted sx-out/ios/M3te.app
xcrun simctl launch booted co.swipelab.m3te
sleep 2
xcrun simctl io booted screenshot /tmp/m3te.png
Render-readiness wait — applies to every sim capture recipe in this README. Each deterministic section below shows only the pinned
launchcommand (it swaps a different scene in for thelaunchline above); capture it exactly as here — after the launch, wait for the first rendered frame (sleep 2, or poll the screenshot) beforexcrun simctl io booted screenshot. Skipping the wait captures the white launch screen, not the board.
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.