sugarcraft / candy-mines
Minesweeper TUI — port of maxpaulus43/go-sweep on the SugarCraft stack.
Requires
- php: >=8.3
- sugarcraft/candy-buffer: dev-master
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-mouse: dev-master
- sugarcraft/candy-sprinkles: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.0
- sugarcraft/candy-testing: dev-master
This package is auto-updated.
Last update: 2026-07-13 00:24:33 UTC
README
CandyMines
Minesweeper on the SugarCraft stack — port of maxpaulus43/go-sweep. Customisable board, recursive flood-fill, win / lose detection, vim-style movement.
Difficulty presets are available via Game::withDifficulty(), passing a Difficulty case — Difficulty::EASY (9×9, 10 mines), Difficulty::MEDIUM (16×16, 40 mines), Difficulty::EXPERT (30×16, 99 mines).
Run it
composer install ./bin/candy-mines # default 10×10, 12 mines ./bin/candy-mines [width] [height] [mines] # custom board (validated: 2–50 per side, 1..w×h−9 mines) ./bin/candy-mines --easy | --medium | --expert
Custom board arguments are validated through CustomDifficulty::fromInput(): each side must be 2–50 and the mine count must leave a safe 3×3 first-click area (1 ≤ mines ≤ width × height − 9). Out-of-range values exit with an error rather than starting a broken game.
Stats persistence is opt-in. Set CANDY_MINES_STATS=/path/to/stats.json and each completed game's outcome (games, wins, best time per preset) is recorded there via DifficultyStats, surviving across sessions. Without the env var the game stays entirely in-memory.
Keys
| Key | Action |
|---|---|
↑/↓/←/→ or hjkl |
Move cursor |
Space / Enter |
Reveal cell |
f |
Toggle flag |
c / middle-click |
Chord — reveal safe neighbors |
r |
Restart with new mines |
q / Esc |
Quit |
Architecture
Five pure-state classes plus the runtime Model, renderer, UI helper, and persistence:
| File | Role |
|---|---|
Cell |
Value object — mine / revealed / flagged / adjacent count |
Board |
The grid + every transition (reveal, flag, flood-fill, chord). Win detection is O(1) via revealedCount counter. Serialises to versioned JSON for mid-game save/load. |
Game (Model) |
Cursor + key routing + restart + win/lose gate + sub-second timer (microtime(true)). Records the result into Stats on the win/lose transition; persists via DifficultyStats when an opt-in stats path is configured. |
Stats |
Immutable difficulty stats — games, wins, best time per preset |
DifficultyStats |
Atomic JSON persistence wrapper (tmp+rename, Homestead pattern) |
Ui/CustomDifficulty |
Validated custom board dimensions — rows (2–50), cols (2–50), mines (1 to rows×cols−9). Throws i18n-aware InvalidArgumentException on constraint violation. |
Renderer |
Pure view function. CandySprinkles Style + Border::rounded() |
The first reveal is always safe — mines are placed only after click 1, with the clicked cell's 3×3 neighbourhood excluded so the player gets a non-trivial flood-fill on every game.
Chord click (c / middle-click): when a revealed number has exactly the right number of flagged neighbours, chord-clicking it safely reveals all remaining neighbours. This mirrors the standard left+right simultaneous press in classic minesweeper.
O(1) win detection: Board::isWon() compares revealedCount (the number of non-mine cells revealed) against width × height − mineCount. Win detection is constant-time regardless of board size — no full-grid scan on every move.
Save / restore mid-game: Board::serialize() produces a versioned JSON payload ({v:1, w, h, m, p, e, r, c}) covering every cell and board state. Board::unserialize() reconstructs an identical Board instance so sessions can be suspended and resumed.
Demos
Gameplay
Flagging
Shared foundations
The minefield renderer builds output via candy-buffer — each cell is zone-tagged via Mark::zone("cell:$row:$col", $glyph) so Scanner::hit($x, $y) maps directly to a cell address. Renderer::resolveClick() bridges mouse coordinates to row/col. Snapshot tests via candy-testing pin canonical game states (first-click safe area, cascade reveal, win overlay).
Test
composer install vendor/bin/phpunit

