Minesweeper
Not a form. Not a signup flow. This is a minefield â 64 cells modeled as Umpire fields, game state as conditions, and a generated reads table for mine topology, numbers, and reveal previews. Same engine, different domain.
Full debug mode enabled so you can cheat. But you wouldnât do that, would you?
Minesweeper
{
"c_0_0": {
"availability": {
"enabled": true,
"fair": true,
"required": false,
"reason": null,
"reasons": []
},
"reads": {
"adjacentMines": null,
"canInteract": true,
"display": {
"kind": "hidden"
},
"probeCascade": [],
"probeWouldExplode": false
}
}
}What this demonstrates
Section titled âWhat this demonstratesâUmpireâs field-availability model isnât tied to forms. Any state that fits a plain object with interdependent options can be modeled as fields + rules. Minesweeper maps naturally:
| Minesweeper concept | Umpire concept |
|---|---|
| Cell on the board | Field |
| Hidden / revealed / flagged | Field value |
| âCan I click this?â | Availability (enabled) |
| âWhy canât I click this?â | Reason (GAME_OVER, ALREADY_REVEALED, FLAGGED) |
| Game status, flag mode | Conditions |
| Mine layout, adjacency numbers, reveal preview | reads |
| Cascade reveal changes values | check() re-evaluates after new values |
| Flagged cell gets revealed by cascade | play() calls foul on the stale flag |
Reason as enum
Section titled âReason as enumâThe reason field doesnât have to be prose. Here, named reads feed three machine-readable gate reasons into Umpire:
const selectInput = (values, conditions) => buildMinesweeperReadInput(board, values, conditions);
enabledWhenRead(key, "gameActive", minesweeperReads, { reason: "GAME_OVER", selectInput,});
enabledWhenRead(key, `notRevealed:${key}`, minesweeperReads, { reason: "ALREADY_REVEALED", selectInput,});The rendering layer reads reasons (plural) and decides what to show:
const cell = availability[cellKey(x, y)]
if (cell.reasons.includes('GAME_OVER')) // dim everythingif (cell.reasons.includes('ALREADY_REVEALED')) // show number or mineif (cell.reasons.includes('FLAGGED')) // show flag iconMultiple reasons can stack. A revealed cell during game over has both ALREADY_REVEALED and GAME_OVER in its reasons[] array.
The Umpire model
Section titled âThe Umpire modelâThree read-backed rules per cell. The field model stays tiny; the board derivations live in reads.
const fields = Object.fromEntries( cellKeys.map((key) => [key, { default: undefined }]),);// 64 fields for an 8Ă8 boardtype GameConditions = { gameStatus: "idle" | "playing" | "won" | "lost"; flagMode: boolean;};const rules = cellKeys.flatMap((key) => [ enabledWhenRead(key, "gameActive", minesweeperReads, { reason: "GAME_OVER", selectInput, }),
enabledWhenRead(key, `notRevealed:${key}`, minesweeperReads, { reason: "ALREADY_REVEALED", selectInput, }),
enabledWhenRead(key, `notFlagBlocked:${key}`, minesweeperReads, { reason: "FLAGGED", selectInput, }),]);const ump = umpire({ fields, rules });
const readInspection = minesweeperReads.inspect( buildMinesweeperReadInput(activeBoard, values, conditions, { probeKey }),);
const { check: availability } = useUmpireWithDevtools( "minesweeper", ump, values, conditions, { reads: readInspection },);// availability.c_3_4 â { enabled: false, reasons: ['ALREADY_REVEALED'] }192 rules. Zero orchestration code. On the 8Ă8 demo board, Umpire still only evaluates that small rule layer on each check() call. The extra reads work in this docs demo is separate and intentionally adds more computation for inspection.
What Umpire does and doesnât do here
Section titled âWhat Umpire does and doesnât do hereâUmpire answers: âWhat can the player interact with right now, and why not?â
Reads answers: âWhat does the board derive from the current values and mine layout?â
It does not run the game. Mine placement, adjacency computation, and the cascade flood-fill are all pure functions in a separate game engine. When the player clicks a cell:
- Game engine reveals cells (cascade if zero-adjacent)
- Game engine checks win/loss
- Reads derive board facts like numbers, display state, and preview cascades
- New values + conditions flow into
ump.check() - Umpire returns updated availability for all 64 cells
- UI renders
This separation is intentional. The game engine owns transitions, reads owns derived board facts, and Umpire owns the availability graph. The result is still a small game engine, but the clickable rules and the inspectable board math now share one declarative layer.
Things to try
Section titled âThings to tryâ- Click a corner cell â if it has zero adjacent mines, watch the cascade reveal propagate. Every revealed cell immediately shows
ALREADY_REVEALEDin its availability. - Flag a cell, then reveal its neighbors â if a cascade covers the flagged cell,
play()calls foul on the stale flag value. The flag was valid before, but now the cell is revealed and the flag is out of bounds. - Hit a mine â every cell on the board disables simultaneously with reason
GAME_OVER. Cells that were already revealed stack both reasons. - Win the game â reveal all safe cells. Same result as game over: all cells disable, but with
gameStatus: 'won'as the condition. - Toggle flag mode â flagged cells are disabled in dig mode (reason:
FLAGGED) but enabled in flag mode (so you can unflag). TheflagModecondition controls which gate passes.
The test suite includes a 30Ă16 expert board â 480 fields, 1,440 rules. Construction takes 2ms. check() takes 1ms. Umpireâs topological evaluation is O(n) per field, so it scales linearly with board size.