Project page Game

Purrplex

A cat logic puzzle game in TypeScript where every puzzle has exactly one solution: how the puzzles are generated and verified, how it works offline, and how it is tested.

At a glance

Purrplex is a free browser puzzle game. On an N×N board split into N connected coloured regions you place N cats, so that every row, column and region has exactly one and no two cats touch, diagonals included. Every puzzle has exactly one solution.

Content
100 campaign levels (4×4 to 8×8), endless generated puzzles up to 11×11, and a quick-play mode.
Guarantee
An exact solver and a second, independent solver check each puzzle when it is made (two exceptions, in the pool); the exact solver checks it again before it is shown.
Difficulty
Rated by the deduction rules a solver needs (13 rules, five ranks), not by board size.
Offline
Works offline after one online visit, or as one HTML file that makes no network requests.
Next
Play it or download the offline file: see Links.

The problem

Produce, in a browser tab and on demand, an N×N board (N from 4 to 11) split into N connected regions with exactly one valid placement of N cats. The game’s own logic engine must be able to find that placement by deduction (defined below), at a requested difficulty (4×4 boards stop at medium; the app uses the nearest tier a size can make), reproducibly from a seed and the rest of the request. Five things make that hard:

  1. On boards from 6×6 up a random layout is rarely unique, so the generator repairs it, within a bounded number of rounds, until the exact solver reports one solution.
  2. Steering difficulty can bring back a second solution, so every change is solved again and undone unless the puzzle stays unique.
  3. A solver bug would ship a bad puzzle, so a second solver that shares no code cross-checks the first.
  4. The same seed must give the same puzzle in every JavaScript engine: integer-only random numbers and hashing, no Math.random, and no clock in anything that chooses a puzzle.
  5. Generation must not freeze the page: a Web Worker, a main-thread fallback and a shipped library of verified puzzles.

Unique means that the exact solver, which counts the arrangements that satisfy every rule and contain every starter cat and stops at two, finished its search and found exactly one. A timeout is never evidence of uniqueness.

Human-solvable means that the game’s own logic engine, starting from the visible starter cats and never from the stored answer, can finish the board with its 13 named rules; otherwise the generator rejects the puzzle as needing a guess. The hardest rule is a one-level trial, “What if?”. The 12 expert levels need it, so deduction here includes that one trial. No human playtest backs this definition.

How the generator works

A request (a size from 4 to 11, a tier, a seed, optional starter cats, and a list of layouts to avoid) gives the same puzzle every time, for one generator version. The app’s list holds the layouts the player has seen, so it does not promise to rebuild a puzzle that has left its cache. Random numbers come from a 32-bit integer generator, seeds are hashed with FNV-1a, and each stage draws from its own stream forked from the seed by name. Time limits are checked only between attempts, so they decide whether a puzzle comes back, never which one. The 100 campaign levels use 4×4 to 8×8; 9×9 to 11×11 appear only in endless play, quick play and the fallback library.

  1. Solution. A randomised depth-first search puts one cat per row, skipping used columns and the columns next to the previous cat.
  2. Regions. Each region starts on one intended cat and grows one adjacent cell at a time toward a planned size, so it is connected by construction and holds one cat.
  3. Starter cats come from the intended solution; ties are broken by the random stream.
  4. Repair. While the exact solver finds several solutions, a cell of an alternative solution is handed to a neighbouring region, keeping regions non-empty and connected, for a bounded number of rounds.
  5. Steering. A hill climb moves region boundaries toward the requested tier; a move is kept only if the puzzle stays unique.
  6. Gate. A candidate is released only after the acceptance gate (next section). Otherwise the attempt is discarded, and if every attempt fails the generator reports failure instead of returning a puzzle.

Duplicates, the pool and failure recovery

A fingerprint is a hash of the smallest of the 8 rotations and reflections of a board, with regions renamed by first appearance, so the gate can reject any layout on its avoid list, in any orientation (the app lists the campaign layouts and those the player has seen or cached). All 277 shipped puzzles (100 campaign, 177 fallback) have distinct fingerprints.

In the browser a puzzle pool supplies a verified cached puzzle, else one generated within 1.5 seconds, else a shipped fallback while generation continues; every puzzle is proven again first. One dedicated generator Web Worker does the work. It is restarted once if it crashes; if it fails again, generation continues on the main thread in short slices, without the second solver from size 10 up (the pool still re-proves every puzzle).

The opt-in “Give Me a Head Start” adds one cat of the puzzle’s only solution to a puzzle that has none. A required cat can only remove solutions, and that one still fits, so uniqueness holds. The code does not rely on the argument alone: it solves the extended puzzle again with the exact solver and keeps the original unless the result is still unique.

How uniqueness is verified

The exact solver is a depth-first search with constraint propagation. Each node keeps bitmasks of the cells still possible in every open row and column and a count for every open region, and branches on the unit with the fewest possibilities, so each solution is found once. By default it stops at 2 solutions (the repair loop asks for up to 8). A result is unique only for a finished search with exactly one solution; a budget stop is a timeout.

The second solver is deliberately naive: one cat per row, each candidate compared with every earlier cat, no bitmasks, no propagation, and no code shared with the first (it imports only the shared types and size constants).

The acceptance gate gives both solvers only the board and the starter cats, never the intended answer. It rejects a puzzle that is invalid, not unique, whose solution differs from the intended one, on which the two solvers disagree, that the logic engine cannot finish, whose metadata or tier does not match, or that repeats a known layout.

Verification runs at generation; at library build (the full gate, then a verifier that re-checks every entry from its board alone and exits with code 1 on any failure); and in the browser, where every puzzle that reaches the player, and a saved game, is proven again with the exact solver. The library verifier is a test and CI step, not part of npm run build.

How difficulty is rated

Difficulty is what the logic engine needs, not board size. Its 13 rules have ranks 0 to 4, and it applies the simplest technique first: each round takes every hit of the lowest rank that has any.

The 13 rules by rank, with the names the game shows
RankRulesWhat they do
0Row, Column and Region check; No touching; Last spot in a region, row or column (7)A cat clears its row, column, region and neighbours; a unit with one open cell takes a cat.
1Region points to a line; Line claims a region (2)A region confined to one line clears the rest of that line, and the reverse.
2Shared attack (1)Every open cell of a unit blocks one cell, so it is out.
2 or 3Regions share lines; Lines share regions (2)k regions confined to k lines, or the reverse. Rank 2 for k = 2, rank 3 for k = 3 or 4.
4What if? (1)A one-level trial cat; a contradiction under rules of rank 3 or lower removes the cell.

A puzzle’s score is its longest chain of dependent deductions plus a weight for each round at each rank (0, 10, 25, 60 and 120). Board size and starter cats are not scored. Up to 10 is tutorial, 40 easy, 80 medium, 200 hard and above that expert; the tier implied by the highest rank is a floor. The 100 campaign levels are 17 tutorial, 18 easy, 25 medium, 28 hard and 12 expert, with scores from 2 to 298 that never decrease.

Each deduction hint is a real deduction of the engine on the board the player sees, simplest first, in three steps (a nudge, an explanation, the move). A hint that points at a mistake is the one place the stored answer is used. If nothing can be deduced it says so.

Offline architecture

There are two builds. The hosted app is installable, uses a service worker and is built for Cloudflare Workers Static Assets. The standalone file, purrplex-offline.html, inlines the CSS, JavaScript, fonts, favicon and worker source in one document.

Only the standalone file makes no network request. Its Content-Security-Policy allows its two inline scripts only by hash, plus inline styles, data: images and fonts and a blob: worker, and forbids every network connection (connect-src 'none'). The build refuses the file if it loads an external resource or names an external URL that is not on a short list of links, and browser tests open it from disk with every http(s) request aborted. The hosted site loads its own files from its own origin and registers a service worker. The app has no accounts, no ads and no analytics or tracking code; I make no claim about what the host’s request logs keep.

Each build precaches its own files (16 in the live build of 30 October 2026) and serves them cache-first; only same-origin GET requests for those files are intercepted. A failed download fails the install, an update takes over on the next launch, and the first visit has to be online.

Progress, settings and the saved game live in localStorage under purrplex:v1:; if it is unavailable the adapter keeps data in memory and says progress will not be saved. Saves are versioned. Tabs coordinate through the storage event, without locks: records only grow, and a reset starts a new epoch stored as its own key, because localStorage has no compare-and-set.

In the live build of 30 October 2026, the first load (HTML, CSS, app script and font) is about 130.6 kB gzipped (budget 150 kB) and the uncompressed offline file is 438.0 kB (budget 450 kB). Tests and the strict build enforce both; the numbers change with every build.

Architecture diagram

In the live code of 30 October 2026, the core imports nothing from the game, runtime, UI, data or content folders; the game imports nothing from the UI or runtime; the runtime imports nothing from the UI.

Purrplex architecture: build time, delivery and the visitor’s browserThree bands: build time, delivery, and the browser. There the UI uses the game and the runtime, the runtime uses a generator worker, and the game, the runtime and the worker use the core. A list follows.Build time (developer machine and CI)DeliveryIn the visitor’s browserLibrary pipeline100 campaign and 177 fallback puzzlesSite buildbundles, renders pages, inlines the fileCloudflare WorkerStatic Assets serves dist/webpurrplex-offline.htmlone file, connect-src 'none'UIshell, board, input, panelsService worker16 files, cache-firstGamesession, hints, progresslocalStoragepurrplex:v1: keysRuntimesource, pool, clientGenerator workerWeb Worker + fallbackCoregenerator, solvers, gate, logic engine, rating
Modules and data flow. A coloured edge groups boxes by kind; dashed arrows are produced at build time. On a narrow screen the picture scrolls sideways.

The diagram as text

  1. Build time (developer machine and CI).
    • Library pipeline: generates, gates and re-verifies the shipped puzzle libraries.
      • Arrow to Site build: writes the libraries that get bundled.
    • Site build: bundles app, worker and sw.js, renders pages and headers, inlines the offline file.
      • Arrow to Cloudflare Worker: produces dist/web.
      • Arrow to purrplex-offline.html: produces the standalone file, also shipped as the download.
  2. Delivery.
    • Cloudflare Worker: serves the hosted bundle with the generated headers.
      • Arrow to UI: delivers HTML, JavaScript, CSS, font, worker and fallbacks.
    • purrplex-offline.html: the standalone file, opened from disk, with no network request.
      • Arrow to UI: opens from disk as the same app, with no HTTP.
  3. In the visitor’s browser.
    • UI: the app shell: first paint, board, keyboard, mouse and touch input.
      • Arrow to Service worker: registers sw.js after load.
      • Arrow to Game: places cats, undoes, asks for hints.
      • Arrow to Runtime: asks for a campaign, endless or quick-play puzzle.
    • Service worker: hosted build only: precaches the app and answers its files cache-first.
    • Game: board state, undo and redo, hints, coach tips, progress, settings, save codes.
      • Arrow to localStorage: reads and writes progress and settings.
      • Arrow to Core: hints call the logic engine.
    • localStorage: progress, settings, the pool and the saved game.
    • Runtime: chooses the puzzle source, keeps the pool of verified puzzles, runs the worker client.
      • Arrow to localStorage: keeps the pool of verified puzzles there, through the game’s storage adapter.
      • Arrow to Generator worker: sends a generation request, gets a puzzle.
      • Arrow to Core: proves every puzzle again with the exact solver.
    • Generator worker: one dedicated Web Worker, restarted once, then main-thread slices.
      • Arrow to Core: runs the generator and the gate.
    • Core: pure TypeScript: generator, both solvers, gate, logic engine, rating, fingerprints.

How solvability is tested

As counted on 30 October 2026, there are 129 test files (103 Vitest, 26 Playwright): about 49,000 lines of test code against about 18,400 lines of TypeScript in src/. These count what is written, not cases run: the files hold about 1,900 Vitest and about 200 Playwright call sites. This page quotes no pass counts or CI results.

Exact solver
Fixtures, limits, budgets, independence from metadata, property tests against exhaustive enumeration, symmetries, sizes 8 to 11 and worst-case layouts.
Two solvers and a brute force
A differential test compares both solvers and a brute force over every column permutation (up to size 9) on random layouts, so a bug in one is exposed by the others.
Gate, generator, logic engine
Negative fixtures fail for the right reason; spies show the intended solution never reaches a solver; each generator stage keeps its invariants; the logic engine is checked for soundness on sampled boards at every rank cap (no deduction places a cat or removes a cell that the solution contradicts); a regression test pins the output for fixed requests.
Stratified audit
In a fixed-seed sample (200 puzzles in CI, 1,000 or 10,000 on demand), every generated puzzle passes up to 17 named checks (exact count, brute force up to size 9, logic trace, tier, fingerprint, head start, gate, determinism and more) across the sizes, tiers and 0 or 1 starter cats the generator serves reliably. A failed generation is an explicit failure, never a wrong puzzle.
Metamorphic
Verdicts, ratings and fingerprints must not change under the 8 board symmetries or region renaming, and the solutions must move with the board.
Mutation testing
50 mutants of the core, each a one-condition text change: 46 that the test layer must kill and 4 that only unit tests can reach (one is an equivalent control that survives). A test requires every cross-module mutant that runs to be killed (14 in CI, plus the control). The harness is home-grown, not Stryker.
Shipped libraries
All 100 campaign levels and 177 fallbacks are re-checked from their boards: connected regions, one solution for both solvers, no duplicates, logically solvable, a ramp that never drops within a tier.
Game, runtime, end to end
A playthrough test that follows the hints on logically solvable puzzles without a mistake; corrupt, newer or refused storage; pool order and worker failure; the offline file in real Chromium; Playwright on Chromium and WebKit, including a level-30 test in which every hint is a true deduction.

What is not covered

  • No human playtest: whether players find the puzzles solvable is unmeasured.
  • The larger audit sizes run on demand, not in CI.
  • Regenerating a level from its stored seed is tested on 7 of the 100 levels.
  • No external accessibility audit, and no WCAG conformance claim.
  • Firefox is a build target, not in the end-to-end matrix.