Minesweeper: the Windows 95 classic (rebuild guide) 04OCT2026

Grey squares, LED counters and the smiley button, with touch flagging

Posted by on 4th Oct 2026

Minesweeper with the Windows 3.1/95 look: grey raised squares, red LED counters and the smiley button that starts a new game. Play it at https://games.503mail.com/minesweeper/ or pick it from the menu at https://games.503mail.com. This post is the complete rebuild guide; every block has a Copy button.

What's in it

It has the three classic sizes: Beginner 9×9 with 10 mines, Intermediate 16×16 with 40, and Expert 30×16 with 99. On an upright phone or tablet, Expert is turned to 16 wide by 30 tall so the squares stay big enough. The first square you dig never has a mine. Tapping a number whose flags are all placed digs all the squares around it. Best times are saved for each size.

Controls

Mouse: click to dig, right-click to flag, click a number to dig round it, F2 or the smiley for a new game. Touch: tap to dig, hold a finger on a square to flag it, and a Tap: dig / Tap: flag button switches what a tap does.

Rebuild, step by step

Minesweeper is one folder on the games site. Build the site first with Games site + Nibbles. That post creates CT 125 (nibbles), the menu, 05-push-site.sh and make-icons.ps1. The menu already has the Minesweeper tile.

1. Make the game's folder

Proxmox host shell (Proxmox web UI → pve → Shell):

mkdir -p /root/nibbles/minesweeper

2. Create the game file

Paste this into the Proxmox host shell. It writes /root/nibbles/minesweeper/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):

cat > /root/nibbles/minesweeper/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#008080">
<title>Minesweeper</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta property="og:title" content="Minesweeper">
<meta property="og:image" content="https://games.503mail.com/minesweeper/icon-512.png">
<style>
  html,body{margin:0;height:100%;background:#008080;color:#000;font-family:"MS Sans Serif",Tahoma,Arial,sans-serif}
  body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;overflow:hidden;padding:8px;box-sizing:border-box}
  #bar{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;align-items:center;font-size:clamp(13px,2.4vmin,18px)}
  #bar button{font:inherit;background:#c0c0c0;border:3px outset #fff;padding:.3em .7em;cursor:pointer;color:#000}
  #bar button.on{border-style:inset;background:#b0b0b0;font-weight:bold}
  #bar label{color:#fff;display:flex;gap:.3em;align-items:center}
  #bar a{color:#fff}
  #flag{display:none}
  body.touch #flag{display:inline-block}
  #best{color:#fff;font-size:clamp(12px,2.2vmin,16px);min-height:1.2em}
  canvas{display:block;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}
</style>
</head>
<body>
<div id="bar">
  <button data-l="0">Beginner</button><button data-l="1">Intermediate</button><button data-l="2">Expert</button>
  <button id="flag" aria-label="Flag mode">Tap: dig</button>
  <label><input type="checkbox" id="snd" checked> Sound</label>
  <a href="/">Other games</a>
</div>
<canvas id="c"></canvas>
<div id="best"></div>
<script>
(() => {
  const cv = document.getElementById('c'), g = cv.getContext('2d');
  const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
  if (isTouch) document.body.classList.add('touch');
  const LEVELS = [{ name: 'Beginner', w: 9, h: 9, m: 10 }, { name: 'Intermediate', w: 16, h: 16, m: 40 }, { name: 'Expert', w: 30, h: 16, m: 99 }];

  // ---- settings + best times (per-viewer convenience only) ----
  let cfg = { level: 0, sound: true, best: [0, 0, 0] };
  try { Object.assign(cfg, JSON.parse(localStorage.getItem('minesweeper') || '{}')); } catch (e) {}
  if (!Array.isArray(cfg.best)) cfg.best = [0, 0, 0];
  const saveCfg = () => { try { localStorage.setItem('minesweeper', JSON.stringify(cfg)); } catch (e) {} };

  // ---- sound ----
  let actx = null, noiseBuf = null;
  function audio() {
    if (!cfg.sound) return null;
    if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
    if (actx.state === 'suspended') actx.resume();
    return actx;
  }
  function sweep(f0, f1, dur, type = 'square', vol = 0.04) {
    const a = audio(); if (!a) return;
    const t = a.currentTime, o = a.createOscillator(), v = a.createGain();
    o.type = type; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + dur);
    v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0005, t + dur);
    o.connect(v).connect(a.destination); o.start(t); o.stop(t + dur);
  }
  function boom() {
    const a = audio(); if (!a) return;
    if (!noiseBuf) { noiseBuf = a.createBuffer(1, a.sampleRate, a.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; }
    const t = a.currentTime, s = a.createBufferSource(), f = a.createBiquadFilter(), v = a.createGain();
    s.buffer = noiseBuf; f.type = 'lowpass'; f.frequency.setValueAtTime(900, t); f.frequency.exponentialRampToValueAtTime(50, t + .9);
    v.gain.setValueAtTime(.35, t); v.gain.exponentialRampToValueAtTime(.001, t + .9);
    s.connect(f).connect(v).connect(a.destination); s.start(t); s.stop(t + .9);
  }
  const SFX = {
    dig: () => sweep(700, 500, .04, 'square', .02),
    flag: () => sweep(900, 1300, .06, 'triangle', .05),
    unflag: () => sweep(1300, 900, .06, 'triangle', .04),
    win: () => [523, 659, 784, 1047].forEach((f, i) => setTimeout(() => sweep(f, f, .14, 'square', .04), i * 130)),
  };

  // ---- board ----
  // Expert is 30 wide; on an upright phone or tablet it is turned to 16 wide x 30 tall so the squares stay big enough.
  let W, H, M, mine, open, flag, near, state, flagsUsed, opened, t0, elapsed, pressed = null, face = 'smile', boomAt = -1;
  const CS = 16, B = 10, HEAD = 36;                  // cell size, border, header height (logical px)
  let SC = 3;                                        // canvas pixels per logical px
  function newGame() {
    const L = LEVELS[cfg.level], tall = innerHeight > innerWidth && L.w > L.h;
    W = tall ? L.h : L.w; H = tall ? L.w : L.h; M = L.m;
    mine = new Uint8Array(W * H); open = new Uint8Array(W * H); flag = new Uint8Array(W * H); near = new Uint8Array(W * H);
    state = 'ready'; flagsUsed = 0; opened = 0; elapsed = 0; face = 'smile'; boomAt = -1; pressed = null;
    document.querySelectorAll('#bar [data-l]').forEach(b => b.classList.toggle('on', +b.dataset.l === cfg.level));
    const b = cfg.best[cfg.level];
    document.getElementById('best').textContent = b ? `Best ${L.name} time: ${b} seconds` : '';
    fit(); draw();
  }
  function layMines(safe) {
    // the first square dug, and the squares around it, never have a mine
    const sx = safe % W, sy = safe / W | 0, spots = [];
    for (let i = 0; i < W * H; i++) { const x = i % W, y = i / W | 0; if (Math.abs(x - sx) > 1 || Math.abs(y - sy) > 1) spots.push(i); }
    for (let n = 0; n < M; n++) { const k = n + Math.floor(Math.random() * (spots.length - n)); [spots[n], spots[k]] = [spots[k], spots[n]]; mine[spots[n]] = 1; }
    for (let i = 0; i < W * H; i++) near[i] = around(i).filter(j => mine[j]).length;
  }
  function around(i) {
    const x = i % W, y = i / W | 0, out = [];
    for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
      if (!dx && !dy) continue;
      const nx = x + dx, ny = y + dy;
      if (nx >= 0 && ny >= 0 && nx < W && ny < H) out.push(ny * W + nx);
    }
    return out;
  }
  function dig(i) {
    if (state === 'won' || state === 'lost' || flag[i] || open[i]) return;
    if (state === 'ready') { layMines(i); state = 'play'; t0 = performance.now(); }
    if (mine[i]) { lose(i); return; }
    // flood-fill outwards from empty squares
    const stack = [i];
    while (stack.length) {
      const j = stack.pop();
      if (open[j] || flag[j]) continue;
      open[j] = 1; opened++;
      if (!near[j]) for (const k of around(j)) if (!open[k] && !mine[k]) stack.push(k);
    }
    SFX.dig();
    if (opened === W * H - M) win();
  }
  function chord(i) {
    // digging a number whose flags are all placed opens the rest of its neighbours
    if (!open[i] || !near[i]) return;
    const nb = around(i);
    if (nb.filter(j => flag[j]).length !== near[i]) return;
    for (const j of nb) if (!flag[j] && !open[j]) { dig(j); if (state === 'lost') return; }
  }
  function toggleFlag(i) {
    if (open[i] || state === 'won' || state === 'lost') return;
    flag[i] ^= 1; flagsUsed += flag[i] ? 1 : -1;
    (flag[i] ? SFX.flag : SFX.unflag)();
    if (navigator.vibrate) navigator.vibrate(30);
  }
  function lose(i) { state = 'lost'; face = 'dead'; boomAt = i; elapsed = secs(); boom(); }
  function win() {
    state = 'won'; face = 'cool'; elapsed = secs(); SFX.win();
    for (let i = 0; i < W * H; i++) if (mine[i] && !flag[i]) { flag[i] = 1; flagsUsed++; }
    const b = cfg.best[cfg.level];
    if (!b || elapsed < b) { cfg.best[cfg.level] = elapsed; saveCfg(); document.getElementById('best').textContent = `NEW BEST ${LEVELS[cfg.level].name} time: ${elapsed} seconds!`; }
  }
  const secs = () => Math.min(999, Math.floor((performance.now() - t0) / 1000));

  // ---- drawing, Windows 3.1 / 95 style ----
  const LW = () => W * CS + B * 2, LH = () => H * CS + B * 3 + HEAD;
  function fit() {
    const bar = document.getElementById('bar').offsetHeight + document.getElementById('best').offsetHeight + 40;
    const k = Math.min((innerWidth - 16) / LW(), (innerHeight - bar) / LH());
    cv.style.width = LW() * k + 'px'; cv.style.height = LH() * k + 'px';
    SC = Math.max(2, Math.ceil(k * (devicePixelRatio || 1)));
    cv.width = LW() * SC; cv.height = LH() * SC;
  }
  function bevel(x, y, w, h, d, up, fill = '#c0c0c0') {
    g.fillStyle = fill; g.fillRect(x, y, w, h);
    g.fillStyle = up ? '#fff' : '#808080'; g.fillRect(x, y, w, d); g.fillRect(x, y, d, h);
    g.fillStyle = up ? '#808080' : '#fff'; g.fillRect(x, y + h - d, w, d); g.fillRect(x + w - d, y, d, h);
  }
  function led(x, y, n) {
    g.fillStyle = '#000'; g.fillRect(x, y, 39, 23);
    const s = n < 0 ? '-' + String(Math.min(99, -n)).padStart(2, '0') : String(Math.min(999, n)).padStart(3, '0');
    g.font = 'bold 21px "Courier New",monospace'; g.textAlign = 'center'; g.textBaseline = 'middle';
    g.fillStyle = '#400'; for (let k = 0; k < 3; k++) g.fillText('8', x + 7 + k * 12.5, y + 12.5);
    g.fillStyle = '#f00'; for (let k = 0; k < 3; k++) g.fillText(s[k], x + 7 + k * 12.5, y + 12.5);
  }
  function drawFace(x, y) {
    bevel(x, y, 26, 26, 2, face !== 'press');
    const cx = x + 13 + (face === 'press' ? 1 : 0), cy = y + 13 + (face === 'press' ? 1 : 0);
    g.fillStyle = '#ff0'; g.beginPath(); g.arc(cx, cy, 9, 0, 7); g.fill(); g.strokeStyle = '#000'; g.lineWidth = 1; g.stroke();
    g.fillStyle = '#000';
    if (face === 'dead') {
      g.lineWidth = 1;
      for (const ex of [-3.5, 3.5]) { g.beginPath(); g.moveTo(cx + ex - 1.6, cy - 4.6); g.lineTo(cx + ex + 1.6, cy - 1.4); g.moveTo(cx + ex + 1.6, cy - 4.6); g.lineTo(cx + ex - 1.6, cy - 1.4); g.stroke(); }
      g.beginPath(); g.arc(cx, cy + 6, 3.5, Math.PI * 1.1, Math.PI * 1.9); g.stroke();
    } else if (face === 'cool') {
      g.fillRect(cx - 7, cy - 4, 14, 1.5); g.beginPath(); g.ellipse(cx - 3.5, cy - 2.5, 3, 2, 0, 0, 7); g.ellipse(cx + 3.5, cy - 2.5, 3, 2, 0, 0, 7); g.fill();
      g.beginPath(); g.arc(cx, cy + 1, 4.5, Math.PI * .2, Math.PI * .8); g.stroke();
    } else {
      g.fillRect(cx - 4.5, cy - 4, 2, 2); g.fillRect(cx + 2.5, cy - 4, 2, 2);
      if (face === 'oh') { g.beginPath(); g.arc(cx, cy + 4, 2, 0, 7); g.stroke(); }
      else { g.beginPath(); g.arc(cx, cy + 1, 4.5, Math.PI * .2, Math.PI * .8); g.stroke(); }
    }
  }
  function drawMine(x, y) {
    g.fillStyle = '#000'; g.strokeStyle = '#000'; g.lineWidth = 1.3;
    g.beginPath(); g.arc(x + 8, y + 8, 4.5, 0, 7); g.fill();
    g.beginPath(); g.moveTo(x + 2, y + 8); g.lineTo(x + 14, y + 8); g.moveTo(x + 8, y + 2); g.lineTo(x + 8, y + 14);
    g.moveTo(x + 4, y + 4); g.lineTo(x + 12, y + 12); g.moveTo(x + 12, y + 4); g.lineTo(x + 4, y + 12); g.stroke();
    g.fillStyle = '#fff'; g.fillRect(x + 6, y + 6, 1.6, 1.6);
  }
  function drawFlag(x, y) {
    g.fillStyle = '#000'; g.fillRect(x + 4, y + 12, 8, 1.5); g.fillRect(x + 6, y + 11, 4, 1.2); g.fillRect(x + 7.6, y + 4, 1.2, 8);
    g.fillStyle = '#f00'; g.beginPath(); g.moveTo(x + 8.8, y + 3); g.lineTo(x + 8.8, y + 8.5); g.lineTo(x + 3.5, y + 5.8); g.closePath(); g.fill();
  }
  const NUM = ['', '#0000ff', '#008000', '#ff0000', '#000080', '#800000', '#008080', '#000', '#808080'];
  function draw() {
    g.setTransform(SC, 0, 0, SC, 0, 0);
    const w = LW(), h = LH();
    bevel(0, 0, w, h, 3, true);
    bevel(B - 3, B - 3, w - B * 2 + 6, HEAD, 2, false);
    led(B + 3, B + 3, M - flagsUsed);
    led(w - B - 42, B + 3, state === 'play' ? secs() : elapsed);
    drawFace(w / 2 - 13, B + 1.5);
    const top = B * 2 + HEAD - 3;
    bevel(B - 3, top - 3, W * CS + 6, H * CS + 6, 3, false);
    g.font = 'bold 13px Tahoma,Arial,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
    for (let i = 0; i < W * H; i++) {
      const x = B + (i % W) * CS, y = top + (i / W | 0) * CS;
      const shown = open[i] || (state === 'lost' && mine[i] && !flag[i]);
      if (!shown) {
        const down = pressed && !flag[i] && (pressed.i === i || (pressed.chord && pressed.nb.includes(i)));
        if (down) { g.fillStyle = '#c0c0c0'; g.fillRect(x, y, CS, CS); g.fillStyle = '#808080'; g.fillRect(x, y, CS, 1); g.fillRect(x, y, 1, CS); }
        else bevel(x, y, CS, CS, 2, true);
        if (flag[i]) {
          drawFlag(x, y);
          if (state === 'lost' && !mine[i]) { g.strokeStyle = '#f00'; g.lineWidth = 1.5; g.beginPath(); g.moveTo(x + 2, y + 2); g.lineTo(x + 14, y + 14); g.moveTo(x + 14, y + 2); g.lineTo(x + 2, y + 14); g.stroke(); }
        }
        continue;
      }
      g.fillStyle = i === boomAt ? '#f00' : '#c0c0c0'; g.fillRect(x, y, CS, CS);
      g.fillStyle = '#808080'; g.fillRect(x, y, CS, 1); g.fillRect(x, y, 1, CS);
      if (mine[i]) drawMine(x, y);
      else if (near[i]) { g.fillStyle = NUM[near[i]]; g.fillText(near[i], x + 8.5, y + 9); }
    }
  }

  // ---- input: tap/click digs, long press or right click flags, tapping a number digs around it ----
  function cellAt(e) {
    const r = cv.getBoundingClientRect(), x = (e.clientX - r.left) / r.width * LW(), y = (e.clientY - r.top) / r.height * LH();
    const cx = Math.floor((x - B) / CS), cy = Math.floor((y - (B * 2 + HEAD - 3)) / CS);
    if (cx >= 0 && cy >= 0 && cx < W && cy < H) return cy * W + cx;
    const w = LW();
    if (x > w / 2 - 13 && x < w / 2 + 13 && y > B && y < B + 28) return 'face';
    return null;
  }
  let flagMode = false, lpTimer = null;
  cv.addEventListener('contextmenu', e => e.preventDefault());
  cv.addEventListener('pointerdown', e => {
    e.preventDefault(); audio();
    const i = cellAt(e);
    if (i === 'face') { face = 'press'; pressed = { face: true }; draw(); return; }
    if (i == null || state === 'won' || state === 'lost') return;
    if (e.button === 2) { toggleFlag(i); draw(); return; }
    pressed = { i, chord: open[i] && near[i], nb: around(i), id: e.pointerId };
    face = 'oh';
    if (e.pointerType !== 'mouse' && !open[i]) {
      clearTimeout(lpTimer);
      lpTimer = setTimeout(() => { if (pressed && pressed.i === i) { toggleFlag(i); pressed = null; face = 'smile'; draw(); } }, 380);
    }
    draw();
  });
  cv.addEventListener('pointermove', e => {
    if (!pressed || pressed.face) return;
    const i = cellAt(e);
    if (i !== pressed.i) { clearTimeout(lpTimer); if (typeof i === 'number') pressed = { i, chord: open[i] && near[i], nb: around(i), id: e.pointerId }; else pressed = null; draw(); }
  });
  cv.addEventListener('pointerup', e => {
    clearTimeout(lpTimer);
    if (!pressed) return;
    const p = pressed; pressed = null;
    if (p.face) { if (cellAt(e) === 'face') newGame(); else { face = 'smile'; draw(); } return; }
    if (state !== 'won' && state !== 'lost') face = 'smile';
    if (p.chord) chord(p.i);
    else if (flagMode) toggleFlag(p.i);
    else dig(p.i);
    if (state === 'lost' || state === 'won') face = state === 'lost' ? 'dead' : 'cool';
    draw();
  });
  cv.addEventListener('pointercancel', () => { clearTimeout(lpTimer); pressed = null; if (state !== 'won' && state !== 'lost') face = 'smile'; draw(); });
  addEventListener('keydown', e => { if (e.key === 'F2' || e.key === 'n') { e.preventDefault(); newGame(); } });

  document.querySelectorAll('#bar [data-l]').forEach(b => b.addEventListener('click', () => { cfg.level = +b.dataset.l; saveCfg(); newGame(); }));
  const flagBtn = document.getElementById('flag');
  flagBtn.addEventListener('click', () => { flagMode = !flagMode; flagBtn.textContent = flagMode ? 'Tap: flag' : 'Tap: dig'; flagBtn.classList.toggle('on', flagMode); });
  const snd = document.getElementById('snd'); snd.checked = cfg.sound;
  snd.addEventListener('change', () => { cfg.sound = snd.checked; saveCfg(); });
  let lastTall = innerHeight > innerWidth;
  addEventListener('resize', () => {
    // turning the tablet only re-shapes Expert before the first dig, so a game in progress is never lost
    const tall = innerHeight > innerWidth;
    if (tall !== lastTall && state === 'ready') { lastTall = tall; newGame(); } else { fit(); draw(); }
  });
  setInterval(() => { if (state === 'play') draw(); }, 250);

  newGame();
})();
</script>
</body>
</html>
EOF

3. Copy the icons

make-icons.ps1 (from the games site post) draws this game's icons into a minesweeper subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):

scp -o BatchMode=yes minesweeper\icon-512.png minesweeper\icon-192.png minesweeper\apple-touch-icon.png minesweeper\favicon-32.png proxmox:/root/nibbles/minesweeper/

4. Push it into CT 125

Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a minesweeper folder:

bash /root/nibbles/05-push-site.sh

5. Check it

Proxmox host shell. Should print 200:

pct exec 125 -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/minesweeper/

Then open https://games.503mail.com/minesweeper/ in a browser.

Updating it later

Edit index.html, paste the new version over /root/nibbles/minesweeper/index.html with the block from step 2, and run step 4 again. No restart is needed; the pages are served with no-cache. A browser that has the page open just needs a reload.

Troubleshooting

The game runs entirely in the browser, so the server's only job is handing out the page.

  • The page doesn't load (or shows a 404): that's the server side. Run the check in step 5. If it doesn't print 200, see "Troubleshooting: where the logs are" in the games site post for the Caddy logs on CT 125 (nibbles) and CT 108 (Caddy reverse proxy).
  • The page loads but the game misbehaves: open the browser's developer console. On a PC press F12, then the Console tab. Red lines are real errors; a 404 for /favicon.ico is harmless (browsers ask for it on their own, and the games use PNG icons instead).
  • Settings or best scores look wrong: they're kept in the browser's localStorage under 'minesweeper'. Clearing the site's data in the browser resets them; nothing is stored on the server.

Notes

  • Turning Expert: the board only turns when the tablet is turned before the first dig, so a game in progress is never lost.
  • Sharp on every screen: the board is drawn at the screen's real pixel density, so the numbers stay crisp on a tablet.