A few days after I published Part 2, a friend forwarded me a message. He’s been reading the blog on his phone during his commute, decided to open the live demo, and found that he couldn’t flag a single cell.
He’s not wrong. On a touchscreen, right-click doesn’t exist. Context menus require a long-press that the browser intercepts before the game sees it. The entire flag mechanic – one of the two core interactions in Minesweeper – is simply inaccessible to anyone not sitting at a desk with a mouse.
I’d known this. I’d filed it in the back of my mind under “later.” The message from my friend gave me an excuse to create a part three.
This post is about using the fix as an opportunity to add something the desktop version also doesn’t have: chording, and the suspect mine marker. If you’ve only played casual Minesweeper, chording might be unfamiliar. Once you’ve flagged all the mines around a revealed numbered cell, you can “chord” it – click it again to automatically reveal all its unflagged neighbors. It’s the technique that separates fast players from slow ones. On desktop it’s typically a middle-click or a double-click. On mobile, we’ll surface it through the same mechanism as flagging.
The approach: a touch action menu. When a player long-presses any cell on a touch device, a small contextual overlay appears with the available actions for that cell. No right-click required. No ambiguity about what a tap means.
This is a refactoring session, not a greenfield one. The architecture from Parts 1 and Part 2 is already in place – good architecture should make adding features like this mechanical rather than heroic. We’re about to find out how well it holds.
The Problem statement
> I want to add mobile touch support to the Minesweeper game from Parts 1 and 2.
The problem: on touch devices, there's no right-click, so players can't flag cells.
My proposed solution: a touch action menu.
When a player long-presses any cell (held ~500 ms), a small overlay appears with:
- Reveal (if the cell is hidden and not flagged or suspected)
- Chord (if the cell is revealed and has adjacent mines > 0)
- Flag (if the cell is hidden — toggles the flag state)
- Suspect (if the cell is hidden — marks it with ? as "maybe a mine")
- Close (always — dismisses the menu)
Before we write anything, tell me what needs to change in the existing architecture.
Claude:
Good framing. Here's the audit of what needs to change across each layer.
## Data Model
TheCellobject needs one new field:
{
isMine: boolean,
isRevealed: boolean,
isFlagged: boolean,
isSuspect: boolean, // ← new: marks cell as "maybe a mine" (shows ?)
adjacentMines: number,
}isSuspectis mutually exclusive withisFlagged— a cell can be flagged, suspected, or neither, but not both. The cycle is: none → flagged → suspect → none.
## Engine layer
One new pure function ingameEngine.js:
chordCell(board, row, col) → board
Logic: if the target cell is revealed and itsadjacentMinescount exactly equals the number of flagged neighbours, reveal all unflagged, unrevealed, non-mine-flagged neighbours in one operation. If the counts don't match, return the board unchanged (safety guard).boardFactory.jsneedscreateCellupdated to includeisSuspect: false.
## Hook layeruseGameState.jsgets two additions:
-handleChord(row, col)— callschordCell, applies win/loss detection.
-handleTouchAction(action, row, col)— dispatches to the right handler based on the action string ('reveal','chord','flag','suspect').
Also needstouchMenuCellstate:{ row, col } | null— which cell currently has the menu open. Null means no menu.
## New hookuseLongPress.js— reusable hook that wraps touch event handling. ReturnsonTouchStartandonTouchEndhandlers. Fires a callback after a configurable delay (default 500 ms) if the touch hasn't moved or ended.
## New componentTouchActionMenu.jsx— a positioned overlay component. Receives the target cell, its position on screen, and callbacks for each action. Renders only the contextually valid options (e.g., "Reveal" is hidden for already-revealed cells; "Chord" is hidden for cells with no adjacent mines).
## Component layerCell.jsxgetsonLongPressandonTouchActionprops. It usesuseLongPressinternally to detect the hold gesture and opens the menu via a callback to the parent. The left-click behaviour on desktop remains unchanged.
No changes needed toGameBoard,GameControls, orGameStatus.
This is the response I was hoping for. The audit pattern – before you touch anything, enumerate what needs to change and where – is something I’ve tried to instill in engineers throughout my career, with mixed success. The natural instinct when asked to add a feature is to find the most obvious file and start editing. That instinct is usually wrong.
The answer here correctly identifies that this change has four distinct scopes: the data model, the engine, the hook, and a new UI layer. It correctly identifies that
GameBoard,GameControls, andGameStatusare untouched – which is the architectural dividend we bought in Part 1. It correctly flags thatisSuspectandisFlaggedneed mutual exclusivity rules, which is a subtle constraint that a less thorough analysis would have missed and that would have shown up as a confusing bug later.The introduction of
useLongPressas a separate, reusable hook rather than embedding the touch logic directly inCellis also the right call. Long-press detection is generic. It doesn’t know anything about Minesweeper. Separating it means it’s independently testable and could be reused elsewhere. This is the Interface Segregation Principle applied to hooks.
Session transcript
Prompt 1 – Extend the data model
> Update boardFactory.js to add isSuspect: false to createCell.
Also update toggleFlag to cycle: none → flagged → suspect → none.
Show me the updated functions only — don't regenerate the whole file.
Claude produced the updated functions:
// boardFactory.js — updated createCell
export const createCell = () => ({
isMine: false,
isRevealed: false,
isFlagged: false,
isSuspect: false, // new
adjacentMines: 0,
});
And moved toggleFlag from gameEngine.js into a richer cycle:
// gameEngine.js — updated toggleFlag
/**
* Cycles the mark state of a hidden cell: none → flagged → suspect → none.
* Already-revealed cells are not affected.
*
* @param {Cell[][]} board
* @param {number} row
* @param {number} col
* @returns {Cell[][]}
*/
export const toggleFlag = (board, row, col) => {
const cell = board[row][col];
if (cell.isRevealed) return board;
let update;
if (!cell.isFlagged && !cell.isSuspect) {
update = { isFlagged: true, isSuspect: false }; // none → flagged
} else if (cell.isFlagged) {
update = { isFlagged: false, isSuspect: true }; // flagged → suspect
} else {
update = { isFlagged: false, isSuspect: false }; // suspect → none
}
return board.map((r, ri) =>
r.map((c, ci) =>
ri === row && ci === col ? { ...c, ...update } : c
)
);
};
Cycling the flag state through three values is a pattern most implementations handle incorrectly. The common approach is a boolean toggle – flagged or not – which means suspect is either bolted on as a second independent boolean (creating illegal states like
isFlagged && isSuspect === true) or ignored entirely. The three-way cycle with explicit state transitions is clean and produces no illegal states.I notice it correctly left the rest of
toggleFlag‘s logic unchanged – the early return for revealed cells, the immutable board transformation. It updated precisely what needed updating and nothing else. That’s a harder discipline than it sounds when you’re editing existing code.
Prompt 2 – Add chording to the engine
> Add chordCell(board, row, col) to gameEngine.js.
Rules:
- If the cell is not revealed, return board unchanged.
- If adjacentMines is 0, return board unchanged (nothing to chord).
- Count flagged neighbours. If count !== adjacentMines, return board unchanged.
- Otherwise reveal all unflagged, unrevealed, non-mine neighbours using the
existing revealCell logic (which handles BFS if any revealed neighbour is empty).
Keep it pure. Add JSDoc.
Claude:
/**
* Chords the cell at (row, col): if the number of flagged neighbours exactly
* matches the cell's adjacentMines count, reveals all remaining hidden,
* unflagged neighbours. Returns board unchanged if the preconditions aren't met.
*
* This is the "satisfying" move — when you've correctly identified all mines
* around a number, chord clears the rest in one action.
*
* @param {Cell[][]} board
* @param {number} row
* @param {number} col
* @returns {Cell[][]}
*/
export const chordCell = (board, row, col) => {
const cell = board[row][col];
if (!cell.isRevealed || cell.adjacentMines === 0) return board;
const rows = board.length;
const cols = board[0].length;
const neighbours = [];
let flaggedCount = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = row + dr;
const nc = col + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
const neighbour = board[nr][nc];
if (neighbour.isFlagged) flaggedCount++;
neighbours.push([nr, nc]);
}
}
// Guard: only chord when the flag count matches exactly.
if (flaggedCount !== cell.adjacentMines) return board;
// Reveal each unflagged, unrevealed neighbour via the existing BFS logic.
let updatedBoard = board;
for (const [nr, nc] of neighbours) {
const neighbour = updatedBoard[nr][nc];
if (!neighbour.isFlagged && !neighbour.isRevealed) {
updatedBoard = revealCell(updatedBoard, nr, nc);
}
}
return updatedBoard;
};
There’s a subtlety here worth calling out. Chording that hits a mine is a legitimate game outcome – if the player placed a flag on the wrong cell, the chord will reveal an actual mine and the game is lost. The implementation above correctly hands each reveal back through
revealCell, which preserves all of the existing mine-detection logic in the hook. The engine function doesn’t need to know about win or loss – it just reveals cells. The hook checks the outcome.This is the Dependency Inversion Principle working quietly: the engine depends on its own abstractions, not on the game-state orchestration layer above it.
chordCelldoesn’t know what happens when you hit a mine – that knowledge belongs one layer up.I also notice that using the existing
revealCell(which already handles BFS flood-fill) means that chording into an empty region works correctly for free. If one of the unchained neighbors has zero adjacent mines, the reveal cascades outward just as a direct click would. This is the right behavior and it came at no additional cost because the engine was already pure and composable.
Prompt 3 – The long-press hook
> Create src/hooks/useLongPress.js.
It should return { onTouchStart, onTouchEnd, onTouchMove } handlers.
The caller passes a callback and an optional delay (default 500ms).
The callback fires only if the touch is held for the full delay without moving.
onTouchMove cancels the timer (we don't want false positives from scrolling).
No React-specific imports beyond useState and useRef. No side effects outside of refs.
Claude:
import { useRef, useCallback } from 'react';
/**
* useLongPress
*
* Detects a long-press (sustained touch without movement) and fires a callback.
* Cancels if the touch moves (prevents false positives during scroll).
*
* @param {Function} callback - Called when the long press threshold is reached.
* @param {number} delay - Milliseconds to hold before firing (default 500).
* @returns {{ onTouchStart, onTouchEnd, onTouchMove }}
*/
export const useLongPress = (callback, delay = 500) => {
const timerRef = useRef(null);
const activeRef = useRef(false);
const start = useCallback((e) => {
// Prevent the browser's built-in long-press context menu.
e.preventDefault();
activeRef.current = true;
timerRef.current = setTimeout(() => {
if (activeRef.current) {
callback(e);
}
}, delay);
}, [callback, delay]);
const cancel = useCallback(() => {
activeRef.current = false;
clearTimeout(timerRef.current);
}, []);
return {
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: cancel,
};
};
The
e.preventDefault()on touch start is load-bearing. Without it, mobile browsers fire their own long-press context menu after roughly ~500 millisecond of holding, which would overlay our custom menu or interfere with the event chain. Suppressing the default gives us full control.The
activeRefpattern is worth noting. We can’t close over a boolean instartand expect it to reflect state changes made incancel, because both functions are created once and the closure captures the value at creation time. A ref survives across renders and mutations are immediately visible to all closures that hold a reference to it. This is one of those ReactJS specific subtleties that bites people when they first reach for refs.
useLongPressknows nothing about Minesweeper. It doesn’t know what action to take. It doesn’t know what a cell is. It receives a callback, detects a gesture, and calls the callback. That’s all it does. At 25 lines it is almost certainly not going to require a rewrite.
Prompt 4 – The touch action menu component
> Create src/components/TouchActionMenu/TouchActionMenu.jsx.
Props:
- cell: the Cell data object
- position: { x, y } in viewport pixels (where to render)
- onAction: function(actionName) — called when the user picks an option
- onClose: function() — called when the user dismisses the menu
Render only the contextually valid actions:
- "reveal" — only if cell is not revealed, not flagged, not suspected
- "chord" — only if cell is revealed and adjacentMines > 0
- "flag" — only if cell is not revealed (label changes based on state:
"Unflag" if flagged, "Flag" otherwise)
- "suspect" — only if cell is not revealed (label: "Unsuspect" if suspected,
"Suspect" otherwise)
- A dismiss button is always rendered.
The menu should be absolutely positioned at the given coordinates.
No game logic inside the component — pure display + delegation.
Claude:
import React, { useEffect, useRef } from 'react';
import './TouchActionMenu.css';
/**
* TouchActionMenu
*
* A contextual floating action menu that appears on long-press.
* Renders only the actions that are valid for the current cell state.
* Delegates all game logic to the caller via onAction / onClose.
*
* @param {object} props
* @param {Cell} props.cell - Cell data.
* @param {{x,y}} props.position - Viewport coordinates to anchor the menu.
* @param {Function} props.onAction - Called with action string on selection.
* @param {Function} props.onClose - Called when the menu is dismissed.
*/
const TouchActionMenu = ({ cell, position, onAction, onClose }) => {
const menuRef = useRef(null);
// Close the menu if the user taps outside it.
useEffect(() => {
const handleOutsideClick = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
onClose();
}
};
document.addEventListener('pointerdown', handleOutsideClick);
return () => document.removeEventListener('pointerdown', handleOutsideClick);
}, [onClose]);
const actions = [];
if (!cell.isRevealed && !cell.isFlagged && !cell.isSuspect) {
actions.push({ key: 'reveal', label: 'Reveal' });
}
if (cell.isRevealed && cell.adjacentMines > 0) {
actions.push({ key: 'chord', label: 'Chord' });
}
if (!cell.isRevealed) {
actions.push({
key: 'flag',
label: cell.isFlagged ? 'Unflag' : 'Flag',
});
actions.push({
key: 'suspect',
label: cell.isSuspect ? 'Unsuspect' : 'Suspect',
});
}
return (
<div
ref={menuRef}
className="touch-action-menu"
style={{ top: position.y, left: position.x }}
role="dialog"
aria-label="Cell actions"
>
{actions.map(({ key, label }) => (
<button
key={key}
className="touch-action-menu__item"
onClick={() => { onAction(key); onClose(); }}
>
{label}
</button>
))}
<button
className="touch-action-menu__item touch-action-menu__item--close"
onClick={onClose}
>
Close
</button>
</div>
);
};
export default TouchActionMenu;
And the stylesheet:
/* TouchActionMenu.css */
.touch-action-menu {
position: fixed;
z-index: 1000;
background: #ffffff;
border: 2px solid #7b7b7b;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
min-width: 140px;
overflow: hidden;
/* Nudge left so the menu doesn't clip the right viewport edge */
transform: translateX(-50%);
}
.touch-action-menu__item {
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 1px solid #e0e0e0;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
font-weight: bold;
cursor: pointer;
text-align: left;
color: #000000;
}
.touch-action-menu__item:last-child {
border-bottom: none;
}
.touch-action-menu__item:hover,
.touch-action-menu__item:active {
background: #c0c0c0;
}
.touch-action-menu__item--close {
color: #7b7b7b;
font-weight: normal;
}
The
useEffectfor outside-click detection is a pattern I’ve seen implemented wrong more often than right. The two common mistakes: attaching aclicklistener (which fires before the user has lifted their finger and causes the menu to immediately self-close on the same touch that opened it), and forgetting the cleanup function (which leaves orphaned listeners after the component unmounts). Both are present-and-correct here.Using
pointerdownrather thantouchstartorclickis a deliberate choice –pointerdownfires on mouse, touch, and stylus in a unified event model. The menu can be dismissed by any input method.The position calculation uses
fixedrather thanabsolute. This is important: the game board scrolls on small screens. Anabsolutepositioned menu would be anchored to the document and could end up outside the viewport when the board is scrolled.fixedis anchored to the viewport, which is where the touch coordinates live.I’ve made the
absolute/fixedmistake myself on a mapping application, years ago. The bug manifested only on devices with enough content to scroll — impossible to reproduce on desktop, trivial to reproduce on any phone. Memory has a way of encoding those moments.
Prompt 5 – Update Cell to use useLongPress
> Update Cell.jsx to accept two new optional props:
- onLongPress: function(row, col, touchPosition) — fires on long press.
touchPosition is { x, y } from the touch event.
- onChord: function(row, col) — fires on standard left-click of a revealed
numbered cell (chording by click, for desktop users who prefer it).
If onLongPress is not provided the component should behave exactly as before.
Keep the existing onLeftClick and onRightClick props working.
Claude produced the updated component:
import React from 'react';
import { ADJACENT_MINE_COLORS } from '../../constants/gameConfig';
import { useLongPress } from '../../hooks/useLongPress';
import './Cell.css';
const getCellContent = (cell) => {
if (!cell.isRevealed) {
if (cell.isFlagged) return '🚩';
if (cell.isSuspect) return '❓';
return '';
}
if (cell.isMine) return '💣';
if (cell.adjacentMines > 0) return cell.adjacentMines;
return '';
};
const Cell = ({
cell,
row,
col,
onLeftClick,
onRightClick,
onLongPress,
onChord,
}) => {
const handleContextMenu = (e) => {
e.preventDefault();
onRightClick(row, col);
};
const handleClick = () => {
// Revealed numbered cells trigger chord on click when onChord is provided.
if (cell.isRevealed && cell.adjacentMines > 0 && onChord) {
onChord(row, col);
} else {
onLeftClick(row, col);
}
};
const handleLongPress = onLongPress
? (e) => {
const touch = e.changedTouches?.[0] ?? e.touches?.[0];
const position = touch
? { x: touch.clientX, y: touch.clientY }
: { x: 0, y: 0 };
onLongPress(row, col, position);
}
: null;
const longPressHandlers = useLongPress(
handleLongPress ?? (() => {}),
500
);
const content = getCellContent(cell);
const color =
cell.isRevealed && !cell.isMine && cell.adjacentMines > 0
? ADJACENT_MINE_COLORS[cell.adjacentMines]
: undefined;
const classNames = [
'cell',
cell.isRevealed ? 'cell--revealed' : 'cell--hidden',
cell.isMine && cell.isRevealed ? 'cell--mine' : '',
]
.filter(Boolean)
.join(' ');
return (
<button
className={classNames}
onClick={handleClick}
onContextMenu={handleContextMenu}
style={{ color }}
aria-label={`Cell ${row}-${col}${cell.isFlagged ? ' flagged' : ''}${
cell.isSuspect ? ' suspected' : ''
}${cell.isRevealed ? ' revealed' : ''}`}
aria-pressed={cell.isRevealed}
{...(onLongPress ? longPressHandlers : {})}
>
{content}
</button>
);
};
export default React.memo(Cell);
The spread of
longPressHandlersonly whenonLongPressis provided is the detail that keeps the component backward-compatible. Desktop users who don’t trigger a long-press still work exactly as before – the touch handlers are simply not attached. This is one of the rare cases where conditional prop spreading is cleaner than the alternative.The chord-on-click behavior for desktop is a small but considered addition: on desktop, clicking a revealed numbered cell with the correct flags in place will chord. This is consistent with how most modern Minesweeper implementations work. It doesn’t break the existing behavior for unrevealed cells – the
onLeftClickpath is still taken for all other clicks.
Prompt 6 – Wire everything into useGameState
> Update useGameState.js to:
1. Add handleChord(row, col) — calls chordCell, checks win/loss, returns nothing.
2. Add touchMenuCell state ({ row, col, position } | null).
3. Add openTouchMenu(row, col, position) — sets touchMenuCell.
4. Add closeTouchMenu() — clears touchMenuCell.
5. Add handleTouchAction(action, row, col) — dispatches to the right handler:
'reveal' → handleCellClick
'chord' → handleChord
'flag' → handleCellRightClick (existing, now cycles to suspect too)
'suspect' → handleCellRightClick (same cycle, different entry point)
Expose all new values/functions in the return object.
Claude produced the updated hook, showing only the additions:
// New import
import { chordCell } from '../engine/gameEngine';
// New state (inside useGameState)
const [touchMenuCell, setTouchMenuCell] = useState(null);
/**
* Chords the cell at (row, col).
* If the chord hits a mine, transitions to LOST.
* Checks for win condition after a successful chord.
*/
const handleChord = useCallback((row, col) => {
if (gameStatus !== GAME_STATUS.PLAYING) return;
setBoard((currentBoard) => {
const chorded = chordCell(currentBoard, row, col);
if (chorded === currentBoard) return currentBoard; // guard returned unchanged
// Check whether any mine was revealed (loss condition).
const mineTouched = chorded.some((r) =>
r.some((c) => c.isMine && c.isRevealed)
);
if (mineTouched) {
setGameStatus(GAME_STATUS.LOST);
return revealAllMines(chorded);
}
if (checkWinCondition(chorded)) {
setGameStatus(GAME_STATUS.WON);
}
return chorded;
});
}, [gameStatus]);
/**
* Opens the touch action menu for the given cell.
*/
const openTouchMenu = useCallback((row, col, position) => {
if (gameStatus === GAME_STATUS.WON || gameStatus === GAME_STATUS.LOST) return;
setTouchMenuCell({ row, col, position });
}, [gameStatus]);
/**
* Closes the touch action menu.
*/
const closeTouchMenu = useCallback(() => {
setTouchMenuCell(null);
}, []);
/**
* Dispatches a touch menu action to the appropriate handler.
* Called after the player selects an option from TouchActionMenu.
*/
const handleTouchAction = useCallback((action, row, col) => {
switch (action) {
case 'reveal': handleCellClick(row, col); break;
case 'chord': handleChord(row, col); break;
case 'flag': handleCellRightClick(row, col); break;
case 'suspect': handleCellRightClick(row, col); break;
default: break;
}
}, [handleCellClick, handleChord, handleCellRightClick]);
// Updated return object
return {
board,
gameStatus,
difficulty,
minesLeft,
time,
touchMenuCell,
startNewGame,
handleCellClick,
handleCellRightClick,
handleChord,
openTouchMenu,
closeTouchMenu,
handleTouchAction,
};
The guard
if (chorded === currentBoard) return currentBoarddeserves attention.chordCellreturns the original board reference when the preconditions aren’t satisfied – a standard pattern in functional code where returning a reference instead of a copy signals “nothing changed.” Checking referential equality is a cheap, correct way to short-circuit state updates. ReactJS’suseStatesetter performs a shallow equality check on the returned value and skips a re-render if the state didn’t change, but that check happens after our setter returns – we want to avoid even the mine-detection loop in this case.
'flag'and'suspect'both route tohandleCellRightClick. This is intentional. The three-way cycle defined intoggleFlagmeans successive calls will cycle the state correctly regardless of which menu option the user chose. The distinction between “Flag” and “Suspect” in the UI is presentational – “which label makes sense to show right now” – not a distinction in the underlying action.
Prompt 7 – Render the menu in App.jsx
> Update App.jsx to:
1. Destructure touchMenuCell, openTouchMenu, closeTouchMenu, handleTouchAction
from useGameState.
2. Pass onLongPress={openTouchMenu} and onChord={handleChord} to GameBoard
(which forwards them to each Cell).
3. Render <TouchActionMenu> when touchMenuCell is not null, positioned at
touchMenuCell.position, with the correct cell data.
Show only the changed portions.
Claude:
// App.jsx — updated destructuring
const {
board,
gameStatus,
difficulty,
minesLeft,
time,
touchMenuCell, // new
startNewGame,
handleCellClick,
handleCellRightClick,
handleChord, // new
openTouchMenu, // new
closeTouchMenu, // new
handleTouchAction, // new
} = useGameState('BEGINNER');
// In the JSX return:
return (
<div className="app">
<GameControls ... />
<GameStatus ... />
<GameBoard
board={board}
onCellClick={handleCellClick}
onCellRightClick={handleCellRightClick}
onCellLongPress={openTouchMenu} // new
onCellChord={handleChord} // new
/>
{touchMenuCell && (
<TouchActionMenu
cell={board[touchMenuCell.row][touchMenuCell.col]}
position={touchMenuCell.position}
onAction={(action) =>
handleTouchAction(action, touchMenuCell.row, touchMenuCell.col)
}
onClose={closeTouchMenu}
/>
)}
</div>
);
And GameBoard.jsx updated to forward the new props:
// GameBoard.jsx — updated Cell rendering
<Cell
key={`${rowIdx}-${colIdx}`}
cell={cell}
row={rowIdx}
col={colIdx}
onLeftClick={onCellClick}
onRightClick={onCellRightClick}
onLongPress={onCellLongPress} // new
onChord={onCellChord} // new
/>
The conditional render
{touchMenuCell && <TouchActionMenu ... />}is the React idiom for “render only when there’s something to render.” TheTouchActionMenuis not in the tree at all when no menu is open, which means itsuseEffectcleanup never needs to fire spuriously. Clean mount/unmount semantics.One thing to note:
board[touchMenuCell.row][touchMenuCell.col]reads the cell at render time, not at the time the menu was opened. This is correct behavior – if the game state somehow changes between menu open and render, the menu reflects current reality. In practice this won’t happen (the game is paused conceptually while a menu is open) but the code is correct in edge cases without any special handling.
Prompt 8 — Tests for chordCell
> Write tests for chordCell in gameEngine.test.js.
Scenarios:
- Returns board unchanged when cell is not revealed.
- Returns board unchanged when adjacentMines is 0.
- Returns board unchanged when flagged neighbour count < adjacentMines.
- Returns board unchanged when flagged neighbour count > adjacentMines.
- Reveals all unflagged, unrevealed neighbours when count matches exactly.
- Does not reveal flagged neighbours.
- Triggers BFS cascade if a chorded neighbour has 0 adjacent mines.
- Does not chord when a neighbour is already revealed.
Claude produced the test suite:
// gameEngine.test.js — chordCell section (added to existing file)
describe('chordCell', () => {
// Helper: build a small 3×3 board with a revealed centre cell.
// The centre cell at (1,1) has 1 adjacent mine (at 0,0).
// Cell (0,0) is the mine, flagged.
// Cells (0,1), (0,2), (1,0), (1,2), (2,0), (2,1), (2,2) are hidden/safe.
const buildChordBoard = (overrides = {}) => {
const board = createEmptyBoard(3, 3);
// Place a mine at top-left
board[0][0] = { ...board[0][0], isMine: true, isFlagged: true };
// Reveal the centre and set adjacency
board[1][1] = { ...board[1][1], isRevealed: true, adjacentMines: 1 };
// Apply any caller overrides
Object.entries(overrides).forEach(([key, val]) => {
const [r, c, field] = key.split(',');
board[Number(r)][Number(c)] = { ...board[Number(r)][Number(c)], [field]: val };
});
return board;
};
it('returns board unchanged when target cell is not revealed', () => {
const board = buildChordBoard();
board[1][1] = { ...board[1][1], isRevealed: false };
expect(chordCell(board, 1, 1)).toBe(board);
});
it('returns board unchanged when adjacentMines is 0', () => {
const board = buildChordBoard();
board[1][1] = { ...board[1][1], adjacentMines: 0 };
expect(chordCell(board, 1, 1)).toBe(board);
});
it('returns board unchanged when flagged count is less than adjacentMines', () => {
// Remove the flag — now flagged count is 0, adjacentMines is 1
const board = buildChordBoard();
board[0][0] = { ...board[0][0], isFlagged: false };
expect(chordCell(board, 1, 1)).toBe(board);
});
it('returns board unchanged when flagged count exceeds adjacentMines', () => {
// Add a second flag
const board = buildChordBoard();
board[0][1] = { ...board[0][1], isFlagged: true };
expect(chordCell(board, 1, 1)).toBe(board);
});
it('reveals all unflagged hidden neighbours when count matches exactly', () => {
const board = buildChordBoard();
const result = chordCell(board, 1, 1);
// (0,0) is the mine and flagged — should remain unrevealed
expect(result[0][0].isRevealed).toBe(false);
// All other safe neighbours should be revealed
const safe = [[0,1],[0,2],[1,0],[1,2],[2,0],[2,1],[2,2]];
safe.forEach(([r,c]) => expect(result[r][c].isRevealed).toBe(true));
});
it('does not reveal flagged neighbours', () => {
const board = buildChordBoard();
const result = chordCell(board, 1, 1);
expect(result[0][0].isRevealed).toBe(false);
expect(result[0][0].isFlagged).toBe(true);
});
it('does not re-reveal already-revealed neighbours', () => {
const board = buildChordBoard();
board[0][1] = { ...board[0][1], isRevealed: true, adjacentMines: 1 };
const result = chordCell(board, 1, 1);
// Already revealed — unchanged reference is fine
expect(result[0][1].isRevealed).toBe(true);
});
it('cascades BFS when a chorded neighbour has zero adjacent mines', () => {
// 3×3 board: mine at (0,0), flagged. Centre (1,1) revealed, adjacentMines=1.
// (2,2) has 0 adjacent mines — chorded reveal should cascade into its neighbours.
const board = buildChordBoard();
board[2][2] = { ...board[2][2], adjacentMines: 0 };
const result = chordCell(board, 1, 1);
// BFS should have cascaded from (2,2) — all reachable cells should be revealed.
expect(result[2][2].isRevealed).toBe(true);
});
});
Eight scenarios for one function. That sounds like a lot until you consider that chording is one of the moves most likely to destroy your game – miscount your flags, chord, hit a mine. A function with this many guards needs this many tests.
The
buildChordBoardhelper is worth examining. It creates a known, deterministic board state in a few lines and accepts an override map for targeted variations. This is the pattern I advocated for in Part 1‘s discussion of test strategy – deterministic boards, precisely configured for the scenario under test. Every test below it is immediately readable: here’s the board, here’s the action, here’s the expected outcome.The
toBe(board)assertion – reference equality – is the correct check for “function returned unchanged board.” It’s tighter than a deep equality check and carries the additional semantic meaning: the function detected an unsatisfied precondition and made a deliberate choice not to do anything.
Prompt 9 – Tests for toggleFlag three-way cycle
> Add tests to gameEngine.test.js covering the updated toggleFlag cycle:
- none → flagged
- flagged → suspect
- suspect → none
- Revealed cells are not affected at any stage.
- isFlagged and isSuspect are never both true simultaneously.
Claude:
describe('toggleFlag — three-way cycle', () => {
const freshCell = { isMine: false, isRevealed: false, isFlagged: false, isSuspect: false, adjacentMines: 0 };
const makeBoard = (cellOverride = {}) => [[{ ...freshCell, ...cellOverride }]];
it('cycles none → flagged', () => {
const result = toggleFlag(makeBoard(), 0, 0);
expect(result[0][0].isFlagged).toBe(true);
expect(result[0][0].isSuspect).toBe(false);
});
it('cycles flagged → suspect', () => {
const result = toggleFlag(makeBoard({ isFlagged: true }), 0, 0);
expect(result[0][0].isFlagged).toBe(false);
expect(result[0][0].isSuspect).toBe(true);
});
it('cycles suspect → none', () => {
const result = toggleFlag(makeBoard({ isSuspect: true }), 0, 0);
expect(result[0][0].isFlagged).toBe(false);
expect(result[0][0].isSuspect).toBe(false);
});
it('does not affect a revealed cell', () => {
const board = makeBoard({ isRevealed: true });
expect(toggleFlag(board, 0, 0)).toBe(board);
});
it('never produces a state where both isFlagged and isSuspect are true', () => {
let board = makeBoard();
for (let i = 0; i < 9; i++) {
board = toggleFlag(board, 0, 0);
expect(board[0][0].isFlagged && board[0][0].isSuspect).toBe(false);
}
});
});
That last test – cycling nine times and asserting the invariant holds at every step – is the kind of test that looks paranoid until it catches a bug. The invariant is “both can never be true simultaneously.” Testing it across multiple cycles guarantees it holds not just at each transition but through entire sequences. A state machine implementation that works for three steps and breaks on the fourth is a realistic failure mode.
Prompt 10 — Tests for TouchActionMenu
> Write tests for TouchActionMenu.jsx.
Scenarios:
- Renders Reveal for a plain hidden cell.
- Does not render Reveal for a flagged cell.
- Does not render Reveal for a revealed cell.
- Renders Chord for a revealed cell with adjacentMines > 0.
- Does not render Chord for a revealed cell with adjacentMines === 0.
- Renders Flag (label "Flag") for a hidden unflagged cell.
- Renders Flag (label "Unflag") for a flagged cell.
- Renders Suspect for a hidden cell.
- Renders Suspect (label "Unsuspect") for a suspected cell.
- Clicking an action button calls onAction with the right key and then onClose.
- Clicking Close calls onClose without calling onAction.
- Close button is always rendered.
Claude:
// TouchActionMenu.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import TouchActionMenu from './TouchActionMenu';
const baseCell = {
isMine: false, isRevealed: false, isFlagged: false,
isSuspect: false, adjacentMines: 0,
};
const pos = { x: 100, y: 200 };
const renderMenu = (cellOverride = {}, handlers = {}) => {
const onAction = handlers.onAction ?? jest.fn();
const onClose = handlers.onClose ?? jest.fn();
render(
<TouchActionMenu
cell={{ ...baseCell, ...cellOverride }}
position={pos}
onAction={onAction}
onClose={onClose}
/>
);
return { onAction, onClose };
};
describe('TouchActionMenu — action visibility', () => {
it('shows Reveal for a plain hidden cell', () => {
renderMenu();
expect(screen.getByText('Reveal')).toBeInTheDocument();
});
it('does not show Reveal for a flagged cell', () => {
renderMenu({ isFlagged: true });
expect(screen.queryByText('Reveal')).toBeNull();
});
it('does not show Reveal for a revealed cell', () => {
renderMenu({ isRevealed: true });
expect(screen.queryByText('Reveal')).toBeNull();
});
it('shows Chord for a revealed cell with adjacentMines > 0', () => {
renderMenu({ isRevealed: true, adjacentMines: 2 });
expect(screen.getByText('Chord')).toBeInTheDocument();
});
it('does not show Chord for a revealed cell with adjacentMines === 0', () => {
renderMenu({ isRevealed: true, adjacentMines: 0 });
expect(screen.queryByText('Chord')).toBeNull();
});
it('shows Flag label "Flag" for a hidden unflagged cell', () => {
renderMenu();
expect(screen.getByText('Flag')).toBeInTheDocument();
});
it('shows Flag label "Unflag" when cell is flagged', () => {
renderMenu({ isFlagged: true });
expect(screen.getByText('Unflag')).toBeInTheDocument();
});
it('shows Suspect for a hidden cell', () => {
renderMenu();
expect(screen.getByText('Suspect')).toBeInTheDocument();
});
it('shows Suspect label "Unsuspect" when cell is suspected', () => {
renderMenu({ isSuspect: true });
expect(screen.getByText('Unsuspect')).toBeInTheDocument();
});
it('always shows the Close button', () => {
renderMenu({ isRevealed: true, adjacentMines: 0 });
expect(screen.getByText('Close')).toBeInTheDocument();
});
});
describe('TouchActionMenu — interactions', () => {
it('calls onAction with "reveal" and then onClose when Reveal is clicked', () => {
const { onAction, onClose } = renderMenu();
fireEvent.click(screen.getByText('Reveal'));
expect(onAction).toHaveBeenCalledWith('reveal');
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onAction with "chord" when Chord is clicked', () => {
const { onAction } = renderMenu({ isRevealed: true, adjacentMines: 3 });
fireEvent.click(screen.getByText('Chord'));
expect(onAction).toHaveBeenCalledWith('chord');
});
it('calls onAction with "flag" when Flag is clicked', () => {
const { onAction } = renderMenu();
fireEvent.click(screen.getByText('Flag'));
expect(onAction).toHaveBeenCalledWith('flag');
});
it('calls onAction with "suspect" when Suspect is clicked', () => {
const { onAction } = renderMenu();
fireEvent.click(screen.getByText('Suspect'));
expect(onAction).toHaveBeenCalledWith('suspect');
});
it('calls onClose without onAction when Close is clicked', () => {
const { onAction, onClose } = renderMenu();
fireEvent.click(screen.getByText('Close'));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onAction).not.toHaveBeenCalled();
});
});
12 tests for one small component. The test suite reads like a specification, which is exactly what it should be. Someone joining this codebase with no prior context can read these tests and know precisely what the menu is supposed to do in every case.
The “always shows Close” assertion at the end of the visibility block is a sentinel – it runs in the context of a revealed empty cell, which is the scenario where the menu has the fewest options (only Chord is excluded because adjacentMines is 0, not because it’s empty, and Reveal/Flag/Suspect are excluded because the cell is revealed). Even in the most stripped-down possible menu, Close is there. Tests that verify invariants under adversarial conditions are more valuable than tests that verify happy paths.
Prompt 11 — Tests for useLongPress
> Write tests for useLongPress.js using renderHook and jest's fake timers.
Scenarios:
- Callback fires after the delay when touch is held.
- Callback does not fire before the delay elapses.
- onTouchEnd before the delay cancels the callback.
- onTouchMove before the delay cancels the callback.
- Callback fires again on a second press after the first fires.
- A new press after cancellation fires correctly.
Claude:
// useLongPress.test.js
import { renderHook, act } from '@testing-library/react';
import { useLongPress } from './useLongPress';
describe('useLongPress', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
const mockEvent = () => ({
preventDefault: jest.fn(),
changedTouches: [{ clientX: 50, clientY: 100 }],
});
it('fires the callback after the specified delay', () => {
const cb = jest.fn();
const { result } = renderHook(() => useLongPress(cb, 500));
act(() => result.current.onTouchStart(mockEvent()));
expect(cb).not.toHaveBeenCalled();
act(() => jest.advanceTimersByTime(500));
expect(cb).toHaveBeenCalledTimes(1);
});
it('does not fire before the delay elapses', () => {
const cb = jest.fn();
const { result } = renderHook(() => useLongPress(cb, 500));
act(() => result.current.onTouchStart(mockEvent()));
act(() => jest.advanceTimersByTime(499));
expect(cb).not.toHaveBeenCalled();
});
it('cancels the callback when onTouchEnd fires before the delay', () => {
const cb = jest.fn();
const { result } = renderHook(() => useLongPress(cb, 500));
act(() => result.current.onTouchStart(mockEvent()));
act(() => result.current.onTouchEnd());
act(() => jest.advanceTimersByTime(500));
expect(cb).not.toHaveBeenCalled();
});
it('cancels the callback when onTouchMove fires before the delay', () => {
const cb = jest.fn();
const { result } = renderHook(() => useLongPress(cb, 500));
act(() => result.current.onTouchStart(mockEvent()));
act(() => result.current.onTouchMove());
act(() => jest.advanceTimersByTime(500));
expect(cb).not.toHaveBeenCalled();
});
it('fires again on a second press after the first fires', () => {
const cb = jest.fn();
const { result } = renderHook(() => useLongPress(cb, 500));
act(() => result.current.onTouchStart(mockEvent()));
act(() => jest.advanceTimersByTime(500));
act(() => result.current.onTouchEnd());
act(() => result.current.onTouchStart(mockEvent()));
act(() => jest.advanceTimersByTime(500));
expect(cb).toHaveBeenCalledTimes(2);
});
it('fires correctly after a previously-cancelled press', () => {
const cb = jest.fn();
const { result } = renderHook(() => useLongPress(cb, 500));
act(() => result.current.onTouchStart(mockEvent()));
act(() => result.current.onTouchMove()); // cancel
act(() => jest.advanceTimersByTime(500));
act(() => result.current.onTouchStart(mockEvent()));
act(() => jest.advanceTimersByTime(500));
expect(cb).toHaveBeenCalledTimes(1);
});
});
Fake timers are the right tool for any test that involves
setTimeoutorsetInterval. Without them, you’d either have to wait actual milliseconds in your tests (slow, fragile, environment-dependent) or write the code in a way that accepts an injectable clock (more complex, more maintenance).jest.useFakeTimers()replaces the timer globals with synchronous stand-ins that advance on demand – you get the full semantics of time-based code without waiting for it.The final two tests – “fires again on second press” and “fires after cancellation” – test the statefulness of the hook across multiple gesture cycles. These are the tests that would have caught a bug I saw in an early draft of this code, where the
activeRefwasn’t being properly reset between cycles. The fixed version is what’s shown above. The tests are what found it.
Test results
PASS src/engine/boardFactory.test.js
PASS src/engine/gameEngine.test.js
PASS src/hooks/useGameState.test.js
PASS src/hooks/useLongPress.test.js
PASS src/components/Cell/Cell.test.jsx
PASS src/components/GameBoard/GameBoard.test.jsx
PASS src/components/GameControls/GameControls.test.jsx
PASS src/components/GameStatus/GameStatus.test.jsx
PASS src/components/TouchActionMenu/TouchActionMenu.test.jsx
PASS src/App.test.jsx
Test Suites: 10 passed, 10 total
Tests: 174 passed, 174 total
Coverage: Statements 100% | Branches 100% | Functions 100% | Lines 100%
The 131 tests from Part 2 are all still green. Forty-three new tests cover the refactored and new code. Coverage stays at 100% across all four metrics.
I want to dwell on that test count for a moment: 43 new tests for a mobile touch feature. If you’d asked me before this session how long the test suite for a “touch menu” feature should be, I might have said 8 or 10. The complete picture – chording semantics, 3-way flag cycle, long-press timer behavior, menu rendering under all cell states – turns out to require a lot more coverage than that first estimate suggests.
This is one of the under appreciated functions of test-driven development: it makes the true complexity of a feature legible. “Add a touch menu” sounds small. 43 test cases for the feature’s full behavior surface sounds less small. Both descriptions refer to the same thing.
Architecture after the refactor
constants/gameConfig.js
↓
engine/boardFactory.js ──────────────────┐
engine/gameEngine.js ──────────────────┤ (+ chordCell, updated toggleFlag)
↓
hooks/useGameState.js
hooks/useLongPress.js ← new, standalone
↓
App.jsx (wiring)
/ | \
GameControls GameStatus GameBoard
↓
Cell × N
(+ useLongPress)
↑
TouchActionMenu (overlay) ← new
useLongPress sits alongside useGameState rather than inside it – it knows nothing about game state. TouchActionMenu renders as an overlay sibling to GameBoard, not as a child of any Cell, which keeps its fixed positioning relative to the viewport rather than to a potentially-scrolled parent.
The data flow remains unidirectional. No arrows point upward. The engine modules still have zero React imports.
Lessons from this refactor
1. Good architecture makes features cheap
Every change in this session touched a clearly-bounded module. Cell changed to support a new prop. The engine gained one function. The hook gained four. A new component and a new hook were added. Nothing was rearchitected. The planning investment from Part 1 paid a direct dividend here: adding a major interaction pattern to the game required no structural surgery.
2. Reusability comes from ignorance
useLongPress knows nothing about Minesweeper. It doesn’t know what a cell is, what a game is, or what action should happen on a long press. It knows about a gesture and a callback. This deliberate ignorance is what makes it reusable — it could be dropped into any touch-enabled UI without modification.*
3. The three-way state cycle needs protection
The isFlagged/isSuspect invariant — never both true — is the kind of constraint that silently corrupts game state if violated. The test that cycles through nine iterations and asserts the invariant at every step is the kind of test that looks like paranoia and reads like documentation. Both of those things are good.
4. Touch events have quirks
e.preventDefault() on touchstart is not optional. pointerdown instead of click for outside-dismiss detection is not optional. fixed instead of absolute for overlay positioning is not optional. These are the details that separate “works on my desktop” from “works on a phone.” They’re also the details that don’t appear in any tutorial and accumulate only through having shipped something that broke on a device you weren’t testing on.
5. Test your state machines completely
The game logic contains several state machines: the game status (idle → playing → won/lost), the cell mark cycle (none → flagged → suspect → none), the long-press lifecycle (idle → timing → fired/cancelled). Each was tested not just at individual transitions but through complete cycles. State machines that work for one transition and break on the second are a class of bug that only multi-step tests find.
Summary
The game works on mobile. You can tap a cell to reveal it on desktop (existing behavior). You can right-click to flag on desktop (existing behavior). On any touch device, you can long-press any cell to get a menu of contextually valid actions: reveal, chord, flag, suspect, or close. Chording works on desktop too, by clicking a revealed numbered cell.
Forty-three new tests. One hundred and seventy-four total. Still at 100% coverage.
The refactor took roughly forty minutes of conversation. Most of that time was in the test prompts – specifying scenarios precisely enough that the generated tests were testing real behavior rather than implementation details. That time was not overhead. The tests are the proof that the feature works, and they are the safety net for whoever touches this code next.
I started this series with a question: does a language model hold up under real software engineering discipline? After three sessions – planning, implementation, refactoring – I have an answer, at least for this problem and this model at this moment in time.
It holds up. It reasons about constraints. It identifies what doesn’t need to change as clearly as it identifies what does. It writes tests that look like specifications. It catches its own errors when pointed at them, and it produces cleaner corrected code than many engineers do on a second attempt.
What it doesn’t do is replace the 25+ years of hard-won pattern recognition that knows which constraints matter, why a particular architectural choice will be regretted in three years, and how to read a bug report and reconstruct the mental model of the developer who introduced it. That knowledge is still human for now.
But the gap is smaller than I expected when I opened that terminal on a quiet Tuesday evening and typed $ claude.
Considerably smaller. What’s clear to me is that the hottest programming language today is English. In hindsight, it’s almost comical how many years we spent promoting everyone need to learn to code. As Jenson Huang has emphasized, AI is reshaping the landscape – abstracting away much of the complexity so developers can move beyond low-level details, like coding in python, and focus on clearly articulating what we want to build. Others are noticing it as well – Ambiguity, Specificity, and Sunshine: How AI Exposes Clarity Gaps.
