---
title: "Mimesweeper: Minesweeper in React, TypeScript and Konva"
description: "Case study: building Mimesweeper, an interpretation of Minesweeper with mimes in place of mines. A playable React and TypeScript build in six days in July 2023, a Konva canvas board, first-click safety, flood-fill reveal, and a 2026 refresh onto React 19, pnpm, Biome and Vitest."
doc_version: 1
last_updated: 2026-11-12
canonical: https://variable.team/projects/mimesweeper
---

<!--
  Source of truth: app/projects/mimesweeper/page.tsx. Keep the h1 in sync.
  scripts/check-markdown-drift.ts verifies this in pre-commit.
-->

# Mimesweeper

- **Company:** [Variable](https://variable.team)
- **Industry:** Games / Personal Project
- **Dates:** Jul 2023 to Present
- **Project link:** <https://mimesweeper.variable.team>
- **Source:** <https://github.com/gcko/mimesweeper>
- **Stack:** React, TypeScript, Konva, Vite, Vitest, Biome, pnpm, AWS S3,
  CloudFront, CodeBuild, GitHub 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.

## Sitemap

[Full site index](/sitemap.md)
