# 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/.sx` that has a `tests/expected/.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 (blue background + a centered orange quad). ```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/p0_quad.png` (a centered orange quad over a blue clear), modulo the status-bar clock — pixel-exact equality is not required. A tap on the quad flips its color (orange ↔ green); see `goldens/p0_input_before.png` / `goldens/p0_input_after.png`. ### 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=` 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=` (= `row*8 + col`) force-selects a cell at startup, so the selection highlight + pop can be captured without a tap. ```bash # Resting board (idle at rest): goldens/p6_idle_t0.png SIMCTL_CHILD_M3TE_ANIM_TIME=0 xcrun simctl launch booted co.swipelab.m3te # Mid-breath idle: goldens/p6_idle_mid.png SIMCTL_CHILD_M3TE_ANIM_TIME=1.0 xcrun simctl launch 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 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=` overrides the per-level score goal. `0` makes the fresh board read **won** immediately (`score 0 ≥ goal 0`). - `M3TE_MOVE_LIMIT=` overrides the move budget. `0` makes it read **lost** (budget spent below the goal). - `M3TE_RESTART=` 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 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 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 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=` 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 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 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 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 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 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 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 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.