Mimesweeper

Variable Games / Personal Project Jul 2023 to PresentProject LinkSource

ReactTypeScriptKonvaViteVitestBiomepnpmAWS S3CloudFrontCodeBuildGitHub Actions

A playable game in six days

I started Mimesweeper on July 9, 2023 from the Create React App TypeScript template. Six days and 36 commits later it was playable. The name swaps mines for mimes, and so does the artwork. The board is drawn on an HTML canvas with Konva, and each square is a group of a rectangle, its number and an image. Four sizes run from 10x10 with 10 mimes up to 40x40 with 100. Twelve days after the first commit I replaced CRA with Vite and added a CodeBuild spec that builds the app, copies dist to an S3 bucket, sets no-cache on index.html and invalidates it in CloudFront. The live site still serves from S3 behind CloudFront.

No mimes until the first click

The board starts as a Map of blank squares keyed by a template literal type, ${string}|${string}, so a square is looked up by a string like 5|4. Mimes don't exist until the player clicks. That click draws random coordinates into a Set, skips the clicked square, and caps the attempts at 100 times the mime count. Only the clicked square is protected. Its neighbors can still hold mimes, so a first click can open a number instead of a clearing. Placing each mime walks its eight neighbors to bump their counts, and any key that isn't in the Map is skipped. Edge squares need no special case.

One recursive function opens the board

updateAdjacent does three jobs, picked by an enum. MIMES bumps neighbor counts during setup. OPEN is the flood fill: it opens every unflagged, unopened neighbor that isn't a mime and recurses into any with zero adjacent mimes, so it stops at numbered squares. FORCE_OPEN is the chord on a double-click or double-tap. It opens every unflagged neighbor, mimes included, and the game is lost if the flags around the number were wrong. Each call returns how many squares it opened. The game is won when opened squares plus mimes equal the board area. Nothing rescans the board.

A pure reducer and a 2026 refresh

On February 28 and March 1, 2026 I did a modernization pass. React 18 went to 19, npm to pnpm, ESLint and Prettier to Biome, Jest to Vitest, and eight useState hooks folded into a pure reducer with a discriminated-union action type. The reducer exposed a real bug. React StrictMode runs reducers twice, and mime placement mutated squares in place, so the second run could land a mime on a square that was already open. Deep-cloning before placement fixed it, and regression tests now assert that flood fill never opens a mime and that reducer state is never mutated.

Testing a canvas game in Node was the first snag. In July 2023 I noted that the tests wouldn't run. CRA ran them under Node, and Konva doesn't implement canvas there. Game logic now sits in a pure reducer and plain helper functions outside the components, so most of it is tested without rendering. The suite is 243 tests across 13 spec files, with 80% coverage thresholds in vitest.config.ts, and GitHub Actions runs it on Node 22 and 24. The same pass added a score that drops 5 points a second, a top-10 scoreboard per size kept in the browser's localStorage, and a mobile layout with a 500 ms long-press to flag.