MineLayer ✸ Reversed Minesweeper — Game Design Spec

Type: Product Requirements Document Status: Reflects implemented game (v1, retroactively spec'd) Series: Do villain things (predecessor of ghost-squad, saboteur) Deliverable target: single offline HTML file

1. Background and Goals

Concept. MineLayer is a reversed Minesweeper: instead of clicking open squares to find mines and flag them, the player lays mines on the squares where they deduce one belongs. The board opens pre-revealed — a fixed set of numbered clues is already on the field — and every field is generated to have exactly one satisfying mine placement. Logic alone wins; no guessing is ever required.

Why this game, why first. The original Minesweeper is one of the most recognizable games ever shipped; the inversion reads in a single screenshot (you place mines where flags would go). The "unique solution" guarantee is the structural hook that makes the reversal fair: in the original, the player infers safe squares from clue numbers; here, the player infers mine squares from the same numbers, against the same constraint system. The generative puzzle work (a unique-solution mine-layout with a minimized clue set) is what turns Minesweeper's deduction into a standalone, self-validating puzzle instead of a guessing risk.

Series origin. MineLayer is the first title in the reversed-games series. It was originally built without the spec-then-test pipeline that its siblings (ghost-squad, saboteur) later adopted; this document retroactively captures the implemented design so all three titles share the same documentation standard, and a deterministic test suite (tests/minelayer/) was added alongside it to find latent bugs.

Goals (observable outcomes).

2. Product Contract

ElementDefinition
Primary userA casual player in a browser; solo
TriggerOpening the HTML file; the game boots straight into a fresh field — no title screen, no external resources
Core jobLay every mine on the correct square so all clue numbers are satisfied
Completion (round won)The count of correctly laid mines equals the field's mine total — the smiley puts on sunglasses
Failure (round lost)A mine is laid on a safe square — instant game over; the smiley dies, the field reveals every mine
Surrender (no result)Reveal solution from the Game menu — the field is opened, nothing is recorded
Trust boundariesFully local: no network calls, no external assets, no accounts; statistics only in localStorage (capped at 200 entries)

3. Scope

P0 — required for the complete game P0

P1 — valuable, not blocking P1

Explicitly excluded OUT

4. Game Model

4.1 Objects

ObjectCanonical definition
FieldThe R×C grid of cells. Size and density come from the active difficulty (SR-2).
CellOne tile. State = {mine, clue, n, mark}: mine = a real mine lives here; clue = this cell is pre-revealed as a number; n = how many of its 8 neighbours are mines (only meaningful when clue); mark = the player's annotation (SR-1).
ClueA pre-revealed numbered cell. The number is the count of neighbouring mines; the player never reveals a cell interactively (unlike the original). Clue and mine are mutually exclusive on a cell.
MarkThe player's per-cell annotation: NONE, MINE (a laid mine), SAFE (a flag — "safe, no mine here"), QUESTION (uncertain). See SR-1.
Difficulty tierOne of Beginner / Intermediate / Expert. Tunes board size, mine density, clue probability, and the generator's difficulty thresholds (SR-2).
SolverThe pure function countSolutions(cells, R, C, limit): counts mine placements on unknown cells that satisfy every clue, up to limit. Used by the generator to verify uniqueness (SR-3) and never at runtime.
SmileyThe new-game button and round-state indicator: smile (ready/playing), surprise (mid-click), dead (lost), cool (won).

4.2 Round state machine (canonical)

READY (fresh field, timer idle) │ ▼ (first mine laid) PLAY ────────────────────────────▶ all mines placed correctly ──▶ WON │ ▲ │ │ ▼ mine on a safe square fail() ends round DEAD (smiley dies, field reveals) ▲ │ │ └── (Game menu → Reveal solution at any time) ──▶ GAVE_UP (field opened, no record) F2 / smiley click ──▶ READY (fresh field, timer reset)

Only the four states above are legal. There is no "paused" state — the timer is a passive 1 Hz wall clock, not a gameplay clock; tab-blur does not pause because there is no opponent to be unfair toward.

5. Functional Requirements

FR-01Windows XP Minesweeper chrome fidelity

FR-02Difficulty tiers

TierGridMine densityClue probabilityMin gapMin unknown fraction
Beginner8×80.160.5530.45
Intermediate10×100.180.4580.55
Expert14×140.200.38160.60

Mine count = max(1, round(R×C × density)) per field, then the coverage step may shift mines (and rarely reduce the count) to keep every unknown cell touching a clue. "Min gap" and "min unknown fraction" are the generator's difficulty targets — the puzzle is returned early once both are met (SR-4).

FR-03Puzzle generation with a uniqueness guarantee

Purpose. Turn Minesweeper's deduction into a self-validating puzzle: every field must have exactly one satisfying mine placement, so the player can always solve it by reasoning.

Pipeline (in generatePuzzle, up to 40 attempts):

  1. Random mine layout — pick mineCount cells uniformly at random.
  2. Seed clues — reveal a random subset of safe cells (clue probability per tier); floor the count at max(6, 15% of total).
  3. Coverage — every unknown cell must touch at least one clue, else it is unconstrained and uniqueness is impossible. Mines blocking coverage are relocated; safe unconstrained cells are revealed. Bounded to 30 passes.
  4. Tighten — reveal one more random safe cell per step until countSolutions === 1 (up to 160 steps).
  5. Minimize — strip every clue whose removal preserves uniqueness (shuffle order). This is what turns the field from a paint-by-numbers into an actual puzzle: clues are removed until the remaining set is just barely enough.
  6. Score — propagationGap × 10 + round(unknownFrac × 100); keep the best-scoring attempt. Return early once both difficulty thresholds are met.

FR-04Interaction — click to lay, right-click to mark

ActionResult
Left-click a shut unknown cellLay a mine (MARK_MINE). If the cell is a real mine → mine laid, clue cells repainted, mine counter ticks down. If the cell is safe → fail(i), instant game over.
Left-click a laid-mine cellPick the mine back up (MARK_MINE → NONE); no penalty; clue cells repainted; counter ticks up.
Left-click a clue cellNo-op (clues are fixed).
Right-click / long-press a shut cellCycle the mark: NONE → SAFE → QUESTION → NONE. A laid-mine cell cannot be flagged (mines take precedence).
Any interaction after WON / DEAD / GAVE_UPNo-op (active() gate).
Smiley click / F2New field at the current difficulty.

FR-05Win and lose state machine

AspectRule
Win checkAfter every placement, if placed === totalMines → win(). Because a misplaced mine kills the round instantly, the player can never reach the win condition with a wrong placement — reaching it means every laid mine is correct.
Win renderSmiley → "cool" (sunglasses); status: "Field secured — all N mines laid in T s." A win is recorded with result, difficulty, mine count, and time.
DeathLaying a mine on a safe square → fail(i): dead=true, failedCell=i, timer stops. The field is revealed: the failed cell renders as a red "boom" with a mine icon; every other real-mine cell renders as a mine; ? marks on safe cells are cleared. A loss is recorded.
Smiley on death→ "dead" (X eyes, frown).
Reveal solutionFrom the Game menu: gaveUp=true, timer stops, every mine is shown, no result is recorded (surrender is not a win or loss).
Timer1 Hz setInterval, starts on the first mine placement, caps display at 999 s.

FR-06Live hints (assist)

The assist layer is on by default (the board carries the assist class); toggled from the Game menu. It only ever colours clue cells, never shut cells — it cannot leak mine locations.

Interactive demo — FR-06 sat / over cues

Place mines around the centre clue. When the laid count equals the clue's number, the clue darkens (satisfied). Exceed it and the clue reddens (over-mined). These are the cues implemented in paintCell and styled in the CSS.

open clue   satisfied (laid == n)   over-mined (laid > n)
Click the four corner cells to lay/pick up mines around the centre "2".

FR-07Reveal solution

FR-08Statistics

FR-09Menus, dialogs, shortcuts

FR-10Result sharing (community layer, phase 1)

6. Shared Rules

SR-1 · Mark states (canonical)

ConstantValueMeaning
MARK_NONE0Unannotated shut cell.
MARK_MINE1A mine is laid here by the player.
MARK_SAFE2A flag — "this square is safe, no mine".
MARK_QUESTION3Uncertain — a question mark.

Right-click cycle: NONE → SAFE → QUESTION → NONE. Laying a mine (MARK_MINE) overwrites any prior mark on that cell. A laid-mine cell cannot be re-flagged by right-click (the mine mark is guarded).

SR-2 · Difficulty configuration (canonical)

Three tiers; each fixes grid size, mine density, clue-seed probability, and the generator's difficulty thresholds (min gap, min unknown fraction). The tier chosen before a round is the only difficulty knob; no in-round scaling.

SR-3 · Uniqueness guarantee (canonical)

Every generated field has countSolutions(cells, R, C, 2) === 1. The solver splits the unknown set into connected components (union-find on clues sharing an unknown), solves each component with forced-move propagation + branching on the tightest unsolved clue, and caps at 300 000 search nodes per component. A component with no clues returns 2 (an unconstrained unknown doubles the count). The product across components is the total; if it reaches limit the solver returns early.

SR-4 · Generator pipeline (canonical)

The six-step pipeline of FR-03. Returns early once both difficulty thresholds (min gap, min unknown fraction) are met; otherwise keeps the best-scoring attempt across 40 tries. The minimize step (step 5) makes the puzzle: it removes every clue whose removal preserves uniqueness, so the final clue set is a minimal satisfying basis.

SR-5 · Live-hint cues (canonical)

With assist on, paintCell adds sat when adjacentMines(i) === c.n (laid count equals the clue's number) and over when adjacentMines(i) > c.n. CSS styles both: .assist .cell.open.sat darkens to #808080; .assist .cell.open.over reddens to #ff0000 with white text. Assist never colours shut cells.

SR-6 · Persistence

Statistics in localStorage["minelayer.stats.v1"], capped at 200 entries. Storage unavailable → session-only, no error surfaced. No other state is persisted; a fresh field is generated on every load.

7. Exceptions and Boundaries

CaseBehavior
Small viewportfitBoard() scales cell size by both axes (floor 18 px; no cap on phones so every tier fills the width identically, classic 34 px cap at ≥520 px) so the window always fits; window centred via overflow-safe auto margins; width pinned to the board so menu/status text cannot stretch the chrome.
Touch long-press vs. tap450 ms threshold; suppressClick flag prevents the tap that follows a long-press from also laying a mine. Pointer-leave cancels the pending press.
Mid-round difficulty switchClicking a different tier in the Game menu calls newGame() — the current round is abandoned without record; clicking the current tier is a no-op.
localStorage blocked / corruptSession-only stats; corrupt JSON on load → empty array; no error surfaced.
Generator cannot meet thresholds in 40 attemptsReturns the best-scoring attempt as a fallback; the uniqueness guard test asserts this fallback is never a non-unique field in practice.
Failed cell after deathThe failed cell's MARK_MINE is intentionally preserved by fail() so the dead-branch renderer paints it as "Misplaced mine" (boom + mine icon). As a cosmetic side effect, the mines-remaining LED reads totalMines − 1 on the dead board — the counter is meaningless once the round is over.

8. Open Questions and Known Behaviours

9. Testing

9.1 Deterministic test suite (bug detection)

A dedicated test suite lives at tests/minelayer/ with 77 tests across 8 files. The harness loads the real index.html in-memory with stubbed browser APIs (DOM, LED SVGs, timer, localStorage) and a seeded Math.random so puzzle generation is fully deterministic. The game file is never modified; an extended __ML test hook is injected in place of the boot newGame(); call. Inspectable fixtures for .app and the Game-menu buttons let layout and menu-sync tests run against the real code paths.

FileTestsCovers
01-puzzle-generation.test.js9Rows/cols per tier, uniqueness guard (36 seeds × 3 tiers), mine count vs density, coverage invariant, clue/mine exclusivity, clue-number correctness, determinism, propagationGap sanity, minimize minimality
02-solver.test.js9countSolutions: all-clue board → 1, clueless board → ≥2, exact count below limit, limit=1 cap, contradictory clues → 0, independent components multiply, propagationGap trivial vs reasoning vs chained fixpoint
03-state-machine.test.js10newGame resets state, first mine starts timer, all-mines-placed wins, safe-square misplacement fails, revealSolution surrenders without record, F2 restarts, Escape no-crash, post-win/post-death no-ops, difficulty switch rebuilds dimensions
04-interaction.test.js10toggleMine on clue/mine/safe/placed-mine, toggleSafe 3-state cycle, clue no-op, laid-mine guard, mine-overwrites-question, clue repaint path, menu popup marks re-sync on open (regression)
05-assist-hud.test.js9sat class on satisfied clue, over class via direct state manipulation, presence of the .sat CSS rule (help-text promise), assist-off disables cues, mineLed/timeLed values, 999 cap, negative-clamp, face states
06-stats.test.js7recordGame win/loss fields, 200-entry cap, surrender records nothing, corrupt-JSON fallback, round-trip, current-difficulty recording
07-misc-soak.test.js720-game uniqueness + coverage soak, totalMines constant under interaction, 10× full-win loop across tiers, 1000× mark-cycle stress, board integrity across newGame, 50× death loop, win-fires-only-on-last-mine
08-layout.test.js17fitBoard across viewports: desktop 34 px unchanged at every tier, mobile portrait full-width cells (43/34/24 px per tier at 390 px), landscape height-constrained floor, short-window shrink, app width keyed to board, presence of the margin:auto centering rule, tier-switch re-fit (medium/easy → expert, expert → beginner), rebuild transient guard (first --cell write of a rebuild is the 18 px floor + source-order guard that it precedes the grid rebuild), consistent full-width fit on phones (430 px beginner → 48 px, 500 px intermediate → 45 px, ≥520 px stays classic 34 px), mobile menu touch targets (CSS guard: phone media query enlarges menubar buttons and popup rows)

Bug found and fixed by this suite: the help modal promises "Live hints darkens a satisfied number and reddens an over-mined one," but the CSS only styled .over — the sat class was added by paintCell but had no styling rule, so satisfied numbers were indistinguishable from untouched ones. The .assist .cell.open.sat rule was added (background: #808080) and a regression test guards it permanently.

Bugs reported from playtesting and fixed (regression-guarded by this suite): (1) the Game popup always showed the boot difficulty and a checked Live-hints item regardless of later changes, because syncMenuMarks() ran only at boot — it is now called on every menu open. (2) The board was not responsive on phones: cells were capped at 30 px on small screens and never constrained by viewport height, so Beginner/Intermediate looked tiny and Expert overflowed; the window was also top-anchored, hard to reach by thumb. fitBoard() now constrains by both axes (cap 44 px under 520 px width) and the window is centred via overflow-safe auto margins. (3) On mobile, switching from Beginner/Intermediate to Expert made the board overflow the right edge of the screen while direct Expert loads were fine: newGame() rebuilt the 14-column grid while --cell still held the previous tier's 34–43 px size, so the document was transiently 500–625 px wide (measured in-browser); mobile browsers react to that instant of horizontal overflow by stickily expanding the layout viewport, after which every fitBoard() computation ran against an inflated innerWidth. newGame() now resets --cell to the 18 px CELL_MIN floor before the rebuild, so the widest possible transient is 14×18 = 252 px. (4) A later consistency pass removed the 44 px phone cap from fix (2): on phones wider than ~396 px the cap bound Beginner first, so it stopped filling the width while Intermediate/Expert kept growing. All tiers now share the same both-axes full-width fit on phones; the 34 px cap applies only at ≥520 px (desktop-classic layouts). (5) The menu was too tiny on phones (~20 px touch targets at the authentic 11 px XP sizing) — a phone media query now scales menubar buttons and popup rows to 16 px text with ~45 px targets (see FR-01 "Mobile menu scaling"); desktop sizing is unchanged.

9.2 Run

node tests/minelayer/run.js   # exit 0 = all green, 1 = failures

10. Internationalization

Shared engine. Localization is provided by public/shared/i18n.js, a dependency-free plain script loaded before the game script (<script src="../../shared/i18n.js">). It exposes window.RG_I18N with init(pageId), t(page, key, fallback), setLang(id), onChange(fn), getLang() and LANGS, and injects a fixed top-right <select> language switcher.

Supported languages (8). en, zh, es, de, fr, ru, ja, ko. English is stored in the dictionary like any other language (the en block) and doubles as the default: when the active language has no entry for a key, the en entry is served; the markup's own text is the last-resort fallback.

Detection order. localStorage['rg.lang'] override → browser language (navigator.languages, then navigator.language, matched as an exact id or a two-letter prefix such as zh-CN → zh) → en.

Two integration patterns.

Re-render on change. Because renderStats(), the status line and the aria-labels are built at runtime, RG_I18N.onChange(refreshI18n) re-renders the status text, difficulty label, aria-labels, menu marks, and the statistics modal body whenever the language changes.

Dictionary namespace. Translations live in public/shared/lang/minelayer.js (window.RG_I18N_DICT.minelayer = { en, zh, es, de, fr, ru, ja, ko }), loaded immediately after the engine and before the game script. Inline markup and entities used by the English source (<b>, →, ×) are preserved verbatim in every translation.

Test-harness isolation. The harness (tests/minelayer/harness.js) evaluates only the first bare <script> block and stubs the DOM, so window.RG_I18N is undefined there. The boot is guarded by if (window.RG_I18N) { … }, and T() falls back to its English default argument, so the 59 deterministic tests remain unaffected by the i18n plumbing.