$sloprun.dev

Minesweeper

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

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.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/minesweeper" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Minesweeper — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/minesweeper

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 954 lines 40.3 KB index.html
demos/minesweeper/index.html
<!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&rsquo;t hiding a mine. Each number counts the mines
      touching that square &mdash; 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">&#9873;</span>Flag mode <span class="state" id="flagState">OFF</span>
    </button>
    <button type="button" class="btn" id="newBtn"><span class="gl" aria-hidden="true">&#8635;</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>&#8596; the board is wider than the screen &mdash; 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>&larr;&uarr;&darr;&rarr;</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 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong