Refine the same drop-in bank toward Candy-Crush candy/jelly character; engine (audio.sx) untouched, same filenames/format. - swap: freesound CC0 "Pop 01" (LilMati #506546) — soft light blip. - match: freesound CC0 "Cartoon Pop" (Mish7913 #741368) — bright juicy candy pop. - combo1..5: VCSL CC0 Marimba F5, real-resample pitch-laddered to a C-major pentatonic (C5 D5 E5 G5 A5) — warm/sweet mallet sparkle, not glassy; ascending. - win: VCSL CC0 Glockenspiel C5/G5/C6 sequenced into a sugary ascending bell fanfare. - lose: VCSL CC0 Marimba B4->G4 descending — soft gentle "aww". All real free CC0 assets (NOT ripped from Candy Crush); provenance + license per cue in LICENSE.txt. Mono/44100/Int16, <=580 ms, peak <=-9 dBFS (lose -12). Combos ascend (1045<1173<1317<1566<1758 Hz). Gate green; all 9 cues load on sim.
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>.sxthat has atests/expected/<name>.exitmarker;tests/test.sx(theexpectassert 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).
# 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=<seconds>freezes the animation clock at that phase.t=0is the resting board — every gem sits at its static pose, so the pre-P6.3 goldens reproduce unchanged. A largert(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.
# 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=<n>overrides the per-level score goal.0makes the fresh board read won immediately (score 0 ≥ goal 0).M3TE_MOVE_LIMIT=<n>overrides the move budget.0makes it read lost (budget spent below the goal).M3TE_RESTART=<non-zero>runsboard.restartafter the overrides, capturing the freshin_progressboard 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 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=<n>commits the n-th currently-legal swap (1-based, clamped;=1is the first) through the normalplan_and_commitpath, then begins the move timeline + its burst/popup FX. WhileM3TE_ANIM_TIMEis set the move/FX timelines are pinned at that phase (the frame loop holds them frozen), so the burst and floating+pointsrender identically every run. A largerM3TE_ANIM_TIMElands 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 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:
# 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:
# 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.