gamekitui
componentminesweepersource: ok · refreshed
Minesweeper
Deterministic 9×9 Minesweeper. Left-click reveals, right-click / long-press flags.
npx shadcn add "https://gamekitui.com/r/minesweeper.json"free
Interaction
scroll-reveal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 5:10:56 AM
registry/games/minesweeper.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Engine */
/* -------------------------------------------------------------------------- */
const COLS = 9;
const ROWS = 9;
const MINE_COUNT = 10;
type CellState = {
mine: boolean;
revealed: boolean;
flagged: boolean;
adjacentMines: number;
};
type Grid = CellState[][];
function createEmptyGrid(): Grid {
const grid: Grid = [];
for (let r = 0; r < ROWS; r++) {
const row: CellState[] = [];
for (let c = 0; c < COLS; c++) {
row.push({ mine: false, revealed: false, flagged: false, adjacentMines: 0 });
}
grid.push(row);
}
return grid;
}
function getNeighbors(r: number, c: number): [number, number][] {
const neighbors: [number, number][] = [];
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS) {
neighbors.push([nr, nc]);
}
}
}
return neighbors;
}
function placeMines(grid: Grid, safeR: number, safeC: number): Grid {
// Safe zone: clicked cell + its neighbors
const safeSet = new Set<string>();
safeSet.add(`${safeR},${safeC}`);
for (const [nr, nc] of getNeighbors(safeR, safeC)) {
safeSet.add(`${nr},${nc}`);
}
const candidates: [number, number][] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!safeSet.has(`${r},${c}`)) {
candidates.push([r, c]);
}
}
}
// Fisher-Yates shuffle, take first MINE_COUNT
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = candidates[i];
const swap = candidates[j];
if (tmp !== undefined && swap !== undefined) {
candidates[i] = swap;
candidates[j] = tmp;
}
}
const newGrid: Grid = grid.map((row) => row.map((cell) => ({ ...cell })));
for (let i = 0; i < MINE_COUNT && i < candidates.length; i++) {
const coord = candidates[i];
if (coord === undefined) continue;
const [mr, mc] = coord;
const mineRow = newGrid[mr];
if (mineRow === undefined) continue;
const mineCell = mineRow[mc];
if (mineCell === undefined) continue;
mineCell.mine = true;
}
// Calculate adjacency
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const row = newGrid[r];
if (row === undefined) continue;
const cell = row[c];
if (cell === undefined) continue;
if (cell.mine) continue;
let count = 0;
for (const [nr, nc] of getNeighbors(r, c)) {
const nrow = newGrid[nr];
if (nrow === undefined) continue;
const ncell = nrow[nc];
if (ncell === undefined) continue;
if (ncell.mine) count++;
}
cell.adjacentMines = count;
}
}
return newGrid;
}
function floodReveal(grid: Grid, startR: number, startC: number): Grid {
const newGrid: Grid = grid.map((row) => row.map((cell) => ({ ...cell })));
const queue: [number, number][] = [[startR, startC]];
const visited = new Set<string>();
while (queue.length > 0) {
const item = queue.shift();
if (item === undefined) break;
const [r, c] = item;
const key = `${r},${c}`;
if (visited.has(key)) continue;
visited.add(key);
const row = newGrid[r];
if (row === undefined) continue;
const cell = row[c];
if (cell === undefined) continue;
if (cell.flagged || cell.mine) continue;
cell.revealed = true;
if (cell.adjacentMines === 0) {
for (const [nr, nc] of getNeighbors(r, c)) {
const nrow = newGrid[nr];
if (nrow === undefined) continue;
const ncell = nrow[nc];
if (ncell === undefined) continue;
if (!ncell.revealed && !ncell.flagged) {
queue.push([nr, nc]);
}
}
}
}
return newGrid;
}
/**
* Chord (a.k.a. "double-click") reveal: activating an already-revealed numbered
* cell whose flagged-neighbor count equals its number reveals all of its
* non-flagged neighbors. Reuses floodReveal so empty cells cascade as usual and
* mines stay flagged. Returns the new grid plus whether a mine got revealed
* (the player flagged wrong → loss, handled by the caller exactly like a normal
* mine reveal). When the chord conditions aren't met, the grid is returned
* unchanged.
*/
function chordReveal(grid: Grid, r: number, c: number): { grid: Grid; hitMine: boolean } {
const row = grid[r];
if (row === undefined) return { grid, hitMine: false };
const cell = row[c];
if (cell === undefined || !cell.revealed || cell.mine || cell.adjacentMines === 0) {
return { grid, hitMine: false };
}
const neighbors = getNeighbors(r, c);
let flaggedCount = 0;
for (const [nr, nc] of neighbors) {
const nrow = grid[nr];
if (nrow === undefined) continue;
const ncell = nrow[nc];
if (ncell === undefined) continue;
if (ncell.flagged) flaggedCount++;
}
// Only chord when the flag count matches the number (standard behavior).
if (flaggedCount !== cell.adjacentMines) return { grid, hitMine: false };
let workGrid = grid;
let hitMine = false;
for (const [nr, nc] of neighbors) {
const nrow = workGrid[nr];
if (nrow === undefined) continue;
const ncell = nrow[nc];
if (ncell === undefined) continue;
if (ncell.flagged || ncell.revealed) continue;
if (ncell.mine) hitMine = true;
// Reuse the normal flood logic so "0" cells cascade as usual.
workGrid = floodReveal(workGrid, nr, nc);
}
return { grid: workGrid, hitMine };
}
function revealAllMines(grid: Grid): Grid {
return grid.map((row) =>
row.map((cell) => (cell.mine ? { ...cell, revealed: true } : { ...cell })),
);
}
function checkWin(grid: Grid): boolean {
for (const row of grid) {
for (const cell of row) {
if (!cell.mine && !cell.revealed) return false;
}
}
return true;
}
function countFlags(grid: Grid): number {
let count = 0;
for (const row of grid) {
for (const cell of row) {
if (cell.flagged) count++;
}
}
return count;
}
/* -------------------------------------------------------------------------- */
/* Shared primitives */
/* -------------------------------------------------------------------------- */
function usePrefersReducedMotion(): boolean {
const [reduce, setReduce] = React.useState(false);
React.useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduce(mq.matches);
const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduce;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export interface MinesweeperProps {
className?: string;
width?: number;
autoFocus?: boolean;
/**
* When true (the default), the game listens for keys on `window` so it works
* without being focused first — ideal for single-game pages (404s, loading /
* empty states). Set false when multiple games share a page, or the game sits
* in scrollable content, so it only responds while focused. Ignores keys aimed
* at form fields.
*/
captureGlobalKeys?: boolean;
persistHighScore?: boolean | string;
onScoreChange?: (score: number) => void;
onGameOver?: (r: { score: number; won: boolean }) => void;
onStart?: () => void;
}
const NUMBER_COLORS: Record<number, string> = {
1: "text-blue-600 dark:text-blue-400",
2: "text-green-600 dark:text-green-500",
3: "text-red-600 dark:text-red-400",
4: "text-purple-800 dark:text-purple-400",
5: "text-red-900 dark:text-red-300",
6: "text-teal-600 dark:text-teal-400",
7: "text-black dark:text-white",
8: "text-muted-foreground",
};
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`;
}
export function Minesweeper({
className,
width,
autoFocus = true,
captureGlobalKeys = true,
persistHighScore = true,
onScoreChange,
onGameOver,
onStart,
}: MinesweeperProps) {
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
const reduce = usePrefersReducedMotion();
const storageKey =
typeof persistHighScore === "string"
? persistHighScore
: persistHighScore === false
? null
: "gamekitui:minesweeper:hi";
type GameStatus = "idle" | "playing" | "won" | "lost";
const [status, setStatus] = React.useState<GameStatus>("idle");
const [grid, setGrid] = React.useState<Grid>(createEmptyGrid);
const [elapsed, setElapsed] = React.useState(0);
const [bestTime, setBestTime] = React.useState<number | null>(null);
// Refs for timer
const startTimeRef = React.useRef<number | null>(null);
const timerIdRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
const statusRef = React.useRef<GameStatus>("idle");
statusRef.current = status;
// Long-press refs for touch flag toggling
const longPressTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressTriggeredRef = React.useRef(false);
// Load best time from localStorage
React.useEffect(() => {
if (!storageKey) return;
try {
const raw = window.localStorage.getItem(storageKey);
if (raw) {
const parsed = Number(raw);
if (!isNaN(parsed) && parsed > 0) setBestTime(parsed);
}
} catch {
/* ignore */
}
}, [storageKey]);
const saveBestTime = React.useCallback(
(seconds: number) => {
setBestTime((prev) => {
const isNew = prev === null || seconds < prev;
if (!isNew) return prev;
if (storageKey) {
try {
window.localStorage.setItem(storageKey, String(seconds));
} catch {
/* ignore */
}
}
return seconds;
});
},
[storageKey],
);
// Timer management
const stopTimer = React.useCallback(() => {
if (timerIdRef.current !== null) {
clearInterval(timerIdRef.current);
timerIdRef.current = null;
}
}, []);
const startTimer = React.useCallback(() => {
stopTimer();
startTimeRef.current = Date.now();
setElapsed(0);
timerIdRef.current = setInterval(() => {
if (startTimeRef.current !== null) {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}
}, 500);
}, [stopTimer]);
// Pause timer on visibility change
React.useEffect(() => {
const onVis = () => {
if (document.hidden) {
stopTimer();
} else if (statusRef.current === "playing" && startTimeRef.current !== null) {
// Restart timer keeping elapsed time
const alreadyElapsed = elapsed;
timerIdRef.current = setInterval(() => {
setElapsed(alreadyElapsed + Math.floor((Date.now() - (startTimeRef.current ?? Date.now())) / 1000));
}, 500);
}
};
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [stopTimer, elapsed]);
// Cleanup timer on unmount
React.useEffect(() => {
return () => stopTimer();
}, [stopTimer]);
const startGame = React.useCallback(() => {
stopTimer();
setGrid(createEmptyGrid());
setElapsed(0);
startTimeRef.current = null;
setStatus("idle");
wrapperRef.current?.focus();
}, [stopTimer]);
const handleCellReveal = React.useCallback(
(r: number, c: number) => {
if (statusRef.current === "won" || statusRef.current === "lost") return;
setGrid((prev) => {
const row = prev[r];
if (row === undefined) return prev;
const cell = row[c];
if (cell === undefined) return prev;
// Chord: activating an already-revealed number reveals its non-flagged
// neighbors when the flag count matches. Only meaningful while playing.
if (cell.revealed) {
if (cell.mine || cell.adjacentMines === 0) return prev;
if (statusRef.current !== "playing") return prev;
const { grid: chorded, hitMine } = chordReveal(prev, r, c);
if (chorded === prev) return prev; // conditions not met → no-op
if (hitMine) {
const revealed = revealAllMines(chorded);
stopTimer();
setStatus("lost");
onGameOver?.({ score: elapsed, won: false });
onScoreChange?.(elapsed);
return revealed;
}
if (checkWin(chorded)) {
stopTimer();
setStatus("won");
saveBestTime(elapsed);
onGameOver?.({ score: elapsed, won: true });
onScoreChange?.(elapsed);
return chorded;
}
return chorded;
}
if (cell.flagged) return prev;
let workGrid = prev;
// First reveal: place mines, start timer
if (statusRef.current === "idle") {
workGrid = placeMines(prev, r, c);
setStatus("playing");
startTimer();
onStart?.();
}
if (statusRef.current === "idle" || statusRef.current === "playing") {
const workRow = workGrid[r];
if (workRow === undefined) return prev;
const workCell = workRow[c];
if (workCell === undefined) return prev;
if (workCell.mine) {
// Hit a mine
const revealed = revealAllMines(workGrid);
stopTimer();
setStatus("lost");
onGameOver?.({ score: elapsed, won: false });
onScoreChange?.(elapsed);
return revealed;
}
const flooded = floodReveal(workGrid, r, c);
if (checkWin(flooded)) {
stopTimer();
setStatus("won");
saveBestTime(elapsed);
onGameOver?.({ score: elapsed, won: true });
onScoreChange?.(elapsed);
return flooded;
}
return flooded;
}
return prev;
});
},
[elapsed, onGameOver, onScoreChange, onStart, saveBestTime, startTimer, stopTimer],
);
const handleCellFlag = React.useCallback((r: number, c: number) => {
if (statusRef.current !== "playing" && statusRef.current !== "idle") return;
setGrid((prev) => {
const row = prev[r];
if (row === undefined) return prev;
const cell = row[c];
if (cell === undefined) return prev;
if (cell.revealed) return prev;
return prev.map((rowArr, ri) =>
ri === r
? rowArr.map((cellItem, ci) =>
ci === c ? { ...cellItem, flagged: !cellItem.flagged } : cellItem,
)
: rowArr,
);
});
}, []);
// Context menu prevention on the wrapper
const onContextMenu = React.useCallback((e: React.MouseEvent) => {
e.preventDefault();
}, []);
// Touch long-press handlers
const onTouchStart = React.useCallback(
(r: number, c: number) => (e: React.TouchEvent) => {
e.stopPropagation();
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
handleCellFlag(r, c);
}, 450);
},
[handleCellFlag],
);
const onTouchEnd = React.useCallback(
(r: number, c: number) => (e: React.TouchEvent) => {
e.stopPropagation();
if (longPressTimerRef.current !== null) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
if (!longPressTriggeredRef.current) {
handleCellReveal(r, c);
}
},
[handleCellReveal],
);
const onTouchCancel = React.useCallback(() => {
if (longPressTimerRef.current !== null) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
React.useEffect(() => {
if (autoFocus) wrapperRef.current?.focus();
}, [autoFocus]);
// Keyboard navigation
const [cursor, setCursor] = React.useState<[number, number]>([0, 0]);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// In global mode, ignore keys aimed at form fields / editable content.
const t = e.target as HTMLElement | null;
if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
if (status === "won" || status === "lost") {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startGame();
}
return;
}
switch (e.key) {
case "ArrowUp":
e.preventDefault();
setCursor(([r, c]) => [Math.max(0, r - 1), c]);
break;
case "ArrowDown":
e.preventDefault();
setCursor(([r, c]) => [Math.min(ROWS - 1, r + 1), c]);
break;
case "ArrowLeft":
e.preventDefault();
setCursor(([r, c]) => [r, Math.max(0, c - 1)]);
break;
case "ArrowRight":
e.preventDefault();
setCursor(([r, c]) => [r, Math.min(COLS - 1, c + 1)]);
break;
case "Enter":
case " ":
e.preventDefault();
setCursor(([r, c]) => {
handleCellReveal(r, c);
return [r, c];
});
break;
case "f":
case "F":
e.preventDefault();
setCursor(([r, c]) => {
handleCellFlag(r, c);
return [r, c];
});
break;
default:
break;
}
};
// Single-game pages can opt into window-level capture so keys work without
// focusing the game; otherwise listen on the wrapper (focus-scoped).
const target: Window | HTMLElement | null = captureGlobalKeys
? window
: wrapperRef.current;
target?.addEventListener("keydown", onKey as EventListener);
return () => {
target?.removeEventListener("keydown", onKey as EventListener);
};
}, [status, handleCellReveal, handleCellFlag, startGame, captureGlobalKeys]);
const flags = countFlags(grid);
const minesRemaining = MINE_COUNT - flags;
const statusText = React.useMemo(() => {
if (status === "idle") return "Click a cell to start. Right-click or long-press to flag.";
if (status === "won") return `You win! Time: ${formatTime(elapsed)}${bestTime !== null && elapsed <= bestTime ? " — new best!" : ""}`;
if (status === "lost") return "Game over — mine detonated!";
return `${minesRemaining >= 0 ? minesRemaining : 0} mines remaining`;
}, [status, elapsed, bestTime, minesRemaining]);
const isGameOver = status === "won" || status === "lost";
return (
<div
ref={wrapperRef}
tabIndex={0}
role="application"
aria-label="Minesweeper game"
style={width !== undefined ? { maxWidth: width } : undefined}
className={cn(
"relative flex w-full max-w-md select-none flex-col gap-2 outline-none",
// Focus ring only matters when keys are focus-scoped; global capture hides it.
!captureGlobalKeys &&
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
onContextMenu={onContextMenu}
>
{/* Score badges */}
<div className="flex items-center justify-between gap-3 text-sm">
<span
className="inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums"
title="Mines remaining"
>
{/* Mine marker: a small diamond shape via CSS */}
<span className="mr-1 inline-block h-2.5 w-2.5 rounded-sm bg-foreground opacity-70" aria-hidden="true" />
{Math.max(0, minesRemaining)}
</span>
<span
className="inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums"
title="Elapsed time"
>
{formatTime(elapsed)}
</span>
{bestTime !== null && (
<span className="text-muted-foreground tabular-nums" title="Best time">
Best {formatTime(bestTime)}
</span>
)}
</div>
{/* Grid */}
<div className="relative rounded-lg border bg-background overflow-hidden">
<div
role="grid"
aria-label="Minesweeper grid"
className="@container grid grid-cols-9 grid-rows-9 w-full aspect-square gap-[1.5%]"
>
{grid.map((row, r) =>
row.map((cell, c) => {
const isCursor = cursor[0] === r && cursor[1] === c;
const numColor = cell.adjacentMines > 0 ? (NUMBER_COLORS[cell.adjacentMines] ?? "text-foreground") : "";
return (
<button
key={`${r}-${c}`}
type="button"
role="gridcell"
aria-label={
cell.revealed
? cell.mine
? "Mine"
: cell.adjacentMines > 0
? `${cell.adjacentMines} adjacent mines`
: "Empty cell"
: cell.flagged
? "Flagged cell"
: "Hidden cell"
}
aria-pressed={cell.flagged}
// Revealed numbered cells stay enabled so they can be
// chorded; revealed empties/mines and game-over disable.
disabled={
isGameOver ||
(cell.revealed && (cell.mine || cell.adjacentMines === 0))
}
onClick={() => {
if (cell.revealed) {
// Chord on a revealed number.
if (!cell.mine && cell.adjacentMines > 0) {
handleCellReveal(r, c);
}
} else if (!cell.flagged) {
handleCellReveal(r, c);
}
}}
onContextMenu={(e) => {
e.preventDefault();
handleCellFlag(r, c);
}}
onTouchStart={onTouchStart(r, c)}
onTouchEnd={onTouchEnd(r, c)}
onTouchCancel={onTouchCancel}
className={cn(
"relative flex size-full items-center justify-center border-r border-b border-border",
"text-[clamp(0.5rem,6cqi,1rem)] font-bold",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
// Revealed mine (loss) → destructive
cell.revealed && cell.mine && "bg-destructive text-destructive-foreground",
// Revealed safe cell → background. Numbered cells stay
// clickable (chord), empties are inert.
cell.revealed && !cell.mine && "bg-background",
cell.revealed && !cell.mine && cell.adjacentMines === 0 && "cursor-default",
cell.revealed && !cell.mine && cell.adjacentMines > 0 && !isGameOver && "cursor-pointer",
// Covered, flagged
!cell.revealed && cell.flagged && "bg-secondary text-secondary-foreground",
// Covered, not flagged, hoverable
!cell.revealed && !cell.flagged && !isGameOver && "bg-muted hover:bg-accent cursor-pointer",
// Covered, game over (lost), no flag → show as muted
!cell.revealed && !cell.flagged && isGameOver && "bg-muted cursor-default",
// Keyboard cursor highlight
isCursor && !cell.revealed && "ring-1 ring-inset ring-ring",
// Reveal animation — only when not reduced motion
!reduce && cell.revealed && !cell.mine && "transition-colors duration-100",
)}
>
{cell.revealed && !cell.mine && cell.adjacentMines > 0 && (
<span className={numColor} aria-hidden="true">
{cell.adjacentMines}
</span>
)}
{cell.revealed && cell.mine && (
// Mine: small filled circle styled with destructive contrast, scales with cell
<span
className="inline-block rounded-full bg-destructive-foreground"
style={{ width: "40%", height: "40%" }}
aria-hidden="true"
/>
)}
{!cell.revealed && cell.flagged && (
// Flag: a vertical bar (pole) + triangle top via CSS borders, scales with cell
<span
className="inline-flex items-center justify-center"
aria-hidden="true"
>
<span
className="inline-block rounded-sm bg-primary"
style={{ width: "30%", height: "50%" }}
/>
</span>
)}
</button>
);
}),
)}
</div>
{/* Game-over overlay (won/lost). At idle the board is shown and the first
click starts the game — no blocking overlay, like classic minesweeper. */}
{isGameOver && (
<div className="absolute inset-0 grid place-items-center bg-background/80 backdrop-blur-[1px]">
<div className="flex flex-col items-center gap-3 px-6 text-center">
{status === "won" ? (
<>
<p className="font-semibold text-lg">You win!</p>
<p className="text-muted-foreground text-sm">
Cleared in {formatTime(elapsed)}
{bestTime !== null && elapsed <= bestTime ? " — new best!" : ""}
</p>
</>
) : (
<>
<p className="font-semibold text-lg">Game over</p>
<p className="text-muted-foreground text-sm">A mine was detonated.</p>
</>
)}
<button
type="button"
onClick={startGame}
className={cn(
"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 font-medium text-primary-foreground text-sm",
"transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
Play again
</button>
</div>
</div>
)}
</div>
{/* aria-live region */}
<p
className="text-center text-sm text-muted-foreground min-h-[1.25rem]"
aria-live="polite"
aria-atomic="true"
>
{statusText}
</p>
</div>
);
}
export default Minesweeper;