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>.sx that has a tests/expected/<name>.exit marker; tests/test.sx (the expect assert 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 launch command (it swaps a different scene in for the launch line above); capture it exactly as here — after the launch, wait for the first rendered frame (sleep 2, or poll the screenshot) before xcrun 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=0 is the resting board — every gem sits at its static pose, so the pre-P6.3 goldens reproduce unchanged. A larger t (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. A simctl launch against an already-running copy just foregrounds the existing process (the launch prints the same PID) and the new SIMCTL_CHILD_* value is ignored. So every multi-launch recipe below passes --terminate-running-process on each pinned launch — it kills the running copy first, so the fresh process re-reads the new pin. (xcrun simctl terminate booted co.swipelab.m3te before 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. 0 makes the fresh board read won immediately (score 0 ≥ goal 0).
  • M3TE_MOVE_LIMIT=<n> overrides the move budget. 0 makes it read lost (budget spent below the goal).
  • M3TE_RESTART=<non-zero> runs board.restart after the overrides, capturing the fresh in_progress board 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; =1 is the first) through the normal plan_and_commit path, then begins the move timeline + its burst/popup FX. While M3TE_ANIM_TIME is set the move/FX timelines are pinned at that phase (the frame loop holds them frozen), so the burst and floating +points render identically every run. A larger M3TE_ANIM_TIME lands 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 of legal_swaps, enumerated in the SAME stable row-major order (right-before-down), 1-based + clamped — through the normal plan_and_commit path (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 27 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 24 landed and flattened with columns 57 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 02) 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 02) 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-frame delta_time as an exponential moving average (FPS_DT_SMOOTH = 0.9) so the digits don't jitter. Read once at startup like every other M3TE_* pin; =0 or unset leaves it off. Purely a render overlay — no board / score / move / animation state changes, and it never gates input. delta_time is real wall-clock even when M3TE_ANIM_TIME pins 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 P16P18 (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 combo5not 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, combo1combo5); 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.

Description
No description provided
Readme 208 MiB
Languages
Shell 50%
Python 50%