# 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), and screenshot:
xcrun simctl install booted sx-out/ios/M3te.app
xcrun simctl launch booted co.swipelab.m3te
xcrun simctl io booted screenshot /tmp/m3te.png
```

The screenshot should match `goldens/p6_idle_t0.png` (the resting candy board),
modulo the status-bar clock — pixel-exact equality is not required; compare the
board + HUD region, not the top status strip. A tap selects a cell; a swipe
between two adjacent gems commits a swap (legal swaps cascade + score, illegal
ones ping back). The deterministic capture hooks below pin a chosen scene so each
golden reproduces from a clean checkout.

> The earlier `p0_*` goldens (a pre-board orange quad over a blue clear, from the
> P0 foundation slice) were removed in P13.1: the app no longer renders that
> scene, so they could only ever mislead.

### Deterministic animation capture (P6.3)

The per-gem idle loop (`gem_anim.sx`) is always-on, so a plain screenshot is
time-dependent. Two environment variables pin the visual state so the board can
be captured reproducibly. The simulator forwards any `SIMCTL_CHILD_*` variable to
the launched app, so prefix them on the `simctl launch`:

- `M3TE_ANIM_TIME=<seconds>` freezes the animation clock at that phase. **`t=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.

## 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_TARGET` /
`M3TE_MOVE_LIMIT` / `M3TE_RESTART`) and state per golden whether it was refreshed,
left unchanged, or removed.
