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).
countSolutions) verifies uniqueness before a field is ever shown.localStorage and degrade silently if storage is blocked.| Element | Definition |
|---|---|
| Primary user | A casual player in a browser; solo |
| Trigger | Opening the HTML file; the game boots straight into a fresh field — no title screen, no external resources |
| Core job | Lay 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 boundaries | Fully local: no network calls, no external assets, no accounts; statistics only in localStorage (capped at 200 entries) |
| Object | Canonical definition |
|---|---|
| Field | The R×C grid of cells. Size and density come from the active difficulty (SR-2). |
| Cell | One 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). |
| Clue | A 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. |
| Mark | The player's per-cell annotation: NONE, MINE (a laid mine), SAFE (a flag — "safe, no mine here"), QUESTION (uncertain). See SR-1. |
| Difficulty tier | One of Beginner / Intermediate / Expert. Tunes board size, mine density, clue probability, and the generator's difficulty thresholds (SR-2). |
| Solver | The 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. |
| Smiley | The new-game button and round-state indicator: smile (ready/playing), surprise (mid-click), dead (lost), cool (won). |
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.
#000080.#ff0000) when on, dark red (#3d0000) when off.fitBoard() constrains the cell size by both axes — min(availW/cols, availH/rows), floored at 18 px — so the whole window always fits the viewport without scrolling. The vertical chrome (titlebar + menu + HUD + statusbar) is measured live from offsetHeight, never hard-coded. Cell cap: none on phones (<520 px wide) — every tier fills the width the same way (a fixed 44 px touch cap was dropped because it bound Beginner first, leaving it visibly narrower than Intermediate/Expert on phones wider than ~396 px); at ≥520 px the classic 34 px cap keeps the authentic desktop look at every tier. The window width is pinned to the board so long status text can never stretch the chrome. Transient guard: newGame() resets --cell to the 18 px floor (CELL_MIN) before rebuilding the grid, so no layout state during a tier switch can ever be wider than the viewport — otherwise a fresh 14-column Expert grid at the previous tier's 34–43 px cells is transiently 500–625 px wide, which mobile browsers answer by stickily expanding the layout viewport, inflating innerWidth and overflowing the physical screen for good..app { margin: auto } — overflow-safe: the auto margins collapse to 0 when the app is taller than the viewport, so the top is never clipped and the page scrolls normally.fitBoard) menubar buttons and popup rows scale up to 16 px text with 10–12 px padding (~45 px touch targets, measured 45/47 px at 430 px width) and the popup widens to 210 px; desktop keeps the authentic 11 px XP sizing. The taller chrome needs no fitting changes because chromeY is measured live.| Tier | Grid | Mine density | Clue probability | Min gap | Min unknown fraction |
|---|---|---|---|---|---|
| Beginner | 8×8 | 0.16 | 0.55 | 3 | 0.45 |
| Intermediate | 10×10 | 0.18 | 0.45 | 8 | 0.55 |
| Expert | 14×14 | 0.20 | 0.38 | 16 | 0.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).
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):
mineCount cells uniformly at random.max(6, 15% of total).countSolutions === 1 (up to 160 steps).propagationGap × 10 + round(unknownFrac × 100); keep the best-scoring attempt. Return early once both difficulty thresholds are met.cells array is guaranteed unique-solvable, coverage-complete, clue-minimized, and within tier thresholds (or the best effort across 40 attempts).propagationGap): the number of unknown cells that trivial single-clue rules (need=0 → all safe, need=avail → all mines) cannot decide, iterated to a fixpoint. High gap = the field needs multi-clue reasoning = real gameplay.| Action | Result |
|---|---|
| Left-click a shut unknown cell | Lay 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 cell | Pick the mine back up (MARK_MINE → NONE); no penalty; clue cells repainted; counter ticks up. |
| Left-click a clue cell | No-op (clues are fixed). |
| Right-click / long-press a shut cell | Cycle the mark: NONE → SAFE → QUESTION → NONE. A laid-mine cell cannot be flagged (mines take precedence). |
| Any interaction after WON / DEAD / GAVE_UP | No-op (active() gate). |
| Smiley click / F2 | New field at the current difficulty. |
suppressClick flag prevents the subsequent click from also laying a mine. Pointer-leave cancels the pending long-press.| Aspect | Rule |
|---|---|
| Win check | After 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 render | Smiley → "cool" (sunglasses); status: "Field secured — all N mines laid in T s." A win is recorded with result, difficulty, mine count, and time. |
| Death | Laying 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 solution | From the Game menu: gaveUp=true, timer stops, every mine is shown, no result is recorded (surrender is not a win or loss). |
| Timer | 1 Hz setInterval, starts on the first mine placement, caps display at 999 s. |
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.
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.
sat class): when the count of laid mines among a clue's 8 neighbours equals its n. Implemented as background: #808080 (the clue number keeps its colour for readability).over class): when the laid count exceeds n. Implemented as background: #ff0000; color: #fff.n without dying. The cue exists for completeness and is exercised only through direct state manipulation (see the test suite). The help-text mention of "reddens an over-mined one" describes a defensive cue, not a live play state.gaveUp=true and writes the true mine layout onto every non-clue cell (c.mine ? MARK_MINE : MARK_NONE). The field is fully opened; the smiley returns to "smile".gaveUp is a new game (F2 or the smiley).localStorage["minelayer.stats.v1"]. Blocked storage → session-only, no error surfaced.confirm().loadStats() returns [] (try/catch), the game is unaffected.syncMenuMarks() inside toggleMenu()), so the popup can never show a stale selection.title_brand + " — " + title_suffix, both localized per language), a hook line with the translated tier name and elapsed seconds, the final field rendered as an emoji grid (clues as keycap digits 0️⃣–8️⃣, mines 💣, safe squares ⬜, flags 🚩, question marks ❓), and a call-to-action line with the page URL.public/shared/share.js. Clicking SHARE RESULT always opens a retro CRT preview modal first — the player sees the exact card before anything leaves the device. From the modal: COPY (clipboard + execCommand legacy fallback + retro toast), native SHARE via the Web Share API sheet where available (a dismissed sheet is not an error), CLOSE. Modal button labels are localized in all 8 languages by the engine's own dictionary. Purely additive: no server, no account, no change to the game loop.share_btn, share_copied, share_hook, share_cta dictionary keys in all 8 languages; card assembly (buildShareCard) is a pure function pinned byte-for-byte by the 09-share test suite.| Constant | Value | Meaning |
|---|---|---|
MARK_NONE | 0 | Unannotated shut cell. |
MARK_MINE | 1 | A mine is laid here by the player. |
MARK_SAFE | 2 | A flag — "this square is safe, no mine". |
MARK_QUESTION | 3 | Uncertain — 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).
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.
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.
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.
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.
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.
| Case | Behavior |
|---|---|
| Small viewport | fitBoard() 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. tap | 450 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 switch | Clicking 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 / corrupt | Session-only stats; corrupt JSON on load → empty array; no error surfaced. |
| Generator cannot meet thresholds in 40 attempts | Returns 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 death | The 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. |
over requires laying more mines around a clue than its n, but the (n+1)-th mine sits on a safe square and triggers fail() synchronously before the cue can render. The cue and its CSS are retained for completeness and are covered by direct-state-manipulation tests; the help text's mention is a known over-promise. A future design could allow "soft" over-mining (a brief preview before death) if playtesting wants the cue visible.return best || last at the end of generatePuzzle could in theory return a non-unique field if all 40 attempts fail to converge. The uniqueness guard test (§9.1) runs 36+ seeds across all tiers and has never observed this; the fallback remains a defensive last resort.totalMines − 1 after death because the failed cell's mine mark is preserved for rendering. Cosmetic only; the round is over.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.
| File | Tests | Covers |
|---|---|---|
01-puzzle-generation.test.js | 9 | Rows/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.js | 9 | countSolutions: 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.js | 10 | newGame 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.js | 10 | toggleMine 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.js | 9 | sat 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.js | 7 | recordGame win/loss fields, 200-entry cap, surrender records nothing, corrupt-JSON fallback, round-trip, current-difficulty recording |
07-misc-soak.test.js | 7 | 20-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.js | 17 | fitBoard 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.
node tests/minelayer/run.js # exit 0 = all green, 1 = failures
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.
data-i18n="minelayer.<key>" (titlebar brand, menu bar and items, the Help modal title and its 8 list items, the OK / Reset buttons, the Statistics modal title). init() swaps in the active translation — English included, from the dictionary's en block — and captures each element's original inner HTML once as the last-resort fallback. The game title is the standalone title_brand key (first key of every language block); the document <title> is derived from it via key doc_title (title_brand + " — " + descriptor) in the dictionary's GAME TITLE section, so renaming the game in one place follows into the browser tab.T(key, EnglishDefault), which delegates to RG_I18N.t('minelayer', key, default). It wraps the status-bar messages (ready / playing / win / death / solution revealed — the win message uses {mines} and {time} placeholders), the difficulty labels (tier_easy / tier_medium / tier_hard, keeping the internal easy/medium/hard keys and data-diff attributes untouched), the statistics body built by renderStats(), the confirm() reset prompt, and the aria-label attributes (Mines left, Time in seconds, New game, Close). syncMenuMarks() restores the menu bullet / check marks after a language swap.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.