Key the painted checkerboard out of the provided gem art via an
edge-connected flood fill (8-conn, light+desaturated predicate) so the
gems' interior specular highlights are preserved, then slice the 6 gems
by alpha bbox, premultiply-resize each to fit 108x108, and center one
per 128x128 transparent cell in index order (red, orange, yellow,
green, blue, purple).
Extend the pure-sx board model with the model-level pieces P5 (input) and
P7 (turn/goal loop) will call:
- Turn accounting on Board: `moves_made` + configurable `move_limit`
(DEFAULT_MOVE_LIMIT), with `moves_remaining()` derived from the two so
the counters can't drift. `init` resets them.
- Special-match flagging: `count_specials` tallies a detection round's
maximal runs of length exactly 4 and length 5+, surfaced over a whole
settle as Cascade.len4 / len5_plus (+ had_len4 / had_len5_plus). A hook
for future special gems — detection only, no gem behavior.
- `commit_swap`: the single player-move entry point. Legal swap → apply,
resolve (scoring accrues), spend one move; illegal → revert, no move
spent. Returns a PlayerMove with the settle's payout + special flags.
Adds tests/turn.sx (+ golden) asserting: legal swap decrements the move
counter by exactly 1 and accrues score; illegal swap leaves counter,
score and board untouched; len4/len5+/len3 rounds set the documented
flags. Existing goldens unchanged; ios-sim build compiles.
Scale each cascade round's base points by combo_multiplier(round) = the
1-based round index (round 1 x1, round 2 x2, ...), so deeper chains pay
out more. resolve now reads score_round before each clear, accumulates
score_round * combo_multiplier(round) into Board.score, and reports the
settle's payout as the new Cascade.awarded field. A depth-1 settle scores
exactly its base (x1, no bonus); any multi-round chain strictly exceeds
the same clears scored flat.
resolve_step keeps its signature (no scoring), so cascade.sx and its
golden are unchanged; score_round/add_round_score are untouched, so
score.sx is unchanged. New tests/combo.sx golden locks exact cumulative
scores for a single-round clear (30), the P2.4 cascade board (flat 60 ->
mult 90), and a controlled 3-round chain (flat 90 -> mult 180), printing
per-round base/multiplier/points so the golden self-explains.
Add a run-enumeration path and base scoring to the pure-sx model, scoring
one resolution round's clears purely by maximal-run length.
- find_runs(board) enumerates each maximal H/V run (len >= 3) with its
length, parallel to find_matches/MatchMask (left untouched so every
clear/cascade caller is unaffected).
- run_score(len): length 3 -> 30, 4 -> 60, 5+ -> 100 (named constants).
- score_round(board): sum of run_score over the current runs (read-only,
must be called before the round's clear). L/T rule: each maximal run
scores independently by its own length, so an overlapping L/T scores
horizontal + vertical (shared corner counts toward both runs).
- Board gains a running `score` field (init zeroes it) and add_round_score
accumulates a round's base points into it; the cross-round combo
multiplier off Cascade.depth is left for P3.2.
- tests/score.sx golden over hand-crafted single-round boards asserts exact
points for len-3/4/5 runs, disjoint runs (sum), an overlapping L/T, and a
no-match board (0).
Add the settle loop a swap triggers: resolve(board) runs rounds of
detect -> clear -> collapse -> refill until a round finds no match,
returning a Cascade { depth, cleared } so P3 can read per-round
cleared-cell counts and the combo-driving depth. resolve_step exposes
one round. Termination follows from eventually reaching no-match; no
artificial round cap.
tests/cascade.sx: a fixed-seed hand-crafted board where clearing the
initial BBB lets gravity pack col 0 into a fresh vertical RRR, so the
loop chains two rounds; snapshots each per-round board and the final
depth, asserts the final board is stable and that public resolve
reproduces the manual loop, plus a depth-0 control on an unchanged
checkerboard. Locked in tests/expected/cascade.{stdout,exit}.
Add refill to the board model: every .empty hole is filled with a fresh gem
drawn from the board's OWN seeded RNG, so refills are fully reproducible for a
seed and continue the stream rather than reseeding.
- Board now owns its RNG state (rng: Rng); init seeds and draws from it, so
draws after init/clears thread deterministically. The init draw sequence is
unchanged, so board_init's golden is byte-identical.
- refill(board) fills all holes in row-major order wherever they sit (does not
assume collapse ran) and makes no attempt to avoid matches — a refill may
create new runs, which drives the P2.4 cascade.
- tests/refill.sx (fixed seed) runs clear -> collapse -> refill, locks the
staged dump as a golden, and asserts: zero empties after refill; each hole
holds the next seeded-stream gem (replayed from the pre-refill state); drawn
gems vary (not a constant); same start+seed -> identical board; a second
refill of the same holes draws new gems (RNG threads, no reseed).
Add collapse(board): per-column gravity that packs gems contiguously at
the bottom (preserving top-to-bottom order) and bubbles holes to the top,
with no horizontal movement. Returns whether any gem fell, for the P2.4
cascade. Does not refill (that is P2.3).
tests/collapse.sx snapshots gravity over hand-crafted boards exercising
holes in the middle / at the bottom, a full column of holes, a column
with none, a lone gem, an alternating stack, and an already-settled board
(idempotency). Asserts, independently of the dump, that each column's gems
end packed at the bottom in original order with holes above, plus the
exact moved flag. Golden locked in tests/expected/collapse.{stdout,exit}.
find_matches walked maximal same-type spans without excluding `.empty`, so a
line of 3+ holes (left by a prior clear) was reported as a match. After any
vertical 3-clear or L/T clear the board carries such a line, so find_matches /
clear_matches returned non-zero on a board with no real gem match — which would
prevent the P2.4 cascade from ever stabilising.
Fix at the source: a run is only a match if its gem type is not `.empty`. Holes
already break runs of real gems (a hole differs from every gem), so this is the
only change needed and every caller (P1.3 legality, P2.4 cascade) is now correct.
Regression in tests/clear.sx: a holes-only board yields zero matches and
clear_matches 0, and re-clearing a holed board returns 0. Other goldens are
unchanged (no board without holes is affected).
Add the first resolution-pipeline step to the headless board model.
- Introduce an `empty` hole sentinel on the Gem enum (ordinal 6, outside
GEM_COUNT so the RNG/pick_gem never draw it). board_dump renders holes as
EMPTY_CHAR ('.') via a single branch in gem_char, leaving boards without
holes byte-identical to before (existing goldens unchanged).
- clear_cells(board, mask): set every matched cell to `.empty`, leave all
others untouched, return the count cleared.
- clear_matches(board): detect+clear in one call; returns 0 (board unchanged)
when there are no matches.
No gravity or refill yet (P2.2 / P2.3).
tests/clear.sx applies detect->clear to hand-crafted boards (single
horizontal/vertical runs, disjoint runs, an overlapping L/T whose shared cell
clears once, and a no-match checkerboard), snapshots before/after, and asserts
matched cells became holes, non-matched cells are unchanged, and the cleared
count is exact. Locked as tests/expected/clear.{stdout,exit}.
Add swap + legality to the board model:
- swap(board, a, b): in-place, self-inverse cell exchange (trial then revert).
- adjacent(a, b): orthogonal-adjacency predicate (diagonal/gap = false).
- swap_legal(board, a, b): legal iff adjacent AND, after the trial swap, either
swapped cell participates in a 3+ match (reuses find_matches); leaves the
board unchanged. Non-adjacent/diagonal rejected before any match check.
- Cell/Swap structs + legal_swaps(board): all currently-legal swaps in a stable
row-major, right-before-down order; dump_swaps for deterministic snapshotting.
tests/swap_legality.sx asserts the predicate over hand-crafted boards (legal
3-run, no-match, non-adjacent, diagonal, only-the-other-gem-matches) and the
non-mutating revert; locks legal_swaps over the seeded board as a golden.
Add a pure-sx match detector to the board model: `find_matches` walks each
row and column once in maximal same-type spans and marks every cell in a run
of length >= 3 into a `MatchMask` (a per-cell membership set mirroring
Board.cells). Overlapping shapes (L / T where a horizontal and vertical run
share a cell) collapse to the union automatically. `dump_matches` renders the
set deterministically: matched cells show their gem char, others '.'.
Detection only — no clear/collapse/refill (that is P2.1).
tests/match_detect.sx exercises hand-crafted boards (built explicitly on a
run-free checkerboard, no seeded init): a horizontal 3-run, a vertical 3-run,
multiple disjoint runs, length-4 and length-5 runs, intersecting L and T
shapes (shared cell counted once), and a no-match board. Output is locked as
tests/expected/match_detect.stdout (+ .exit) and asserts matched-cell counts.
Add board.sx, the headless Phase-1 match-3 core:
- Gem enum (6 types, ordinal 0..5) + single-char dump alphabet.
- Rng: a 32-bit LCG carried in s64, masked to 32 bits each step, so the
stream is host-width independent and valid for any seed.
- Board (8x8, row-major) with idx/at/set accessors and a seeded init that
fills row-major, excluding any gem that would complete a 3-in-a-row with
the two cells to the left or above — so the result has zero pre-existing
matches. Single RNG draw per cell, always terminates.
- board_dump: deterministic one-row-per-line textual snapshot.
tests/board_init.sx seeds with a fixed seed, dumps the board, and asserts
zero horizontal/vertical 3-in-a-row runs via an independent scan. Output and
exit code are locked as goldens. App ios-sim build is unaffected (main.sx
does not import the model yet).
Add a real logic-test gate so future pure-sx game logic fails the build on a
bad assertion:
- tests/test.sx: `expect(cond, msg)` assert helper — prints a greppable
`FAIL <file>:<line>: <msg>` and exits non-zero via process.exit on failure.
- tools/run_tests.sh: snapshot runner mirroring sx/tests/run_examples.sh; runs
each tests/<name>.sx and diffs stdout + exit code against tests/expected/.
Exits 0 iff all tests pass.
- tests/arith.sx (+ expected snapshots): seed passing sanity test.
- README.md: document both halves of the gate — logic runner and the
reproducible ios-sim build/launch sequence (with device discovery).
Prove the input path end-to-end. Poll platform events in the frame loop
and, on a mouse_down (UIKit touchesBegan, mapped to an Event in uikit.sx),
toggle the centered quad between orange and green. The toggle marks the
vertex buffer dirty; the next frame re-uploads the active color palette
before drawing. Flip on the press only — a tap also delivers mouse_up
(touchesEnded), so toggling on both would net to no visible change.
goldens/p0_input_before.png (orange quad) and goldens/p0_input_after.png
(green quad) bracket a real tap injected at the device-screen center
(201,437 pt) via `idb ui tap` on the booted iOS 26.0 simulator.
Prove the render path end-to-end: bring up UIKit + Metal (already
scaffolded), clear to a solid blue, and draw one centered orange quad via
the GPU protocol's clear+quad path — an MSL pass-through pipeline plus a
6-vertex (2-triangle) NDC buffer, created lazily once the MTLDevice exists.
Geometry is NDC [-0.5, 0.5]² so the quad is the central 50%x50% of the
drawable regardless of device resolution, keeping the screenshot golden
unambiguous. Background (0.10, 0.20, 0.55), quad (1.0, 0.6, 0.0).
goldens/p0_quad.png is the first screenshot golden, captured from the
booted iOS 26.0 simulator after install + launch.
Stand up build.sx (macos + ios/ios-sim targets, bundle id
co.swipelab.m3te, output sx-out/ios/M3te.app, assets dir) and a minimal
main.sx that brings up the platform (UIKit+Metal on iOS, SDL3+GL on
macOS) and renders a solid-clear frame. Add assets/ and goldens/
directories and a .gitignore for build artifacts.
Modeled on game/build.sx and game/main.sx; modules resolve from the
compiler binary with no -L flag.