Minesweeper
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
The classic deduction game, rebuilt as an instrument panel: mines left, elapsed time and your best run sit in mono readouts above a board that opens already in progress. Mines are laid after your first move rather than before it, so the opening click can never lose — which is why the page can play that move for you and hand over a board with a real cascade already spread across it. Try this first: find a 1 with a single unopened neighbour, right-click or long-press to flag that square, then click the 1 — chording opens the rest of the neighbourhood at once. Three sizes, a flag-mode toggle so the same moves work under a thumb, arrow keys and space for full keyboard play, R for an instant new board, and Esc to push the result card aside and study the finished grid.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Minesweeper — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions (faces/shadows need alpha; tokens are opaque hex) */
:root {
--face: #E6E5DE; /* unopened cell */
--face-hover: #EEEDE6;
--face-hi: rgba(255,255,255,.95);
--face-lo: rgba(26,29,33,.13);
--open: #FCFCFA; /* opened cell */
--grid-line: rgba(26,29,33,.09);
--shadow: 0 1px 2px rgba(26,29,33,.06);
--lift: 0 10px 30px rgba(26,29,33,.13);
--scrim: rgba(246,246,243,.62);
--on-accent: #FFFFFF;
--n1: #2A62D6; --n2: #7A46C8; --n3: #C05600; --n4: #1B3F8B;
--n5: #C93C37; --n6: #A63E86; --n7: #4A5058; --n8: #1A1D21;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--face: #333944; --face-hover: #3C4350;
--face-hi: rgba(255,255,255,.10); --face-lo: rgba(0,0,0,.42);
--open: #171A20; --grid-line: rgba(255,255,255,.06);
--shadow: 0 1px 2px rgba(0,0,0,.35);
--lift: 0 10px 30px rgba(0,0,0,.50);
--scrim: rgba(20,22,26,.62); --on-accent: #14161A;
--n1: #7BA7FF; --n2: #C6A6FF; --n3: #FFB454; --n4: #8FB6FF;
--n5: #F47067; --n6: #F09BD8; --n7: #B9BFC7; --n8: #E8E6E1;
}
}
:root[data-theme="dark"] {
--face: #333944; --face-hover: #3C4350;
--face-hi: rgba(255,255,255,.10); --face-lo: rgba(0,0,0,.42);
--open: #171A20; --grid-line: rgba(255,255,255,.06);
--shadow: 0 1px 2px rgba(0,0,0,.35);
--lift: 0 10px 30px rgba(0,0,0,.50);
--scrim: rgba(20,22,26,.62); --on-accent: #14161A;
--n1: #7BA7FF; --n2: #C6A6FF; --n3: #FFB454; --n4: #8FB6FF;
--n5: #F47067; --n6: #F09BD8; --n7: #B9BFC7; --n8: #E8E6E1;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; line-height: 1.5; }
.wrap { max-width: 764px; margin: 0 auto; padding: 20px 14px 24px; }
header h1 {
margin: 0; font-size: clamp(19px, 4.4vw, 25px); letter-spacing: -.02em; font-weight: 650;
}
header p.lede {
margin: 5px 0 0; color: var(--muted); font-size: clamp(13px, 3.1vw, 14.5px);
max-width: 62ch;
}
/* ── control deck ────────────────────────────────────────────────────────── */
.deck {
margin: 16px 0 12px; background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 10px 12px; display: flex; flex-direction: column; gap: 10px;
}
.deck-row { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.deck-row + .deck-row { border-top: 1px solid var(--line); padding-top: 10px; }
.readouts { display: flex; gap: 8px; align-items: stretch; }
.ro {
display: flex; flex-direction: column; gap: 1px; min-width: 74px;
padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
}
.ro .k {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted);
}
.ro .v {
font-family: var(--font-mono); font-size: clamp(17px, 4.6vw, 21px); font-weight: 600;
font-variant-numeric: tabular-nums; letter-spacing: .02em; line-height: 1.15;
}
.ro.live .v { color: var(--accent); }
.ro.best .v { font-size: clamp(14px, 3.6vw, 16px); padding-top: 3px; color: var(--muted); }
.ro.best .v.has { color: var(--run); }
.spacer { flex: 1 1 auto; }
.pill {
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase;
padding: 5px 10px; border-radius: 999px; background: var(--bg); color: var(--muted);
border: 1px solid var(--line); white-space: nowrap;
}
.pill.win { color: var(--run); background: var(--run-soft); border-color: var(--run); }
.pill.lose { color: var(--danger); border-color: var(--danger); }
.pill.play { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.seg { display: inline-flex; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 3px; gap: 3px; }
.seg button {
font: 600 12px/1 var(--font-sans); color: var(--muted); background: transparent;
border: 0; border-radius: 6px; padding: 8px 10px; cursor: pointer; min-height: 34px;
}
.seg button .sz { display: block; font-family: var(--font-mono); font-size: 9.5px; font-weight: 500; margin-top: 3px; opacity: .78; }
.seg button:hover { color: var(--ink); background: var(--surface); }
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.btn {
font: 600 13px/1 var(--font-sans); color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px; padding: 9px 13px; cursor: pointer;
min-height: 38px; display: inline-flex; align-items: center; gap: 7px;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.btn .gl { font-size: 14px; line-height: 1; }
.btn[aria-pressed="true"] {
background: var(--accent); border-color: var(--accent); color: var(--on-accent);
}
.btn[aria-pressed="true"]:hover { background: var(--accent); color: var(--on-accent); }
.btn .state { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .08em; opacity: .85; }
:where(button, [tabindex]):focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px;
}
.c:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; z-index: 3; }
/* ── board ───────────────────────────────────────────────────────────────── */
.stage { position: relative; }
/* wide boards scroll inside their frame; these fades say so without a scrollbar */
.stage::before, .stage::after {
content: ""; position: absolute; top: 1px; bottom: 1px; width: 30px; z-index: 4;
pointer-events: none; opacity: 0; transition: opacity 140ms ease;
border-radius: var(--radius);
}
.stage::before { left: 1px; background: linear-gradient(90deg, var(--surface), rgba(0,0,0,0)); }
.stage::after { right: 1px; background: linear-gradient(270deg, var(--surface), rgba(0,0,0,0)); }
.stage.can-l::before { opacity: 1; }
.stage.can-r::after { opacity: 1; }
.hint {
margin: 8px 0 0; font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
color: var(--muted);
}
.hint[hidden] { display: none; }
.frame {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 9px; overflow-x: auto; overflow-y: hidden;
-webkit-overflow-scrolling: touch;
width: 100%; margin: 0 auto;
}
.grid {
display: grid; gap: 2px; margin: 0 auto; width: max-content;
touch-action: manipulation; user-select: none; -webkit-user-select: none;
}
/* once the game is decided nothing is clickable — the cursor must say so too */
.grid[data-over="1"], .grid[data-over="1"] .c, .grid[data-over="1"] .c.open.num { cursor: default; }
.row { display: contents; }
.c {
width: var(--cell, 28px); height: var(--cell, 28px); border-radius: 3px;
display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono); font-weight: 700;
font-size: calc(var(--cell, 28px) * .56); line-height: 1;
background: var(--face); cursor: pointer; position: relative;
box-shadow: inset 0 1px 0 var(--face-hi), inset 0 -1px 0 var(--face-lo);
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
.c:hover { background: var(--face-hover); }
.c.open {
background: var(--open); box-shadow: inset 0 0 0 1px var(--grid-line); cursor: default;
}
.c.open:hover { background: var(--open); }
.c.open.num { cursor: pointer; }
.c.n1 { color: var(--n1); } .c.n2 { color: var(--n2); } .c.n3 { color: var(--n3); }
.c.n4 { color: var(--n4); } .c.n5 { color: var(--n5); } .c.n6 { color: var(--n6); }
.c.n7 { color: var(--n7); } .c.n8 { color: var(--n8); }
.c.flag { color: var(--accent); background: var(--accent-soft); }
.c.flag:hover { background: var(--accent-soft); }
.c.blast { background: var(--danger); }
.c.blast .mine { color: var(--surface); }
.c.blast .mine::after { background: var(--danger); opacity: .8; }
.c.wrong { background: var(--danger); color: var(--surface); }
/* a decided board is inert: kill the hover lift so nothing looks clickable any more */
.grid[data-over="1"] .c:hover { background: var(--face); }
.grid[data-over="1"] .c.open:hover { background: var(--open); }
.grid[data-over="1"] .c.flag:hover { background: var(--accent-soft); }
.grid[data-over="1"] .c.blast:hover,
.grid[data-over="1"] .c.wrong:hover { background: var(--danger); }
/* mine glyph, drawn in CSS — no images, no requests */
.mine {
width: 42%; height: 42%; border-radius: 50%; background: currentColor;
position: relative; color: var(--ink);
}
.mine::before {
content: ""; position: absolute; inset: -26%;
background:
linear-gradient(currentColor, currentColor) center / 100% 15% no-repeat,
linear-gradient(currentColor, currentColor) center / 15% 100% no-repeat;
}
.mine::after {
content: ""; position: absolute; left: 20%; top: 18%;
width: 24%; height: 24%; border-radius: 50%; background: var(--surface); opacity: .55;
}
.c.wrong .mark {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-size: calc(var(--cell) * .8); font-weight: 400; color: var(--surface);
}
@keyframes pop { from { transform: scale(.72); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes nudge { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-2px); } 75% { transform: translateX(2px); } }
@keyframes glow { from { box-shadow: inset 0 0 0 1px var(--run); } to { box-shadow: inset 0 0 0 1px var(--grid-line); } }
.c.pop { animation: pop 140ms ease-out both; }
.c.nudge { animation: nudge 150ms ease-in-out 2; }
.grid.won .c.open { animation: glow 700ms ease-out both; }
/* ── result overlay ──────────────────────────────────────────────────────── */
.over {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: var(--scrim); border-radius: var(--radius); padding: 12px; z-index: 5;
}
.over[hidden] { display: none; }
.panel {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--lift); padding: 16px 18px; text-align: center; max-width: 300px;
animation: pop 160ms ease-out both;
}
.panel .tag {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
display: inline-block; padding: 3px 8px; border-radius: 999px;
}
.panel.win .tag { color: var(--run); background: var(--run-soft); }
.panel.lose .tag { color: var(--danger); background: var(--bg); box-shadow: inset 0 0 0 1px var(--danger); }
.panel h2 { margin: 9px 0 4px; font-size: 18px; letter-spacing: -.01em; }
.panel p { margin: 0 0 12px; color: var(--muted); font-size: 13px; }
.panel .stat { font-family: var(--font-mono); font-size: 12.5px; color: var(--muted); }
.panel .stat b { color: var(--ink); font-weight: 700; }
.panel .btn { justify-content: center; width: 100%; }
.panel .btn + .btn { margin-top: 7px; }
.btn.ghost {
background: transparent; border-color: transparent; color: var(--muted);
font-weight: 500; min-height: 32px; padding: 7px 12px;
}
.btn.ghost:hover { background: var(--bg); border-color: var(--line); color: var(--ink); }
/* ── legend + footer ─────────────────────────────────────────────────────── */
.legend {
margin: 11px 0 0; display: flex; flex-wrap: wrap; gap: 6px 14px;
color: var(--muted); font-size: 12.5px; align-items: center;
}
.legend span { display: inline-flex; align-items: center; gap: 6px; }
kbd {
font-family: var(--font-mono); font-size: 10.5px; padding: 2px 5px; border-radius: 4px;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
box-shadow: 0 1px 0 var(--line);
}
footer {
margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .04em;
}
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0);
clip-path: inset(50%); white-space: nowrap;
}
@media (max-width: 520px) {
.deck { padding: 9px; gap: 8px 10px; }
.ro { min-width: 66px; padding: 4px 7px; }
.seg button { padding: 7px 8px; }
.spacer { flex-basis: 100%; height: 0; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Minesweeper</h1>
<p class="lede">Open every square that isn’t hiding a mine. Each number counts the mines
touching that square — including corners. Your first move is always safe, so the board below
has already been opened for you.</p>
</header>
<div class="deck">
<div class="deck-row">
<div class="readouts">
<div class="ro"><span class="k">mines left</span><span class="v" id="roMines">010</span></div>
<div class="ro live"><span class="k">time</span><span class="v" id="roTime">000</span></div>
<div class="ro best"><span class="k">best</span><span class="v" id="roBest">···</span></div>
</div>
<div class="spacer"></div>
<div class="seg" role="group" aria-label="Difficulty">
<button type="button" data-lvl="0" aria-pressed="true">Gentle<span class="sz">9×9 · 10</span></button>
<button type="button" data-lvl="1" aria-pressed="false">Middling<span class="sz">16×16 · 40</span></button>
<button type="button" data-lvl="2" aria-pressed="false">Tough<span class="sz">30×16 · 99</span></button>
</div>
</div>
<div class="deck-row">
<button type="button" class="btn" id="flagBtn" aria-pressed="false">
<span class="gl" aria-hidden="true">⚑</span>Flag mode <span class="state" id="flagState">OFF</span>
</button>
<button type="button" class="btn" id="newBtn"><span class="gl" aria-hidden="true">↻</span>New board</button>
<div class="spacer"></div>
<span class="pill" id="pill">first move played for you</span>
</div>
</div>
<div class="stage">
<div class="frame">
<div class="grid" id="grid" role="grid" aria-label="Minesweeper board"></div>
</div>
<div class="over" id="over" hidden>
<div class="panel" id="panel" role="dialog" aria-modal="false" aria-labelledby="ovTitle">
<span class="tag" id="ovTag">CLEARED</span>
<h2 id="ovTitle">Board cleared</h2>
<p id="ovSub">Every safe square found.</p>
<p class="stat" id="ovStat"></p>
<button type="button" class="btn" id="againBtn">Play again</button>
<button type="button" class="btn ghost" id="peekBtn">Look at the board</button>
</div>
</div>
</div>
<p class="hint" id="hint" hidden>↔ the board is wider than the screen — drag it sideways</p>
<p class="legend">
<span><kbd>click</kbd> open</span>
<span><kbd>right-click</kbd> / <kbd>hold</kbd> flag</span>
<span><kbd>click a number</kbd> open its neighbours</span>
<span><kbd>←↑↓→</kbd> move</span>
<span><kbd>space</kbd> open</span>
<span><kbd>F</kbd> flag</span>
<span><kbd>R</kbd> new board</span>
</p>
<div class="sr" id="live" role="status" aria-live="polite"></div>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
var root = document.documentElement;
var gridEl = document.getElementById("grid");
var frameEl = gridEl.parentNode;
var overEl = document.getElementById("over");
var panelEl = document.getElementById("panel");
var roMines = document.getElementById("roMines");
var roTime = document.getElementById("roTime");
var roBest = document.getElementById("roBest");
var flagBtn = document.getElementById("flagBtn");
var flagState = document.getElementById("flagState");
var newBtn = document.getElementById("newBtn");
var againBtn = document.getElementById("againBtn");
var peekBtn = document.getElementById("peekBtn");
var liveEl = document.getElementById("live");
var pillEl = document.getElementById("pill");
var hintEl = document.getElementById("hint");
var stageEl = null;
var segBtns = [].slice.call(document.querySelectorAll(".seg button"));
var mqRM = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduced = mqRM ? mqRM.matches : false;
var mqCP = window.matchMedia ? window.matchMedia("(pointer: coarse)") : null;
var coarse = mqCP ? mqCP.matches : false;
var LEVELS = [
{ name: "Gentle", cols: 9, rows: 9, mines: 10 },
{ name: "Middling", cols: 16, rows: 16, mines: 40 },
{ name: "Tough", cols: 30, rows: 16, mines: 99 }
];
var HIDDEN = 0, OPEN = 1, FLAG = 2;
var lvl = 0;
var S = null; // game state
var cells = []; // DOM nodes
var flagMode = false;
var cursor = 0;
var roving = -1; // which cell currently holds tabindex="0"
var boardFocused = false;
var timerId = 0, startedAt = 0, elapsed = 0;
var bests = [null, null, null];
/* best times survive a reload when storage is available; memory-only otherwise */
function loadBests() {
try {
var raw = window.localStorage.getItem("sloprun.minesweeper.best");
if (!raw) return;
var arr = JSON.parse(raw);
if (Object.prototype.toString.call(arr) === "[object Array]") {
for (var i = 0; i < 3; i++) {
var v = Number(arr[i]);
bests[i] = (isFinite(v) && v >= 0) ? v : null;
}
}
} catch (e) { /* storage blocked — keep in memory */ }
}
function saveBests() {
try { window.localStorage.setItem("sloprun.minesweeper.best", JSON.stringify(bests)); }
catch (e) { /* ignore */ }
}
function pad3(n) {
n = Math.max(0, Math.min(999, Math.round(n)));
return (n < 10 ? "00" : n < 100 ? "0" : "") + n;
}
function minesLabel(n) {
if (n < 0) { var a = Math.min(99, -n); return "-" + (a < 10 ? "0" : "") + a; }
return pad3(n);
}
/* ── geometry ──────────────────────────────────────────────────────────── */
function neighbours(i) {
var c = i % S.cols, r = (i - c) / S.cols, out = [];
for (var dr = -1; dr <= 1; dr++) {
var rr = r + dr; if (rr < 0 || rr >= S.rows) continue;
for (var dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
var cc = c + dc; if (cc < 0 || cc >= S.cols) continue;
out.push(rr * S.cols + cc);
}
}
return out;
}
/* ── new game ──────────────────────────────────────────────────────────── */
function newGame(preseed) {
var L = LEVELS[lvl];
var n = L.cols * L.rows;
S = {
cols: L.cols, rows: L.rows, mines: L.mines, total: n,
mine: new Uint8Array(n), adj: new Uint8Array(n), st: new Uint8Array(n),
placed: false, opened: 0, flags: 0, over: false, won: false
};
stopTimer(); elapsed = 0; startedAt = 0;
overEl.hidden = true;
gridEl.classList.remove("won");
gridEl.removeAttribute("data-over");
buildDOM();
cursor = Math.min(cursor, n - 1);
updateHUD();
if (preseed) {
// The demo opens already in progress: play one guaranteed-safe move.
var start = Math.floor(S.rows * 0.5) * S.cols + Math.floor(S.cols * 0.32);
place(start);
openFrom(start, true);
cursor = start; // keyboard players start where the action is
setPill("first move played for you", "");
say("Board ready. " + S.mines + " mines hidden. The opening move has been played for you.");
} else {
setPill("fresh board · open anywhere", "");
say("New " + LEVELS[lvl].name.toLowerCase() + " board. " + S.mines + " mines hidden.");
}
focusCell(cursor, boardFocused);
}
function buildDOM() {
var frag = document.createDocumentFragment();
cells = new Array(S.total);
roving = -1; // fresh nodes: every cell starts at tabindex -1
gridEl.style.gridTemplateColumns = "repeat(" + S.cols + ", var(--cell))";
gridEl.setAttribute("aria-rowcount", String(S.rows));
gridEl.setAttribute("aria-colcount", String(S.cols));
for (var r = 0; r < S.rows; r++) {
var row = document.createElement("div");
row.className = "row"; row.setAttribute("role", "row");
for (var c = 0; c < S.cols; c++) {
var i = r * S.cols + c;
var el = document.createElement("div");
el.className = "c";
el.setAttribute("role", "gridcell");
el.setAttribute("tabindex", "-1");
el.dataset.i = String(i);
cells[i] = el;
row.appendChild(el);
paintCell(i);
}
frag.appendChild(row);
}
gridEl.textContent = "";
gridEl.appendChild(frag);
sizeBoard();
}
function sizeBoard() {
var stage = frameEl.parentNode;
var avail = stage.clientWidth - 20; // frame padding + border
var per = (avail - (S.cols - 1) * 2) / S.cols; // 2px gap
var cap = S.cols <= 9 ? 64 : S.cols <= 16 ? 44 : 30;
var min = coarse ? 28 : (avail < 480 ? 24 : 20);
var cell = Math.floor(Math.max(min, Math.min(cap, per)));
gridEl.style.setProperty("--cell", cell + "px");
updateEdges();
}
/* Scrollable boards get an edge fade and a plain-words nudge, never a bare cut-off. */
function updateEdges() {
if (!stageEl) stageEl = frameEl.parentNode;
var over = frameEl.scrollWidth - frameEl.clientWidth;
var scrollable = over > 2;
stageEl.classList.toggle("can-l", scrollable && frameEl.scrollLeft > 2);
stageEl.classList.toggle("can-r", scrollable && frameEl.scrollLeft < over - 2);
hintEl.hidden = !scrollable;
}
frameEl.addEventListener("scroll", updateEdges, { passive: true });
/* ── mine placement with a safe first move ─────────────────────────────── */
function place(safe) {
var i, safeSet = {};
safeSet[safe] = 1;
// Keep the whole 3x3 around the first click clear when there is room,
// so the opening move always breaks the board open.
if (S.mines <= S.total - 9) {
var nb = neighbours(safe);
for (i = 0; i < nb.length; i++) safeSet[nb[i]] = 1;
}
var pool = [];
for (i = 0; i < S.total; i++) if (!safeSet[i]) pool.push(i);
for (i = pool.length - 1; i > 0; i--) { // Fisher–Yates
var j = Math.floor(Math.random() * (i + 1));
var t = pool[i]; pool[i] = pool[j]; pool[j] = t;
}
for (i = 0; i < S.mines; i++) S.mine[pool[i]] = 1;
for (i = 0; i < S.total; i++) {
if (S.mine[i]) continue;
var nb2 = neighbours(i), k = 0;
for (var q = 0; q < nb2.length; q++) if (S.mine[nb2[q]]) k++;
S.adj[i] = k;
}
S.placed = true;
}
/* ── painting ──────────────────────────────────────────────────────────── */
function paintCell(i) {
var el = cells[i]; if (!el) return;
var st = S.st[i], cls = "c", label;
var c = i % S.cols, r = (i - c) / S.cols;
var pos = "row " + (r + 1) + " column " + (c + 1) + ", ";
el.textContent = "";
if (st === OPEN) {
cls += " open";
if (S.mine[i]) {
var m = document.createElement("span"); m.className = "mine";
el.appendChild(m);
label = pos + "mine";
} else if (S.adj[i] > 0) {
cls += " num n" + S.adj[i];
el.textContent = String(S.adj[i]);
label = pos + S.adj[i] + (S.adj[i] === 1 ? " mine nearby" : " mines nearby");
} else {
label = pos + "clear";
}
} else if (st === FLAG) {
cls += " flag";
el.textContent = "⚑";
label = pos + "flagged";
} else {
label = pos + "unopened";
}
el.className = cls;
el.setAttribute("aria-label", label);
}
function updateHUD() {
roMines.textContent = minesLabel(S.mines - S.flags);
roTime.textContent = pad3(elapsed);
var b = bests[lvl];
roBest.textContent = (b === null) ? "···" : pad3(b);
roBest.className = "v" + (b === null ? "" : " has");
}
function say(msg) { liveEl.textContent = msg; }
function setPill(text, kind) {
pillEl.textContent = text;
pillEl.className = "pill" + (kind ? " " + kind : "");
}
/* ── timer ─────────────────────────────────────────────────────────────── */
function startTimer() {
if (timerId || S.over) return;
startedAt = Date.now() - elapsed * 1000;
timerId = window.setInterval(function () {
elapsed = Math.min(999, Math.floor((Date.now() - startedAt) / 1000));
roTime.textContent = pad3(elapsed);
}, 250);
}
function stopTimer() {
if (timerId) { window.clearInterval(timerId); timerId = 0; }
// settle on the true elapsed time, not whatever the last 250ms tick happened to write
if (startedAt) elapsed = Math.min(999, Math.floor((Date.now() - startedAt) / 1000));
}
/* ── moves ─────────────────────────────────────────────────────────────── */
function openFrom(i, silent) {
if (!S.placed) place(i);
if (S.st[i] !== HIDDEN) return;
if (S.mine[i]) { boom(i); return; }
var stack = [i], seen = {}, opened = [];
seen[i] = 1;
while (stack.length) {
var k = stack.pop();
if (S.st[k] !== HIDDEN) continue;
S.st[k] = OPEN; S.opened++; opened.push(k); paintCell(k);
if (S.adj[k] === 0) {
var nb = neighbours(k);
for (var q = 0; q < nb.length; q++) {
var m = nb[q];
if (S.st[m] === HIDDEN && !seen[m]) { seen[m] = 1; stack.push(m); }
}
}
}
ripple(opened, i);
if (!silent && opened.length > 1) say(opened.length + " squares opened.");
checkWin();
}
function ripple(list, origin) {
if (reduced || list.length === 0) return;
var oc = origin % S.cols, or = (origin - oc) / S.cols;
for (var q = 0; q < list.length; q++) {
var i = list[q], c = i % S.cols, r = (i - c) / S.cols;
var d = Math.max(Math.abs(c - oc), Math.abs(r - or));
var el = cells[i];
el.style.animationDelay = Math.min(d * 14, 190) + "ms";
el.classList.add("pop");
}
}
function toggleFlag(i) {
if (S.over || S.st[i] === OPEN) return false;
if (S.st[i] === FLAG) { S.st[i] = HIDDEN; S.flags--; }
else { S.st[i] = FLAG; S.flags++; }
paintCell(i);
updateHUD();
say(S.st[i] === FLAG ? "Flag placed. " + (S.mines - S.flags) + " left." : "Flag removed.");
return true;
}
function chord(i) {
if (S.st[i] !== OPEN || S.adj[i] === 0) return false;
var nb = neighbours(i), f = 0, q;
for (q = 0; q < nb.length; q++) if (S.st[nb[q]] === FLAG) f++;
if (f !== S.adj[i]) { nudge(nb); return false; }
var acted = false;
for (q = 0; q < nb.length; q++) {
var m = nb[q];
if (S.st[m] === HIDDEN) { acted = true; openFrom(m); if (S.over) return true; }
}
return acted;
}
function shake(i) {
if (reduced) return;
var el = cells[i]; if (!el) return;
el.classList.remove("nudge");
void el.offsetWidth;
el.classList.add("nudge");
}
function nudge(list) {
if (reduced) return;
for (var q = 0; q < list.length; q++) {
if (S.st[list[q]] === HIDDEN) shake(list[q]);
}
}
function checkWin() {
if (S.over) return;
if (S.opened !== S.total - S.mines) return;
S.over = true; S.won = true;
stopTimer();
for (var i = 0; i < S.total; i++) {
if (S.mine[i] && S.st[i] !== FLAG) { S.st[i] = FLAG; S.flags++; paintCell(i); }
cells[i].classList.remove("pop");
cells[i].style.animationDelay = "";
}
gridEl.classList.add("won");
gridEl.setAttribute("data-over", "1");
var prev = bests[lvl];
var record = (prev === null || elapsed < prev);
if (record) { bests[lvl] = elapsed; saveBests(); }
updateHUD();
finish(true, record);
}
function boom(hit) {
S.over = true; S.won = false;
stopTimer();
S.st[hit] = OPEN;
for (var i = 0; i < S.total; i++) {
if (S.mine[i] && S.st[i] !== FLAG) {
S.st[i] = OPEN; paintCell(i);
} else if (!S.mine[i] && S.st[i] === FLAG) {
paintCell(i);
cells[i].classList.add("wrong");
cells[i].textContent = ""; // drop the ⚑ so the ✕ reads cleanly
var x = document.createElement("span");
x.className = "mark"; x.textContent = "✕";
cells[i].appendChild(x);
cells[i].setAttribute("aria-label", cells[i].getAttribute("aria-label").replace("flagged", "flagged, but no mine here"));
}
}
paintCell(hit);
cells[hit].classList.add("blast");
cells[hit].setAttribute("aria-label", cells[hit].getAttribute("aria-label") + ", the one you opened");
gridEl.setAttribute("data-over", "1");
updateHUD();
finish(false, false);
}
function finish(won, record) {
var tag = document.getElementById("ovTag");
var title = document.getElementById("ovTitle");
var sub = document.getElementById("ovSub");
var stat = document.getElementById("ovStat");
panelEl.className = "panel " + (won ? "win" : "lose");
tag.textContent = won ? (record ? "NEW BEST" : "CLEARED") : "HIT A MINE";
title.textContent = won ? "Board cleared" : "That one had a mine";
sub.textContent = won
? (record ? "Fastest run yet on this size." : "Every safe square found.")
: "Every mine is showing underneath this card.";
var b = bests[lvl];
stat.innerHTML = "time <b>" + pad3(elapsed) + "</b>s · size <b>" + S.cols + "×" + S.rows +
"</b> · mines <b>" + S.mines + "</b>" + (b === null ? "" : " · best <b>" + pad3(b) + "</b>s");
againBtn.textContent = won ? "Play again" : "Try another board";
peekBtn.textContent = won ? "Look at the board" : "See where they were";
overEl.hidden = false;
setPill(won ? (record ? "cleared · new best " + pad3(elapsed) + "s" : "cleared in " + pad3(elapsed) + "s") : "hit a mine", won ? "win" : "lose");
say((won ? "Board cleared in " + elapsed + (elapsed === 1 ? " second." : " seconds.") : "You hit a mine.") +
" Press R for a new board, or Escape to dismiss this and look at the finished board.");
window.setTimeout(function () { try { againBtn.focus(); } catch (e) {} }, 30);
}
/* The finished board is the interesting part — never trap it behind the card. */
function dismissOverlay() {
if (overEl.hidden) return false;
overEl.hidden = true;
focusCell(cursor, true);
say("Card dismissed. The finished board is showing. Press R for a new board.");
return true;
}
peekBtn.addEventListener("click", dismissOverlay);
overEl.addEventListener("click", function (e) { if (e.target === overEl) dismissOverlay(); });
/* ── input: pointer ────────────────────────────────────────────────────── */
function cellFrom(target) {
var el = target;
while (el && el !== gridEl) {
if (el.classList && el.classList.contains("c")) return Number(el.dataset.i);
el = el.parentNode;
}
return -1;
}
function act(i, wantFlag) {
if (S.over || i < 0) return;
var did;
if (S.st[i] === OPEN) did = chord(i);
else if (wantFlag) did = toggleFlag(i);
else if (S.st[i] === FLAG) { // flagged squares are protected — say so
did = false; shake(i);
say("That square is flagged. Unflag it before opening it.");
}
else { openFrom(i); did = true; }
if (did) { startTimer(); if (!S.over) setPill("in play", "play"); }
cursor = i;
focusCell(i, boardFocused);
}
var press = null, holdId = 0, holdFired = false;
gridEl.addEventListener("contextmenu", function (e) {
e.preventDefault();
if (holdFired) return; // our long-press already flagged this square
var i = cellFrom(e.target);
if (i >= 0 && !S.over) { if (toggleFlag(i)) { startTimer(); setPill("in play", "play"); } cursor = i; focusCell(i, boardFocused); }
});
gridEl.addEventListener("pointerdown", function (e) {
var i = cellFrom(e.target);
if (i < 0 || S.over) return;
if (holdId) { window.clearTimeout(holdId); holdId = 0; } // a second finger must not leave an armed timer
holdFired = false;
if (e.button === 2) return; // handled by contextmenu
if (e.button === 1) e.preventDefault(); // no middle-click autoscroll
press = { i: i, x: e.clientX, y: e.clientY, btn: e.button, touch: e.pointerType !== "mouse" };
if (press.touch) {
holdId = window.setTimeout(function () {
holdId = 0;
if (!press || S.over) return;
holdFired = true;
if (toggleFlag(press.i)) { startTimer(); setPill("in play", "play"); }
cursor = press.i; focusCell(press.i, false);
try {
var ua = navigator.userActivation;
if (navigator.vibrate && ua && ua.hasBeenActive) navigator.vibrate(12);
} catch (err) {}
}, 420);
}
});
gridEl.addEventListener("auxclick", function (e) { if (e.button === 1) e.preventDefault(); });
gridEl.addEventListener("pointermove", function (e) {
if (!press) return;
if (Math.abs(e.clientX - press.x) > 10 || Math.abs(e.clientY - press.y) > 10) cancelPress();
});
gridEl.addEventListener("pointercancel", cancelPress);
gridEl.addEventListener("pointerleave", cancelPress);
function cancelPress() {
if (holdId) { window.clearTimeout(holdId); holdId = 0; }
press = null;
}
gridEl.addEventListener("pointerup", function (e) {
if (!press) return;
var p = press;
if (holdId) { window.clearTimeout(holdId); holdId = 0; }
press = null;
if (holdFired) return;
if (Math.abs(e.clientX - p.x) > 10 || Math.abs(e.clientY - p.y) > 10) return;
var i = cellFrom(e.target);
if (i !== p.i) return;
if (p.btn === 1) { if (chord(i)) { startTimer(); if (!S.over) setPill("in play", "play"); } cursor = i; focusCell(i, boardFocused); return; }
act(i, flagMode);
});
gridEl.addEventListener("focusin", function () { boardFocused = true; });
gridEl.addEventListener("focusout", function () { boardFocused = false; });
/* ── input: keyboard ───────────────────────────────────────────────────── */
/* roving tabindex: exactly one cell is tabbable, and only two attributes move */
function focusCell(i, doFocus) {
if (!cells.length) return;
i = Math.max(0, Math.min(S.total - 1, i));
if (roving >= 0 && roving !== i && cells[roving]) cells[roving].setAttribute("tabindex", "-1");
cells[i].setAttribute("tabindex", "0");
roving = i;
cursor = i;
if (doFocus) { try { cells[i].focus({ preventScroll: false }); } catch (e) { cells[i].focus(); } }
}
gridEl.addEventListener("keydown", function (e) {
var i = cursor, c = i % S.cols, r = (i - c) / S.cols, k = e.key, moved = true;
if (k === "ArrowLeft") c = Math.max(0, c - 1);
else if (k === "ArrowRight") c = Math.min(S.cols - 1, c + 1);
else if (k === "ArrowUp") r = Math.max(0, r - 1);
else if (k === "ArrowDown") r = Math.min(S.rows - 1, r + 1);
else if (k === "Home") c = 0;
else if (k === "End") c = S.cols - 1;
else if (k === "PageUp") r = 0;
else if (k === "PageDown") r = S.rows - 1;
else moved = false;
if (moved) { e.preventDefault(); focusCell(r * S.cols + c, true); return; }
if (k === "Enter" || k === " " || k === "Spacebar") { e.preventDefault(); act(i, flagMode); focusCell(cursor, true); }
else if (k === "f" || k === "F" || k === "x" || k === "X") {
e.preventDefault();
if (!S.over) { if (toggleFlag(i)) { startTimer(); setPill("in play", "play"); } }
focusCell(i, true);
}
});
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target;
if (t && t.tagName === "INPUT") return;
if (e.key === "r" || e.key === "R") { e.preventDefault(); newGame(false); }
else if (e.key === "Escape" && !overEl.hidden) { e.preventDefault(); dismissOverlay(); }
});
/* ── deck controls ─────────────────────────────────────────────────────── */
flagBtn.addEventListener("click", function () {
flagMode = !flagMode;
flagBtn.setAttribute("aria-pressed", flagMode ? "true" : "false");
flagState.textContent = flagMode ? "ON" : "OFF";
say(flagMode ? "Flag mode on. Tapping a square places a flag." : "Flag mode off. Tapping a square opens it.");
});
newBtn.addEventListener("click", function () { newGame(false); focusCell(cursor, true); });
againBtn.addEventListener("click", function () { newGame(false); focusCell(cursor, true); });
segBtns.forEach(function (b) {
b.addEventListener("click", function () {
lvl = Number(b.dataset.lvl);
segBtns.forEach(function (o) { o.setAttribute("aria-pressed", o === b ? "true" : "false"); });
cursor = 0;
newGame(true);
});
});
/* ── resize ────────────────────────────────────────────────────────────── */
var rid = 0;
window.addEventListener("resize", function () {
if (rid) window.clearTimeout(rid);
rid = window.setTimeout(function () { rid = 0; if (S) sizeBoard(); }, 90);
});
/* ── theme plumbing ────────────────────────────────────────────────────── */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
}
});
if (mqRM) {
var onRM = function () { reduced = mqRM.matches; };
if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
else if (mqRM.addListener) mqRM.addListener(onRM);
}
if (mqCP) {
var onCP = function () { coarse = mqCP.matches; if (S) sizeBoard(); };
if (mqCP.addEventListener) mqCP.addEventListener("change", onCP);
else if (mqCP.addListener) mqCP.addListener(onCP);
}
/* ── go ────────────────────────────────────────────────────────────────── */
loadBests();
newGame(true);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.