Freedoku — offline-first sudoku PWA (React 19 + TypeScript + Vite; pure engine + pure reducer, openspec sudoku-v1 51/51) https://sudoku.underthere.xyz
  • TypeScript 83.5%
  • CSS 8.2%
  • JavaScript 7.4%
  • Shell 0.5%
  • HTML 0.4%
Find a file
Chris Moriarty 0e7a5e0aa8 docs(readme): clone from cmoriarty/freedoku after the rename
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:40:50 -04:00
assets updated readme 2026-08-20 16:00:37 -04:00
e2e feat(ui): home screen, continue, and puzzle of the day 2026-09-22 02:58:58 -04:00
openspec feat(ui): home screen, continue, and puzzle of the day 2026-09-22 02:58:58 -04:00
public Initial import: Freedoku sudoku PWA (openspec sudoku-v1, 51/51 tasks) 2026-08-17 09:27:13 -04:00
screenshots feat(ui): home screen, continue, and puzzle of the day 2026-09-22 02:58:58 -04:00
scripts feat(ui): home screen, continue, and puzzle of the day 2026-09-22 02:58:58 -04:00
src feat(ui): home screen, continue, and puzzle of the day 2026-09-22 02:58:58 -04:00
tests feat(ui): home screen, continue, and puzzle of the day 2026-09-22 02:58:58 -04:00
.gitignore chore(deploy): quiet the two standing warnings from first real run 2026-09-21 23:50:42 -04:00
index.html Initial import: Freedoku sudoku PWA (openspec sudoku-v1, 51/51 tasks) 2026-08-17 09:27:13 -04:00
LICENSE chore(license): add the MIT license 2026-10-06 11:39:33 -04:00
package-lock.json Initial import: Freedoku sudoku PWA (openspec sudoku-v1, 51/51 tasks) 2026-08-17 09:27:13 -04:00
package.json chore(license): add the MIT license 2026-10-06 11:39:33 -04:00
playwright.config.ts Initial import: Freedoku sudoku PWA (openspec sudoku-v1, 51/51 tasks) 2026-08-17 09:27:13 -04:00
README.md docs(readme): clone from cmoriarty/freedoku after the rename 2026-10-06 11:40:50 -04:00
tsconfig.json Initial import: Freedoku sudoku PWA (openspec sudoku-v1, 51/51 tasks) 2026-08-17 09:27:13 -04:00
vite.config.ts fix(pwa): update-toast Reload now actually applies the new build 2026-08-20 01:29:53 -04:00
vitest.config.ts Initial import: Freedoku sudoku PWA (openspec sudoku-v1, 51/51 tasks) 2026-08-17 09:27:13 -04:00

Freedoku

An offline-first sudoku PWA. React 19 + TypeScript + Vite, with a pure engine (no React imports), a pure game-reducer as the single source of truth, and a thin rendering layer. No account, no server — everything lives in localStorage.

How to play

Freedoku in play — an easy puzzle with pencil candidates in the selected cell

The basics

  • Placing digits — tap a cell, then tap 1–9 (or just type 1–9 on a keyboard). Wrong entries (never the givens) get a red marker and bump the ✗ counter; the game continues on easy and medium.
  • Erasing — the ⌫ Erase key clears the selected cell (or, on an empty cell, the most recently added pencil mark). Keyboard: e, Backspace, or Delete.
  • Undo / redo — 50 levels of history (buttons or U / R). Restores the board, pencil marks, hinted cells, and mistakes — not the timer.
  • Timer — starts on your first board action, not on page load, and auto-pauses whenever the tab is hidden.
  • Navigation — arrow keys / WASD move the cell selection; the game is playable entirely from the keyboard.

Difficulty

Every puzzle has a unique solution, its givens are 180°-rotation symmetric, and none ever requires guessing. Levels differ in how many givens you get and in the hardest technique the puzzle may call on:

Level Givens Hardest technique it may require
easy 38–44 singles
medium 32–36 up to pairs & triples
hard 27–31 up to X-wings

What those technique names actually mean. They describe the hardest purely logical deduction a solver must make. Background: each row, column, and 3×3 box (a unit) must end up containing 1–9 exactly once, and a candidate is a digit that could still go in a particular cell. Freedoku never displays them; the terms are how puzzle strength is measured (same ladder the hint button uses — see Hints below).

Singles (easy puzzles only ever need these). A naked single is a cell whose only candidate is 5 — it is settled, whatever the rest of the board looks like:

A 3×3 box in which one cell has a single candidate, 5

A hidden single works the other way: in a unit, a digit has only one possible home. In this row the 7 fits in exactly one cell — so that cell is a 7, even though it still shows other candidates:

A row of nine cells in which the 7 appears as a candidate in only one cell

Pairs & triples (medium puzzles may need these). A naked pair is two cells in a unit holding exactly the same two candidates — 3 and 8 must live in those two cells, so every other 3 and 8 in the unit (crossed out) can be erased:

A 3×3 box where two cells hold exactly candidates 3 and 8, eliminating both digits from the other cells

A hidden pair is the mirror image: two digits in a unit have nowhere to go but the same two cells, so all of those cells' other candidates drop away. Triples generalise the same ideas to three cells and three digits.

X-wings (hard puzzles may need these). In two different rows, the candidate 5 can go in exactly the same two columns — the four corners of a rectangle (dashed outline). Then in each of those columns the 5 must land in one of those two rows, so every other candidate 5 in the columns (crossed out) is impossible:

A 9×9 board showing the candidate 5; two rows each hold it in the same two columns, an X-wing, and the 5s in those columns outside those rows are crossed out

The middle column is a ceiling: a medium puzzle might only ever need a naked pair. Past X-wings, human solvers reach for fishing patterns, colouring, and blind trials — the generator guarantees none of those are ever required at any level.

Hard is the one that bites: a single wrong entry loses the game — the "Out of luck" screen then offers Play again (same puzzle, fresh board and timer) or New puzzle. Easy and medium let you play on through mistakes.

Pencil marks (notes)

Toggle Notes (button or N). In notes mode, tapping a digit toggles that candidate in the selected cell's 3×3 mini-grid. Two automatic behaviours keep your notes tidy:

  • Placing a digit always clears that cell's own candidate list.
  • Auto-off (on by default, toggle in Settings — "Auto-completes pencil candidates"): placing a digit also removes it from the candidate lists of all peers — the cells sharing its row, column, and box.

Fill

Fill is a batch-entry tool: instead of tapping each cell yourself, you tap the digit once and Freedoku places it everywhere it fits the board — every empty cell whose row, column, and box don't already contain it. The whole batch is a single undoable action. (Notes and Fill are mutually exclusive — enabling one turns off the other.)

Before and after one tap in Fill mode: a fresh easy board, then the same board with twelve 5s placed — six of them flagged with a red ✗, mistake counter at × 6

Left: a fresh easy board with Fill active (button highlighted). Right: after one tap of 5 — twelve fives appear at once.

Fill is not a judge. It refuses placements that clash with visible cells, but it doesn't know the solution. On a mostly empty board a digit "fits" in many cells — and most of them are wrong. In the picture, 6 of the 12 fives belong where they're placed and 6 don't: the wrong ones get the red ✗ and the mistake counter jumps to × 6 in a single tap, while the red cells without a ✗ simply clash with another 5 in their row, column, or box. So:

  • undo is one tap (U) — the entire twelve-cell batch rolls back as a single history step;
  • Fill earns its keep late in a game, when the board is mostly filled: a digit you can see must go in two or three places, and it lands in all of them at once, almost all of them correct;
  • on hard, a single wrong entry loses the game — so a batch fill on a nearly empty hard board ends the puzzle instantly.

Hints

Three per puzzle, shown as the ● ● ● dots in the top bar; the button disables when they're gone. A hint reveals the easiest cell the technique ladder can currently resolve — the first cell that singles, pairs, triples, or X-wings can nail down (random among that pass's cells, or a random cell if only guessing would remain). Hints never count as mistakes, the revealed cell keeps a hint marker even after undo, and how many you used shows on the solved sheet.

Stats

Recorded per device in localStorage (no server, no account):

  • per difficulty — solved count, best time, average time
  • total time played
  • streaks — current and best consecutive-day run
  • puzzle of the day — today's status, the daily streak, and total dailies solved
  • history — the last 200 games: date, difficulty, time, mistakes, hints, puzzle id

Open the top-bar menu (▤) → Statistics for the summary sheet; each finished game also gets its own solved sheet with time, mistakes, hints used, and puzzle id.

Home screen & puzzle of the day

Freedoku opens to a home screen: the title, your in-progress puzzle (when you have one), today's Puzzle of the day, and the new-puzzle buttons.

The daily is one medium puzzle a day — the same one for everyone on a given date (seeded from the date, so it's deterministic). The home card tracks it: while you play it shows "in progress"; once solved it shows the time and an Admire view — the completed board with that day's stats. A solved day isn't replayable until the date changes, and the statistics page keeps the daily's own record: today's status, the daily streak, and the total dailies solved.

Resuming

The board is saved after every state change. By default Freedoku opens to the home screen, where your in-progress puzzle is one Continue tap away (difficulty and elapsed time on the card); enable Resume last puzzle on launch in Settings and it reloads exactly where you were instead — entries, pencil marks, hints used, mistakes, and the timer. The top-bar menu's Home item returns you to the home screen at any time (mid-play, or after a finished game). Finished games (solved or lost) are not resumable — they clear back to the home screen.

New game & reset

The top-bar menu offers New game (a difficulty picker, preselected to your current level) and Reset board (same puzzle, givens only, timer and mistakes cleared). Both ask one confirmation step, since they replace or scrub your in-progress board.

Offline & install

Freedoku is a PWA. On iOS a one-time "Add to Home Screen" explainer appears on first visit in the browser (it's remembered permanently once dismissed, and never shown in the installed standalone app). After the first load the game works fully offline, and a "new version" toast appears when an update is picked up.

Settings (gear icon): theme — auto / light / dark — and the pencil marks auto-off toggle.

There is deliberately no sound, no sharing, and only 9×9 boards.

How to develop

Clone & install

git clone ssh://git@forgejo.underthere.xyz:222/cmoriarty/freedoku.git
cd freedoku
npm install
npx playwright install   # once, only for e2e

Node 20+ (developed on Node 22). The repo also ships its LLM-agent setup (.opencode/ — see Development with an LLM).

Running locally

npm run dev      # Vite dev server → http://localhost:5173

The service worker is disabled in dev, so each reload is a fresh load — no stale-cache issues.

npm run build    # tsc --noEmit && vite build → dist/ (+ sw.js, manifest, worker)
npm run preview  # serves dist/ at http://localhost:4173

Any static server works in place of vite preview; the app must be served at the site root (the service worker scope is /). PWA behaviour (caching, offline reload, update toast) only exists in production builds.

Tip: if the app looks stale after a rebuild in a PWA-enabled tab, that is the service worker doing its job — it will show a "new version" toast, or clear it by unregistering the service worker (DevTools → Application → Service workers).

Tests

npm test            # unit tests (vitest + jsdom): engine, state, storage, components
npm run typecheck   # tsc --noEmit
npm run test:e2e    # playwright e2e
npm run build       # full gate: typecheck + production PWA build
  • Unit tests — tests/ (engine correctness/property tests, reducer action matrix, storage, a11y component tests).
  • E2E tests — e2e/freedoku.spec.ts: a happy path (fixed-seed new game → keyboard play to solved → stats recorded), PWA checks (manifest, service worker, full offline reload with zero failed requests), the mid-play new-game flow (confirmation + difficulty picker), and mid-play reset (same puzzle, givens only, 0:00, mistakes cleared). The Playwright webServer auto-runs the production build and vite preview --port 4173.

Deploying

The live app is a static site served from the LAN host 192.168.1.122 (npm.home). The git checkout at /data/compose/15/data/static/sudoku is the deployment directory and its dist/ is what gets served (the PWA service worker is scoped to the site root — do not move the checkout). The server has no Node toolchain, so the production build runs in a throwaway node:22 container.

npm test && npm run test:e2e   # local gates (recommended)
scripts/deploy.sh              # ssh → git pull main → docker build → dist/ in place
scripts/deploy.sh --push       # push local main first, then deploy

What scripts/deploy.sh does on the server (the manual flow it automates):

  1. cd /data/compose/15/data/static/sudoku
  2. git pull --ff-only
  3. docker run --rm -v "$PWD":/app -w /app node:22 sh -c 'npm ci && npm run build' — npm run build is tsc --noEmit && vite build, so a broken main fails the deploy before dist/ is replaced

No restart or docker compose step is needed — the static files are replaced in place, and installed PWA clients show the "new version" toast on next load (registerType: 'prompt').

One-time: stop the Forgejo password prompt on the server. The server clone pulls over https, so each deploy prompts for the cmoriarty Forgejo password. Do this once on the server:

ssh-keygen -t ed25519 -N ''   # if you have no key yet
cat ~/.ssh/id_ed25519.pub     # add it in Forgejo → Settings → SSH keys
git -C /data/compose/15/data/static/sudoku remote set-url origin \
    ssh://git@forgejo.underthere.xyz:222/cmoriarty/freedoku.git

The user is git, not your Forgejo username: the Forgejo container's sshd accepts only git (AllowUsers git) — the same user the laptop's remote uses. A bare or cmoriarty@ URL is refused before the key is even examined (it reads as "Permission denied (publickey)" either way).

Deterministic puzzles (?seed=)

Appending ?seed=<n> to the URL makes the first "new puzzle" deterministic (used by e2e and the iOS gate; not a user feature). The ?seed value applies to the first new game only — later new games use a random seed.

Known fixtures:

seed difficulty givens puzzle id
424242 easy 41 aa25e3460f74
987654321 hard 30 c3dd03f41ad7

Helpers in scripts/ (bundled with scripts/bundle.mjs, e.g. node scripts/bundle.mjs scripts/print-grid.ts /tmp/pg.js && node /tmp/pg.js):

  • print-grid.ts — print the 424242 givens as a grid
  • print-taps.ts — print the r,c,digit tap plan to solve it (1-based, matches the cell ARIA labels)
  • hard-hint.ts — first empty cell + a guaranteed-wrong digit for the hard fixture (drives the hard-loss screenshots)
  • check-contrast.js — WCAG contrast gate for the theme tokens (4.5:1 text, 3:1 large text & non-text). Run node scripts/check-contrast.js.

Development with an LLM

The repo is set up to be developed by an LLM coding agent. The behavioural contracts live in openspec/specs/ — six capabilities: game-lifecycle, gameplay, presentation, progression, puzzle-generation, pwa-shell — and openspec/config.yaml holds the standing project context (brand, stack, constraints, conventions) that the agent sees when planning. The openspec CLI (v1.9.0) plus the in-repo skills and slash commands (.opencode/skills/ and .opencode/commands/) drive the workflow:

  1. Explore — /opsx-explore (or just ask): have the LLM think the idea through against the current specs before any plan exists.
  2. Propose — /opsx-propose "…" (or openspec new change <name>): the LLM writes a change under openspec/changes/<name>/:
    • proposal.md — what & why
    • specs/<capability>/spec.md — the delta: what changes in the behavioural contract
    • design.md — how
    • tasks.md — the implementation steps, each with a verifiable check You review these artifacts before anything is implemented.
  3. Apply — /opsx-apply: the LLM works through tasks.md one at a time, running the gate as it goes: npm run typecheck, npm test, npm run test:e2e, npm run build — plus, wherever a spec demands it, the iOS-simulator visual gate (dark + light at 390×844 and a 320px viewport; evidence lands in screenshots/).
  4. Archive — /opsx-archive: the change moves to openspec/changes/archive/ and its spec delta is synced into openspec/specs/. The main specs are always the current truth.

Supporting commands: /opsx-update (revise a change's artifacts to fold in new decisions) and /opsx-sync (sync a delta into the main specs without archiving).

Not everything needs a proposal. A change is for work that alters the behavioural contract. Docs, typos, refactors with no observable behaviour change, and tooling tweaks are one-off tasks — just ask the LLM to do them directly. The completed builds you can read about in openspec/changes/archive/ (2026-08-19 sudoku-v1, 2026-08-20 new-game) are what that process looks like end to end.

iOS simulator (visual gate)

npm run build
npm run preview                       # or any server on 127.0.0.1:4173
xcrun simctl list devices             # find/boot an iPhone
xcrun simctl boot <UDID>
xcrun simctl openurl booted "http://127.0.0.1:4173/?seed=424242"

UI automation via idb (or the ios-simulator MCP tools) additionally needs the idb CLI + idb_companion: pip install --user fb-idb, then install idb_companion from the facebook/idb releases (place the bundle in ~/.local/share/idb/, symlink …/bin/idb_companion into a PATH dir, and de-quarantine with xattr -dr com.apple.quarantine). Gate evidence — start screen, candidates, solved + confetti, stats, hard loss, responsive widths — lives in screenshots/.

Project layout

src/
  engine/      pure TS: rng, grid, solver, generator, rater, fill (no React)
  state/       pure game reducer + selectors (single source of truth)
  storage/     localStorage adapter, active-puzzle resume, stats, settings,
               daily (Puzzle of the Day record)
  workers/     puzzle generation (web worker + main-thread fallback)
  components/  thin React rendering: Board, Cell, NumberPad, Start (home),
               sheets, …
  styles/      theme tokens, motion (prefers-reduced-motion), responsive
  lib/         pwa.ts (workbox-window: register + update toast), dayruns.ts
               (local-date helpers), daily.ts (Puzzle of the Day seed)
tests/         vitest + jsdom unit/a11y tests
e2e/           playwright e2e specs
public/        PWA icons (192/512/maskable/apple-touch)
scripts/       fixture + contrast helpers (see above)
openspec/      the change spec: specs/ (current contracts), changes/ (in-flight + archive)
assets/        README diagrams: sudoku technique SVGs
screenshots/   gate evidence (responsive, iOS)