Classic Minesweeper, rebuilt as an animation study: every state change on the board — reveal, flip, explode, win — is a motion sequence rather than an instant repaint.
▶ Play it
| React 18 + TypeScript | Component model and strict typing across game state |
| Vite 6 | Dev server and build, with @/ path alias to src/ |
| Tailwind CSS + shadcn/ui | Utility styling; Button is the single shared primitive |
| Framer Motion 11 | All board, cell and route animations |
| react-explode, react-confetti-explosion | Mine detonation and win effects |
| React Router 7 (HashRouter) | Navigation; hash routing needs no server rewrites, so it works as-is on GitHub Pages |
| Vitest | Unit tests over the pure game rules in src/core |
Typography is an LED dot-matrix face (public/assets/fonts), applied globally to
give the UI a hardware-display feel.
The codebase splits along a single line: game rules are pure, React only renders them.
src/
├── core/ # framework-free
│ ├── utils/ # generate-empty-board, place-mines,
│ │ # calculate-adjacents, reveal-cell, get-distance
│ ├── typings/ # CellData, Coordinate, Difficulty
│ └── constants/ # difficultyTypes, TRANSITION_SPEED
└── ui/
├── hooks/ # useGameMechanics, useUpdateEffect, useIsFirstRender
├── components/ # board, cell, status-bar, animatedRoute
└── pages/ # home (difficulty select), game
core/utilsare pure functions overCellData[][]— each takes a board and returns a new one. No React, no mutation, no side effects, which is what makescore.test.tsa plain data-in/data-out test file with no rendering involved.useGameMechanicsis the only place game state lives. It composes those utils into the game loop (handleReveal,handleFlag,reset) and exposes a flat, serialisable snapshot:board,gameOver,gameWon,isGameStarted,lastClickedCell.- Components are dumb.
BoardandCellreceive state and callbacks; they own animation, not rules. - Difficulty lives in the URL.
Homeencodes the preset as query params (/game?rows=16&cols=30&mines=99), so a game configuration is a link and no global store is needed. Because a URL can say anything,Gamevalidates the params and falls back to the easy preset, and the mine count is clamped tomaxMines(rows, cols)— the same boundplaceMinesuses, so the counter in the status bar can never disagree with the board.
Two mechanics worth calling out:
- Deferred mine placement. The board starts empty;
placeMinesruns on the first click with a 3×3 safe zone around it, so the opening move can never lose and always opens a region. - Flood fill.
reveal-cellexpands zero-adjacency regions with an explicit stack rather than recursion — no call-stack limit on large boards.
lastClickedCell is the key idea: the click coordinate is kept in state, and
every cell derives its own animation timing from its distance to it.
Cascading reveal. Board computes a Manhattan distance from the clicked cell
(get-distance) and multiplies it by TRANSITION_SPEED to get a per-cell
revealDelay. Cells on the same distance ring animate together, so the reveal
travels outward as a wave from the click instead of appearing all at once.
Cell flip. Each unrevealed cell is a cover <div> inside AnimatePresence;
on reveal it exits with rotateY: 360 + fade, reading as a tile flipping over.
The number or emoji underneath enters with a scale: 3 → 1 pop.
Detonation. The clicked mine renders a react-explode burst on top of the
grid, while a translucent black overlay covers the board and fades out on a
1.2 s delay — the cascade finishes before the board dims.
Victory. Two confetti bursts fire from either side of the grid.
Reset. Board uses Framer Motion's imperative useAnimate with
useUpdateEffect (a useEffect that skips the first render) to run a two-step
sequence — fade/scale out, then back in — so a reset re-introduces the board
rather than blanking it.
Route transitions. App wraps Routes in AnimatePresence mode='wait',
keyed on location.key. AnimatedRoute gives every page the same
opacity + scale 1.1 → 1 entrance and reversed exit, so the outgoing screen
fully leaves before the next arrives.
- Three presets — easy (10×10, 10 mines), medium (16×16, 40), hard (16×30, 99)
- First click is always safe
- Right-click flagging, with a live count of unflagged mines
- Timer that starts on the first move and stops on win or loss
Requires Node.js 18+.
npm install
npm run dev # http://localhost:5173
npm test # game-rule tests
npm run lint
npm run build # type-check + production build into dist/Pushing to main runs .github/workflows/deploy.yml: lint, test, build, publish
to GitHub Pages. The build reads GITHUB_PAGES=1 to set Vite's base to
/minesweeper/; locally base stays / so npm run preview works unchanged.
Enable it once under Settings → Pages → Source → GitHub Actions.
