Files
m3te/README.md
swipelab e697e40297 FX1: README capture recipes — add render-readiness wait before screenshot
Screenshotting immediately after 'simctl launch' grabs the blank white
launch screen, not the rendered board. Add an explicit 'sleep 2' between
launch and screenshot in the main capture recipe, and state a once-stated
render-readiness rule (near the first recipe) that every pinned-launch
section below references, so a clean-checkout reader reproduces the board.
Docs-only: no .sx/asset/golden change.
2026-06-06 14:40:02 +03:00

694 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
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.
```bash
# 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.
```bash
# 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.
```bash
# 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).
```bash
# 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`:
```bash
# 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:
```bash
# 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):
```bash
# 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)`:
```bash
# 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):
```bash
# 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:
```bash
# 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):
```bash
# 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:
```bash
# 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):
```bash
# 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.
```bash
# 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.
```bash
# 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:
```bash
# 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:
```bash
# 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) |
```bash
# 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.