- TypeScript 83.5%
- CSS 8.2%
- JavaScript 7.4%
- Shell 0.5%
- HTML 0.4%
|
|
||
|---|---|---|
| assets | ||
| e2e | ||
| openspec | ||
| public | ||
| screenshots | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| index.html | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.ts | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
| vitest.config.ts | ||
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
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 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:
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 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:
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.)
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 PlaywrightwebServerauto-runs the production build andvite 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):
cd /data/compose/15/data/static/sudokugit pull --ff-onlydocker run --rm -v "$PWD":/app -w /app node:22 sh -c 'npm ci && npm run build'—npm run buildistsc --noEmit && vite build, so a brokenmainfails the deploy beforedist/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 the424242givens as a gridprint-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). Runnode 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:
- Explore —
/opsx-explore(or just ask): have the LLM think the idea through against the current specs before any plan exists. - Propose —
/opsx-propose "…"(oropenspec new change <name>): the LLM writes a change underopenspec/changes/<name>/:proposal.md— what & whyspecs/<capability>/spec.md— the delta: what changes in the behavioural contractdesign.md— howtasks.md— the implementation steps, each with a verifiable check You review these artifacts before anything is implemented.
- Apply —
/opsx-apply: the LLM works throughtasks.mdone 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 inscreenshots/). - Archive —
/opsx-archive: the change moves toopenspec/changes/archive/and its spec delta is synced intoopenspec/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)