2026-06-05 20:08:43 +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 (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=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.
# 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. 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 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.

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