Skip to content

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?

Playable example

Minesweeper

64 fields / 192 read-backed rules
Playing
Mines10
Coords(0, 0)
Valuehidden
Boardunseeded
Previewunseeded
availability + readsuseUmpire() + createReads()
{
  "c_0_0": {
    "availability": {
      "enabled": true,
      "fair": true,
      "required": false,
      "reason": null,
      "reasons": []
    },
    "reads": {
      "adjacentMines": null,
      "canInteract": true,
      "display": {
        "kind": "hidden"
      },
      "probeCascade": [],
      "probeWouldExplode": false
    }
  }
}

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 conceptUmpire concept
Cell on the boardField
Hidden / revealed / flaggedField value
”Can I click this?”Availability (enabled)
“Why can’t I click this?”Reason (GAME_OVER, ALREADY_REVEALED, FLAGGED)
Game status, flag modeConditions
Mine layout, adjacency numbers, reveal previewreads
Cascade reveal changes valuescheck() re-evaluates after new values
Flagged cell gets revealed by cascadeplay() calls foul on the stale flag

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 everything
if (cell.reasons.includes('ALREADY_REVEALED')) // show number or mine
if (cell.reasons.includes('FLAGGED')) // show flag icon

Multiple reasons can stack. A revealed cell during game over has both ALREADY_REVEALED and GAME_OVER in its reasons[] array.

Three read-backed rules per cell. The field model stays tiny; the board derivations live in reads.

Fields — one per cell, generated from the board
const fields = Object.fromEntries(
cellKeys.map((key) => [key, { default: undefined }]),
);
// 64 fields for an 8×8 board
Conditions — game state lives outside the field graph
type GameConditions = {
gameStatus: "idle" | "playing" | "won" | "lost";
flagMode: boolean;
};
Rules — three read-backed gates per cell, 192 rules total
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,
}),
]);
That's the whole integration
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.

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:

  1. Game engine reveals cells (cascade if zero-adjacent)
  2. Game engine checks win/loss
  3. Reads derive board facts like numbers, display state, and preview cascades
  4. New values + conditions flow into ump.check()
  5. Umpire returns updated availability for all 64 cells
  6. 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.

  1. Click a corner cell — if it has zero adjacent mines, watch the cascade reveal propagate. Every revealed cell immediately shows ALREADY_REVEALED in its availability.
  2. 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.
  3. Hit a mine — every cell on the board disables simultaneously with reason GAME_OVER. Cells that were already revealed stack both reasons.
  4. Win the game — reveal all safe cells. Same result as game over: all cells disable, but with gameStatus: 'won' as the condition.
  5. Toggle flag mode — flagged cells are disabled in dig mode (reason: FLAGGED) but enabled in flag mode (so you can unflag). The flagMode condition 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.