2026-06-06 10:50:00 +03:00

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), 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.

# 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).

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

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