Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
An NYT-style Sudoku with unlimited Easy, Medium, Hard and Expert puzzles, auto candidates, and links to race your friends.
GamesTypeScript · CSSGitHub @dhensbyOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
An NYT-style Sudoku for when the day's three puzzles are done: a daily puzzle of each
difficulty, the same for everyone, with a calendar and streaks, and unlimited Easy, Medium,
Hard and Expert puzzles besides; Normal and Candidate modes, Auto Candidate Mode, a pause
that really hides the board, a history of everything you have played — and links to race
your friends on the same puzzle. Built with React, TypeScript and Vite, and runs entirely in
your browser.
▶ Play it in your browser — the latest green build of
main, deployed to GitHub Pages.Read the full README ↗An NYT-style Sudoku for when the day's three puzzles are done: a daily puzzle of each difficulty, the same for everyone, with a calendar and streaks, and unlimited Easy, Medium, Hard and Expert puzzles besides; Normal and Candidate modes, Auto Candidate Mode, a pause that really hides the board, a history of everything you have played — and links to race your friends on the same puzzle. Built with React, TypeScript and Vite, and runs entirely in your browser.
▶ Play it in your browser — the latest green build of
main, deployed to GitHub Pages.
React 19 · TypeScript (strict) · Vite · Vitest + Testing Library · Playwright · ESLint + Prettier.
See package.json for exact versions.
Bitter (Huerta Tipográfica) for the wordmark, titles
and givens, and Schibsted Grotesk (Schibsted /
Bakken & Bæck) for everything else — both under the
SIL Open Font License 1.1, and bundled with the app through
Fontsource (@fontsource-variable/bitter,
@fontsource-variable/schibsted-grotesk), so no font service is contacted while you play.
The design keeps a hard line between logic and presentation:
src/core/ — a pure, framework-free engine: grid geometry, a bitmask solver, a human-style
logical grader (with a worked example of every technique it knows), the puzzle generator, hints
and walkthroughs from the player's own candidates (Show me: the steps one cell depends on, sliced
from a solve, pruned of any it can do without and checked step by step; a hint, the next of
them), share-link codec, clock arithmetic, the game reducer (which remembers each cell's hints,
and the steps seen), the move log that replays it, playback (a solve shown again move by move,
from its log) and the comparison of two solves (their progress over play time, and the board
they overlay). No DOM, no React and no
English; reduce(state, action) is a pure function, randomness is passed in, and the whole
directory is held to 100% coverage.src/storage/ — a thin, injectable localStorage layer for preferences, the history and
saved games, which validates everything it reads back and never lets a full or broken storage
stop play.src/daily/ — the daily puzzles: dealing a date's four from its seeds, the archive of
days dealt by engines since replaced (and the rules for keeping it), and the store the app
asks for a day's puzzles, which deals them in the worker and keeps them.src/worker/ — the module Web Worker that generates puzzles off the main thread.src/update/ — how a page left open learns of a newer deploy: the version stamped in the
page, and the look at the page deployed now (see New versions). The stamp is
made by the build's own Vite plugin, in plugins/.src/ui/ — React components and hooks that render the engine's state and turn input into
actions, plus the dialogs in src/ui/dialogs/. Each dialog is a card on the one scrim
(DialogOverlay) that the App holds up while any dialog is open. A dialog that opens another
(History its Friends, the Solved dialog its Watch) swaps only the card, and the entrance plays
only as the scrim first arrives.Requires Node 22.12 or newer.
npm install
npm run dev # start the dev server (http://localhost:5173)
| Script | What it does |
|---|---|
npm run dev | Start the Vite dev server |
npm run build | Type-check and build the production bundle to dist/ |
npm run preview | Serve the production build locally |
npm test | Run unit & component tests once |
npm run test:watch | Run tests in watch mode |
npm run coverage | Run tests with coverage (100% enforced on src/core) |
npm run test:e2e | Build, preview and run the Playwright end-to-end suite |
npm run typecheck | tsc --noEmit |
npm run lint | ESLint |
npm run lint:fix | ESLint with --fix |
npm run format | Format with Prettier |
npm run format:check | Check formatting without writing (this is what CI runs) |
npm run dailies:freeze | Freeze the dailies dealt so far, before an engine change |
npm run dailies:switch | Hand later dailies to a new engine |
npm run moves:golden | Record the golden move logs again, after a rules change |
Each new puzzle is generated in a Web Worker, in a fraction of a second:
Fill a random complete grid, then remove givens in random order for as long as the puzzle keeps exactly one solution. That leaves a minimal puzzle — every given is needed — with 21–28 givens, like NYT's Medium and Hard. NYT puzzles are not symmetric, and neither are these.
Solve it the way a person would, always applying the easiest technique that makes progress,
and grade it by the hardest technique the solve needed. These are the labels the grader
(rate) gives any puzzle, a link's and one entered included; step 3 says
how the generator's own differ:
| Label | Hardest technique needed |
|---|---|
| Easy | Full houses and hidden singles in a box |
| Medium | Hidden singles in a row or column, naked singles, pointing pairs or box/line reductions |
| Hard | Naked or hidden pairs and triples |
| Expert | X-Wing, Swordfish, XY-, XYZ- or W-Wing, Skyscraper, 2-String Kite, XY-Chain or alternating chain |
| Beyond Expert | More than all of them: the solve stalls before the grid is full. Never dealt — only a link or a puzzle you enter brings one |
Keep it if it is the tier you asked for; otherwise try again. The generator holds its own puzzles to more than their label: an Easy is padded to 38 givens, a Medium must need its pointing pair or box/line reduction (one that needs only singles plays like an Easy with a snag), and an Expert's fish, wing or chain must be needed while at least 40 cells are still empty. Left to chance, most puzzles that need one only need it near the end, where it is quick to spot or to guess past — and they play like a Hard. A puzzle the techniques cannot finish is never dealt as Beyond Expert: in the rare case the attempts run out with it the closest to the tier asked for, it is dealt as an Expert, the hardest tier the game deals.
The tiers were calibrated against hundreds of published NYT puzzles: Easy needs only box hidden singles, every Medium needs locked candidates, and Hard needs pairs and triples but never a fish or a wing. Each tier rarely takes more than a hundred attempts — well under a second — and while you play, the next puzzle of the same tier is already being prepared, so "New game" is instant.
Your puzzles…, the last item under Random puzzle in New game, lists the puzzles you have typed in (see Your puzzles, below), and Enter a new puzzle, at its top, opens an empty grid for a puzzle from elsewhere — a newspaper's, a book's — to be typed in and then played like any other. The keys are a typist's, for copying a puzzle out row by row: a number fills the selected cell and moves on, from the end of a row to the start of the next, stopping at the last cell; Space, 0 or a dot leaves a cell blank and moves on past it; Backspace erases the cell's number or, on an empty cell, steps back and erases the one before, as a text caret does; Delete erases where it is; and the arrow keys move — left and right in reading order, as typing does, from the end of a row to the start of the next and back, so a row that ends in blanks is left with an arrow, and up and down stopping at the edges. A number is read by its key's place, as on the board, so Shift, Alt, the numpad and an AZERTY keyboard all type it, and a number or a blank held down goes in once; every Ctrl or Cmd chord is left to the browser. On a phone the pad copies a puzzle out the same way: tap a cell, then numbers on the pad, each filling the selected cell and moving on, as a typed one does; tap a cell to skip past blanks or to put one right, and Erase erases where it is, as Delete does. Every number typed in is set as a given — it will be one — and the selected cell's row, column and box are tinted as Settings has the board's.
Name, at the top in the title's place beside the close button, is for the puzzle's name — the paper's and its number, say: "The Times #5,123" — empty for a new puzzle, and holding the name of one opened again with Edit. The editor still opens on the grid, so numbers typed at once go in and a phone's keyboard stays down until you tap Name; Enter (Done on a phone) goes from the name back to the selected cell, as a tap or a click on the grid does, while a press on the pad puts its number in and leaves the keyboard in Name. The name is tidied as Rename tidies it (see Your puzzles) and kept with the grid however the grid is kept — but only with a number in, so a name typed over an empty grid is not kept, as the grid is not. The field is as tall as the close button on a phone, so the grid loses none of its height to it.
A puzzle already written out — on a puzzle site, in a file, in a message — can be pasted in whole
instead, with Paste, last in the row of buttons, or Ctrl/⌘+V on
the grid. What is read is the grid in the text copied (see readGrid): its digits in reading order,
a 0 or a dot for each blank, and everything else — spaces, line breaks, the bars and dashes a grid
is drawn with — passed over, so a grid on one line reads as well as one in nine; it must come to
exactly 81 cells. It takes the place of the grid typed, the selection where it was, and the line
says what it makes, as for one typed. Text that comes to any other count — words, a link, a grid
under a numbered heading — puts nothing in, and the line says "No puzzle in what you copied." until
the grid next changes; a clipboard that cannot be read, refused or missing, says "Couldn't read the
clipboard." Paste reads the clipboard as it is pressed, so Safari and Firefox first show a Paste
button of their own to press, and Chrome may ask leave the first time. Nothing asks before a paste
replaces the grid: the whole grid changes in plain sight, and one of Your puzzles pasted over by
mistake is kept as pasted once the editor closes, as any change is.
As the numbers go in, the grid dots every clash, whatever the board's setting, and one line says what they make so far: the first of these that holds, its words the same for as long as it does, so a screen reader speaks only as it changes, never at every number.
| The numbers so far | The line |
|---|---|
| None | "Type in the numbers from your puzzle." |
| A number twice in a row, column or box, however few there are: the first such row, column or box | "Two 5s in row 3.", "Three 5s in column 7.", "Two 5s in box 9." |
| Fewer than 17, the fewest any puzzle with one solution has | "A puzzle needs at least 17 numbers." |
| Every cell filled | "Every cell is filled: nothing to solve." |
| More than one solution | "More than one solution so far." |
| No solution, though nothing clashes | "No solution: a number must be wrong." |
A search deeper than the check allows (see checkGivens) | "Too complex to check." |
| Exactly one solution | "One solution: a Hard puzzle." |
Play waits for the last: until then it is marked unavailable — not disabled, so it keeps focus
and, focused, says why (just after a paste that came to nothing, it says that instead, until the
grid next changes). The puzzle is checked and graded exactly as a link's is, by the same
puzzleFromGivens, so a friend who opens your link sees the label you saw; and it is named by its
tier alone — Easy to Expert, or Beyond Expert for one that needs more than every technique the
grader knows (see How puzzles are made). No check can tell a mistyped
number from the paper's: one wrong number that clashes with nothing can still make a puzzle with
one solution, just another one (about one time in eighteen, in a trial on minimal puzzles). So a
line under the buttons says "Check every number against your copy."
Play opens it as the game on screen, behind its Start card — "Your Hard puzzle is ready." — and sets the game you were playing aside, as New game does: kept to resume, or dropped if you had only glanced at it. Typing a puzzle in is not seeing it: it is seen once Start shows the board, and its time counts as a new puzzle's, towards best and average times, and to share and race. That is the one exception to the rule that a puzzle you know never sets a best — you have seen its givens, typing them, but not the puzzle solved. A puzzle you have seen before here still starts as a replay; one you have an unfinished game of carries on with it, and one you have solved says so and offers to play it again, as its link would.
To send it to a friend without playing it, press Share…, between Play and Save: unavailable
until Play is, it keeps the puzzle as Play does and opens Share on the puzzle alone, a link with
only ?p= — and its name, if you have given it one (in Name, or with Rename in
Your puzzles) — labelled as Play would label it. Closing Share gives the editor
back on the grid and name as kept, the selection where it was and focus on Share…; no game of it is
started, and the one behind stays paused. Share in the header (in the ☰ menu on a phone) shares
it the same way while it waits behind its Start card, once Play has put it there; and once you have
solved it, Share sends your time with it, as for any puzzle.
Every grid you type in is kept in Your puzzles, finished or not: one with too few numbers, a clash or more than one solution is kept as readily as a puzzle ready to play, to finish another time. Save, between Share… and Paste, keeps the grid and its name as they are and stays in the editor, its label reading "Saved" — unavailable — for as long as both are as kept, and "Saved to Your puzzles." spoken as it is pressed; Play keeps them too, as it starts the game, and Share… as it shares it; and closing the editor — Escape, the close button or a tap outside, Escape in Name included — keeps a grid or a name that has changed since it was last kept, then goes back to the list, focus on that puzzle's Edit. A grid with no number in is never kept, nor a name typed over one, so a new puzzle closed with no number in leaves nothing behind, and a puzzle erased to nothing keeps the numbers and the name it had. Each grid is kept once: typing in one already in the list, number for number, is that puzzle, name and all — a name typed in Name replaces its name, and an empty Name leaves it as it was — and editing one into another's numbers makes the two one, keeping the name either had. A name is written only when you change it in Name, so one left as it opened never undoes a rename made meanwhile in another tab.
Each row gives the puzzle's name, or "Untitled puzzle", when it was first kept, and the line the editor would say of its numbers — "One solution: a Hard puzzle.", "A puzzle needs at least 17 numbers." — worked out afresh once a visit rather than stored, so a puzzle is always labelled as its link would be today. Then Play, for one ready to play, as the editor's Play does; Share, for one ready to play too, which shares the puzzle alone as the editor's Share… does, focus back on the row's Share as it closes; Edit, to open it in the editor again, its name in Name and the selection just after its last number, to carry on typing or rename it there; Rename, to name it without opening it, up to 40 characters, tidied as a player's name is (empty for none), its field's hint saying "Sent with its link, for friends to see."; and Delete, which asks first. Editing a puzzle changes it where it is: its games in History are of the numbers they were played with, and stay as they were. The list is in the order the puzzles were first kept, the latest first, so neither an edit nor a rename moves a row. It keeps up to 1,000 — as many as History keeps games — and saving one more past that forgets the one changed longest ago, never the one saved. Escape goes from the editor back to the list, and from the list back to the game, which stays paused until then; Share, opened from the editor or the list, closes back to where it was opened.
A puzzle's name goes with its link (&l=), however it is shared — from the editor, its row, its
Start card or a game of it in History, solved or not — and its message says it on a line of its own
under the first: "Try this Hard Sudoku!" over "The Times #1,234". A friend who opens the link sees
it under "Ready?" on its Start card and on the game's row in their History, and their own link to
it passes it on (see Racing friends). Your own games of it are drawn under the
name it has here now, not written into them, so a rename names them all, past ones included, as
does Share on any of them; and the name you give a puzzle here wins over one a link gave the same
numbers.
History's Export carries the list, names and all, to another browser, whose Import adds the puzzles it does not have, into whatever room is left of its 1,000, and takes the copy changed later of one it has, under the other's name if that copy has none (see Your data).
Each day has a daily puzzle of each difficulty, the same for everyone: the Hard of 13 October
2026 is generatePuzzle('hard', createRng('daily/2026-10-13/hard')) — dealt in the browser, in
the worker like any other puzzle, from nothing but the date (never from how far it is from
Daily #1). The day turns over at each player's own midnight, and Daily #1 was 7 October 2026, the
day the dailies launched: the calendar goes back no further. (Should the generator ever fall back to another
tier, which for an Expert is about a one-in-10¹¹ chance, the seed …/hard/2 is tried next, and so
on: a daily is always of its tier.)
Nothing in the repository or the bundle lists the puzzles to come: each day is dealt live, by
whoever opens it, so there is no answer key to read ahead in. There is no server either, so a
determined player could run the generator for a future date — but times are on the honour system
anyway. Dealing a day's four takes a few tens of milliseconds on a laptop (Expert has the long
tail: some 20 ms at the median, under a tenth of a second at worst over October 2026) and a few
hundred on a slow phone, so the game starts on today's four as soon as the page has loaded, keeps
every daily it deals — in memory, and the last 112 in localStorage — and lets a daily someone
is waiting for go ahead of those being made in advance. A date only has a daily once it has begun
somewhere on Earth, which lets a friend a time zone ahead send a link to "tomorrow's" puzzle; a
daily opened that way the evening before and started the next morning counts on its own day.
A change to the engine — anything that bumps GENERATOR_VERSION — changes what every seed deals,
past dates included, and a player's past dailies must not change under them. So just before an
engine is replaced, the days it has dealt are frozen into
src/daily/archive.json, and the new engine deals from the day after:
{
"segments": [
{ "version": 4, "from": "2026-10-07" },
{ "version": 5, "from": "2026-11-15" }
],
"frozenThrough": "2026-11-14",
"days": ["2026-10-07 v4 <easy> <medium> <hard> <expert>", "…one line a day…"]
}
segments say which engine deals which dates. days hold every frozen day from Daily #1 to
frozenThrough, one line each: the date, the engine version that dealt it, and its four puzzles'
share codes (givens only — the solver recovers each solution in a fraction of a millisecond). The
app takes a date up to frozenThrough from the archive and deals every later one live. Until the
first engine change it holds no days at all, and it only grows at an engine change, by about 150
bytes a day. It is a chunk of its own, read once the first game is on screen, so it costs the
first load nothing; and as its name is a hash of what it holds, a page left open across a deploy
that changed it already has it, or, failing that, asks for a reload rather than failing to deal.
A guard test, src/daily/archive.guard.test.ts, holds the file to two rules: the last segment's
version is GENERATOR_VERSION, and it starts the day after frozenThrough (on Daily #1 while
nothing is frozen), with every day before it frozen. So bumping GENERATOR_VERSION without
freezing, freezing without switching, and switching without freezing all fail CI, with a message
that says what to run.
Neither rule looks at the clock, so on a pull request the guard also holds the archive to the one
already released — main's copy, which CI fetches and names in DAILY_ARCHIVE_BASE (locally,
origin/main) — at the moment it runs: only what no player can have been dealt yet may change.
Main's days and segments must all still be there; a segment added must start on a day that has
not begun anywhere on Earth, so a switch that waited too long to merge fails; and the live segment
may be re-pointed to a new engine only while its first day is still ahead everywhere, so a version
changed by hand instead of freezing fails too. On main itself there is nothing to compare with:
once merged, the released archive is this one. CI checks at the moment it runs, so run it again
on a pull request that has waited a day or more.
Daily #1 (DAILY_EPOCH, and the archive's "epoch" and first segment) may move later, but
only while main's archive has nothing frozen, and only to a day that has begun somewhere. A date's
puzzle comes from its own seed, so a later Daily #1 changes no remaining day's puzzle — it only
takes days off the front of the calendar — and with nothing frozen, no frozen day is lost. A
Daily #1 still to come would take today's dailies from everyone, and since re-pointing an engine
needs its first day to be ahead everywhere, the rule also keeps a move from standing in for a
freeze: change the engine on its own. The guard then compares as though main's archive had begun
on the new Daily #1, its one segment starting there, and every rule above still applies. Moving
Daily #1 earlier, or later once anything is frozen, fails, saying why; a branch cut before
main moved Daily #1 later looks as though it moves it earlier, and rebasing onto main puts that
right. That is how Daily #1 moved from 1 to 7 October 2026 on launch day, so that the calendar
starts on the day the dailies launched. A record of a daily from a day before Daily #1 — one
opened in the few hours the earlier days were on offer — stays in the history as an ordinary
game, with its time, but not in the calendar or a streak.
Before dailies are first released — while src/daily/archive.json is not on main — no player
has been dealt a daily, whatever the date, and there is nothing to freeze. An engine change then
needs only npm run dailies:switch, which re-points the archive's one segment to the new
GENERATOR_VERSION (dailies:freeze says so and stores nothing). That is how the dailies kept
up with engine changes while they were still on a branch of their own; every engine change since
they reached main freezes and switches, as below.
Freeze the old engine's days and switch to the new one as the last step before merging, in the
same commit as the engine change, so that every commit passes the guard. On your branch, rebased
onto an up-to-date main, with the engine change (and the GENERATOR_VERSION bump) committed:
git restore --source=origin/main -- src/core src/daily/archive.json # main's engine and archive, for now
npm run dailies:freeze # freeze the days it has dealt
git restore -- src/core # your engine back
npm run dailies:switch # hand it every later day
git commit --fixup=<the engine change> -- src/daily/archive.json
dailies:freeze stores every day from the first unfrozen one through the day after
tomorrow, by your clock (or an earlier date: -- --through YYYY-MM-DD), as the engine in
src/core deals them. The two days ahead are for players in time zones ahead of yours and tabs
left open overnight; they briefly put a couple of upcoming days' puzzles in the repository and
the bundle — the only time any are there. It refuses to reach further ahead, refuses to run on
an engine that is not the archive's live one, never rewrites a frozen day, and writes the file
after every day, so a run stopped part-way carries on where it left off. It runs the engine
through Vite's server-side loading, which is some ten times slower than the bundled app: a few
tenths of a second a day.dailies:switch starts the bumped GENERATOR_VERSION the day after the last frozen one.
It refuses until the version has been bumped and some of the old engine's days are frozen, and
if the first day it would hand over has already begun anywhere. An engine switched in so
recently that it has not dealt a day yet — or any engine, before dailies are released — is
simply replaced.The engine keeps a game as a log of its moves, src/core/moves.ts — a record to replay the game
from, step by step, with nothing but the puzzle. Every game records one as it is played: each
change the game makes goes through one step, advance in src/ui/session.ts, which runs the
reducer and logs the move at the time on the play clock, and a guard test fails if the main hook
ever calls the reducer itself. A game is recorded from its first move or not at all — never from
part-way, which would read as a whole game — so one begun before logs were kept, one whose saved
board no longer replays from its saved log (a tab still on an older version played on without
logging), or one past 5,000 moves keeps no log. The log is saved just before the board, so it is
never behind it: if the board's write is refused, or lost to a crash, the log reopens trimmed back
to the moves that reach the board saved, which is the whole log of the game as saved. A log
written by a newer version, which this one cannot read, is left alone for that version to judge.
What the logs show is a game's mistakes, and the solve itself, played back (see
Watch your solve); where they are kept is under Your data.
verifyMoveLog checks a replay against a saved game.op × 81 + cell, the
moves without a cell above those — Check guesses on and off among them, in the two codes kept
for them when the format was made, so they came with no new rules version; a hint adds 2 more,
from a fixed table of techniques and units, where a wrong-marks hint took a code then unused,
again with no new rules version; a step hint takes descriptors then unused too, 256 +
technique × 28 + unit, and adds its hardest technique, where it falls and the route's length
(varints) and the step's 24-bit id, 4 characters; a page of Show me takes a code a cell after
Check guesses', 2761 + cell, and adds the id of its step — so an older build, which counted Show
me once a cell, refuses a log holding either rather than replaying it its own way) and the time
since the previous move as a varint, then a 3-character check, so that a log
cut short or mistyped is refused rather than read as another game. That comes to 3–4 characters
a move: about 160–250 for a solve that only places digits, 190–370 with auto candidates, and
500–1,100 for one that pencils in every candidate. Decoding is strict: anything malformed, or
from a format or rules version the build does not know, is refused whole. readMoveLogHeader
tells those apart — a log from an older version, one that needs a newer build, or a broken one.A game's mistakes are worked out from its move log by src/core/mistakes.ts,
judged against the puzzle's solution, so the count is the same live, saved and reopened. Two
kinds, counted apart:
Each one — the same wrong digit or strike made again included — opens a window of its own, which settles at the first of: 3 seconds of play time (the log's time, so a pause neither uses it up nor stretches it); a change to another cell (what one move does beside its own cell, such as clearing peers' notes, is not; Undo or Redo acts on the cell of the change it takes back; switching auto candidates changes every cell); any help — Check, Hint (a step hint included), Show me and each new page of it (opened again for free included) or Reveal; Reset; and the board becoming full (the move that fills its last empty cell), since "The board is full, but something isn't right" is news a slip must not wait for. A board full already tells nothing new, so a slip made on it, while you hunt for what is wrong, has its window like any other. As it settles it counts unless forgiven. A wrong number is forgiven only if its cell's answer was obvious as it went in — the cell held its answer already (a solved cell overwritten by tapping the wrong square), or the answer was a single on the placed numbers (the only digit left for the cell, or the only place left for it in its row, column or box; never judged from notes) — and the cell held its answer again before the window settled. So a wrong number in a cell whose answer was not obvious counts however fast it is put right, and so does a clash undone to an empty cell before another is solved. A candidate mistake needs no obvious answer: it is forgiven if the answer is back (pencilled in again, the strike taken back, or placed) in time. Put right is for good: the same wrong digit typed in again afterwards is judged afresh in a window of its own, while one brought back before it was put right shares the earlier window, and is forgiven only if the answer was obvious both times it went in. Reset never forgives; the solve settles everything still open, as put right.
With Check guesses when entered on, nothing waits: a wrong number typed, or a candidate
struck, while it is on counts the moment it is made, with nothing forgiven, and the wrong number is
struck through with the slash a Check gives (markWrongGuesses in src/core/game.ts; a right
number gets no mark). The log says when it was on, so each move is judged by the setting as it
stood. It judges only what is typed: turning it on judges nothing already on the board, and nor
does Undo or Redo, which bring a number back with the mark it had — either would be a free Check —
so a slip made just before it came on keeps its window, even brought back by Undo or Redo. Typed
again while it is on, though, that slip is marked, and counts at once. It is help,
recorded next to the time (assists.checkGuesses, sticky like auto candidates: turning it off
again stops the marking, not the record), and spoken as "guesses checked as entered" — "Checked as
entered" on a History chip and in the head-to-head. The setting follows the player from game to
game: changed in Settings, it takes effect as Settings closes; a game reopened, or one waiting
behind Start or a dialog, takes it up as its clock starts — so one switched off before Start is
played, and recorded, without it. A saved game marks such a number w, as a Check would, only once its assists
carry checkGuesses: true — so a tab still on a version from before the setting, which keeps that
field without knowing it, never takes those marks for Checks.
Show error counter shows the count so far as the game is played: "Mistakes 2", and "· 1
candidate" once there is a candidate mistake. It shows what has settled by the time on the clock
(mistakesSoFar in src/ui/session.ts, read on the timer's once-a-second render and at every
move), so with Check guesses off it never moves while a slip can still be put right — it would be
a free Check — and with Check guesses on it moves with the move. It is hidden with the board while
the game is paused, says "Mistakes —" for a game not recorded move by move, and tells a screen
reader "1 mistake counted." as each one settles. It is only a display: not help, and not recorded.
Counts only go up. Each save writes the count so far onto the game's record as mistakes: { values, candidates, atMs }, never lower than it was, and the solve freezes it: a game reopened
solved keeps the count it was solved with. atMs is the record's time it was counted at. A tab
still open on a version from before mistakes keeps the field as it found it while it plays on, so
a solved record whose atMs is not its own elapsedMs reads as not recorded — never as a final
count that missed what came after. Only solved games show a count (an unfinished game's would
work as a free Check — the error counter shows the settled count, see above), and a game not
recorded move by move from its start (see
The move log) shows none at all — never "No mistakes". A game solved by a version
that kept logs but did not count mistakes is counted from its log once, as a visit starts, if the
log holds the whole game.
Shared. A solved game's link carries its count — only a count as the record keeps it, stamped at the solve — inside the assists parameter (see Racing friends), and its message says it on the line under the time (under the puzzle's name, for one with a name), before the help: "No mistakes · with 2 hints", "1 mistake · 1 candidate mistake", "No mistakes". The friend's Ready card says it the same way, under a "Dan's solve:" that keeps it from reading as a rule of the race or as their own count, the head-to-head sets it beside their own on a row for each kind, and their History keeps it with your time. A count that is not known — a game not recorded move by move, or a link from a version before mistakes were shared — is never sent or read as 0: the link and message say nothing of it, and the head-to-head shows a dash, which a screen reader reads as "not recorded". The "Mistakes" row shows whenever either count is known, a clean 0 included; "Candidate mistakes" only once either of you made one.
Show help taken, in Settings beside Show timer and on unless turned off, shows the help the
game has taken so far as it is played, once there is any, in History's words — "Auto candidates ·
Checked as entered · 2 hints · 1 check · 1 reveal" — so a player sees each Hint, Show me, Check and
Reveal counted as it happens, and sees that asking again for a hint already given was free. The
"…" menu's Hint says how many have been used, "Hint (2 used)", in its name as well as on show. It
is only a display: help is recorded next to the time (assists) whether it shows or not, and
turning it off hides the count, the count on Hint and the count said with a move.
.tally in layout.css), designed
together: in the header, help taken is held to the error counter's widest line (measured at
173.5px on a phone on its side and 186.8px on a desktop), so the header's breakpoints, which make
room for the counter, hold for both — a daily's wordmark giving way 3.3em sooner for its
"Daily ·", as without them; on a phone's line, help taken gives way and the counter never does.
The header makes room, and a phone gives the line, only once there is something to show: a game
played without help keeps its wordmark and its whole board, and on a phone held upright the
board gives up the line's height as the first help is taken (with a hint, as the hint bar fills).
Every phone held upright, the shortest included, has the line: at 320×568 a hint's bar still
keeps to its two lines beside it.HelpTaken.tsx) — then
the counts alone, "2 hints · 1 check": what the "…" menu charges and what a player watches,
while the two switches show for themselves (the Auto Candidate switch, a checked guess's slash);
and where even those do not fit, their total, "Help 3", set as the counter's "Mistakes 2" is
and short enough to stay whole beside the widest counter on the narrowest phone. A count is
never cut short; only "Help taken", the total's wording for a game whose only help is a switch,
could ever end in an ellipsis. Hovering shows the full words, and a screen reader is always given
the full list ("Help taken: auto candidates, 2 hints").A solved game can be watched again, move by move: Watch your solve in the Solved dialog, and
Watch on a solved game's row in History, open a player (PlaybackDialog) over the game,
which stays paused behind it, as it does for any dialog. Watching changes nothing — no record,
count or time moves, and nothing is written — and closing it goes back to the dialog it was
opened from as it was (History still filtered, as long, and at the game), focus back on its
Watch.
A solved game's time can carry the solve itself: the Share dialog — from the Solved dialog's
Share your time, or History's Share — has a switch, Include my solve ("Your friend can watch
how you did it. If they watch before solving it themselves, they won't get a time for it."),
shown only when the game has a log this build plays back to its solve at the time being shared,
no longer than 4,000 characters (MAX_SHARED_LOG_LENGTH; a chat app may cut a longer link short,
and a link cut short opens nothing). It is off each time the dialog opens: a shared time is
for racing, and its link stays short — the solve makes it several times longer (some 200–1,100
characters more) and gives the puzzle away, so sending it is the sharer's choice, never something
they find they did. Switched on, the link gains &s= and the message a last line, "You can watch
my solve too, once you've had a go.", which says nothing of how the solve went. A replay's
solve, or one after watching a friend's, has no time to go with, and is never sent.
Opening a link with a solve, the log is decoded strictly and replayed against the link's givens
(sharedSolveRefusal in src/core/playback.ts): it must play to their solve, and end there, at
the time the link claims — within a second, the rounding a link's whole seconds and the log's
tenths allow — so a link cannot pair one solve with another's time. A solve that fails any of it
is dropped without a word and the link opens as it would have without it, but for one recorded
by a newer version, which says so under the board: "This solve needs a newer version of the game
to watch." A solve that checks out is kept with the challenge (Challenge.log) — and, from a
sender with a player ID, among the puzzle's friends' solves too (sudoku.friendSolves), whatever
the link opens, so it can be compared once you have solved the puzzle however the race goes (see
Comparing solves).
sudoku.watched, its
share code, up to 2,000 like the puzzles seen, outliving its records) and flags every
unfinished attempt at it (GameRecord.watched) — the game on screen and any in History — and
every attempt begun since, by any way back to the puzzle: a reload, Play again, the link
again, the generator or a daily dealing it, a game resumed from History, an import. The rule
lives where every record is written (upsertRecord), so no path can miss it. Such a solve is
recorded as solved, without a time: the Solved dialog says "Solved — no time recorded: you
watched a solve of this puzzle first." where the time would be, and the header's timer shows a
dash ("Solved, no time recorded"); it is no best and in no
average; a daily's counts for no streak ("You watched a solve of it first, so it doesn't count
towards your streak"), and its calendar mark is a solve that did not count; History says
"Solved after watching a solve"; sharing it shares the puzzle alone; and the head-to-head shows
a dash for your time ("no time recorded") and, in place of a verdict, "You watched a solve
first, so there's no time to compare." Its mistakes still show. The Ready card, once the solve
has been watched, says "You've watched a solve of this puzzle, so no time will be recorded."
in place of "The timer starts when you do.", and watching again asks nothing more.The player is the one your own solves play in (see Watch your solve), headed "Dan's solve" (the name you gave the friend, if you have, else their link's) over the puzzle's tier, or its daily, and their time.
Once you have solved a puzzle whose link carried a friend's solve, Compare solves, beside
Watch Dan's solve — in the Solved dialog's head-to-head, the offer to play a solved puzzle
again, and the solved game's row in History — sets your solve against theirs (CompareDialog). It
is offered only when both play back: your own move log (see The move log) and the
one the link carried (Challenge.log) — or any other friend's solve of the puzzle kept (see below).
So it is offered with no race at all, or a race whose link carried only a time, wherever another
friend's solve is kept: beside Watch your solve in the Solved dialog, under the verdict of a
time-only race, on its own in the offer to play a solved puzzle again (a solved daily chosen again,
a puzzle typed in again), and on the row in History. A solve recorded with no time, after watching theirs
first, can be compared too: it simply shows no time. Like watching, comparing changes nothing —
no record, count or time moves, and nothing is written — the game behind stays paused, as for any
dialog, and closing it goes back where it was opened, focus on its Compare solves.
More than one friend: every friend's solve a link brings — checked, from a sender with a
player ID — is kept, one per puzzle for each device that sent one (sudoku.friendSolves, see
Your data): a newer link that takes over a part-way game's race, or an offer to
play a solved puzzle again closed without playing, loses nobody's solve. A link with a time and
no solve brings nothing to compare: its time is kept for the friend's
head to head alone (sudoku.friendTimes), which Compare never reads. Compare
lists every friend whose solve of the puzzle plays back, one per friend (a friend merged in
Friends is one choice, two again once split; your own browser's links are never
among them), under the names you gave them. With two or more, the heading becomes the choice:
"You vs [Dan · 6:15 ▾]", a native select (the platform's own picker on a phone) as tall as the
close button beside it, so the header and the board are no smaller for it — the solve of the race
or link it was opened from first, if it carried one that plays back, the rest fastest first, each
friend by their whole name and time (a long one cut at the closed choice's edge, never in what a
screen reader hears). Opened from a friend's head to head, that friend's solve
comes first instead, if one of theirs plays back, and the rest in that order after it — so the
comparison opens on theirs. Choosing another starts the comparison over with them, at 0:00,
paused, at 1×; focus stays on the choice, which keeps its keys (the comparison's are its own
elsewhere). The Solved dialog, the offer and History's row of a solved game offer Compare solves
whenever any friend's solve of its puzzle plays back, raced or not. Comparing is still you against
one friend at a time: the board tells two players apart, never more.
The heading: "You vs Dan" (the name you gave the friend, if you have, else their link's; "You vs your friend" for a link with no name), over the puzzle and both times: "Easy · You 5:23 · Dan 6:40", or "You: no time recorded" — a line left to a screen reader on a short phone and on a phone on its side, where the board needs the room and the chart's labels give the times.
Stepping and playing: it opens at the start, paused, on Play, with a playback's controls: Play and Pause (Watch again at the end), a step back or forward, to the start or the end, and speeds of 1×, 2×, 4× and 8×. A step goes a at a time: a second at whose end either board's numbers stood otherwise than a second before, or in which either of you made a mistake that counted or took help (the marks on the chart). A second in which you only pencilled candidates, or put a number in and took it out again, is passed over: nothing on the board changed. Everything both of you did in one second is one moment, as the chart and the board are read at the ends of seconds. Play goes through the moments on one clock, as they went: each waits the play time since the one before, a long wait shortened to 1.5 s (at 1×) — for both of you at once, so who was ahead, and when, stays true — while the readout and the chart's cursor show the real play time. Under the board a caption says what each of you did in the moment on show, you first, each move as a playback captions it ("You: 5 in row 3, column 4", "Dan: Hint: X-Wing", "You: 4 in row 5, column 5 — solved"), and the cells its moves acted on are outlined, as a playback outlines a move's (nothing for a move about no one cell, such as a Reset or Check the puzzle). And it plays as a playback does, sharing its timing and its stylesheet: the next moment's cells are outlined a moment before it is shown (0.4 s, or half the wait if that is shorter, taken out of the wait, not added to it), the board, the caption and the time still on the moment on show, so you see where before what; a pause takes the outline back to the moment on show; and a number fades in as it goes in — yours, or your friend's, a matching one included — while what a moment takes away goes at once and a number that stays never fades again, whichever way you step. With less motion asked for, nothing fades. The keys work anywhere in the dialog — Space plays and pauses, ← and → step a moment, Home and End jump — but where a control has a use of its own for one (Space presses a button, the arrows move the speeds), and on the chart, whose own keys move it a second at a time. A step or a jump taken by hand speaks the time, the counts and the moment; the chart, which speaks the time and the counts as its value, speaks the moment alone, and only as it changes; and a comparison playing by itself speaks only where it stops — where it was paused, and the end, once — but for the chart's value, if the chart has focus, which a screen reader reads as it moves, as it does a playback's scrubber.
A share link carries the puzzle itself, so it opens the same puzzle for anyone, on any version of the game — there is no server and nothing is uploaded.
| Parameter | Meaning |
|---|---|
?p=<code> | The puzzle: its givens packed into at most 57 URL-safe characters, about 26–34 for the game's own puzzles. |
&t=<secs> | The sharer's time in whole seconds — the time to beat. |
&n=<name> | The sharer's name (optional; past 24 characters it is cut short with an ellipsis); shown until the friend names you (see Friends). |
&u=<id> | The sharer's player ID: 10 random letters and digits, made once per browser the first time it shares a time. Only with t=. Versions before it ignore it. |
&a=<assists> | Help the sharer took: c auto candidates, g guesses checked as entered, h<N> hints, k<N> checks, r<N> reveals; mistakes: m<N>, candidate x<N>. |
&d=<date> | A daily's date (2026-10-13): the opener checks it against that day's daily of the tier. |
&l=<name> | The puzzle's name (at most 40 characters): the sharer's in Your puzzles, else the one their link gave, with or without t=. Versions before it ignore it. |
&s=<solve> | The sharer's solve, its move log as stored (base64url), only with t=: see Sharing a solve. Versions before it ignore it. |
Opening a link:
u= is this browser's own ID — races no one: its time, solve and
ID are set aside and it opens as a link without a time would, so a puzzle you have solved here
shows your time and offers to play it again, with no head-to-head against yourself and no
solve of your own to watch as a friend's. A game raced against your own link by a version from
before such a link raced no one — one whose link carried this browser's ID — has that race taken
off as a visit starts (repairOwnRaces), the game on screen included, and so does one an import
brings in: History lists it as a game of your own. A race from a link older than IDs carries
none, so it cannot be told from a friend's, and stays; Delete in History removes the game.The difficulty is always re-graded from the puzzle itself rather than taken from the link — so
a puzzle the techniques cannot finish reads Beyond Expert wherever it is opened, and a game of it
is raced, timed and shared like any other — and the parameters are cleared from the address bar
once read, so reloading never drags you back.
A version of the game opens a link from a later one all the same: it skips a letter of a= it
does not know (g came after the others), and reads the rest as written. A version from before
solves were shared skips s= too — it only stays in that version's address bar, which clears
the parameters it knows and no others; harmless, as nothing there reads it. A version from before
player IDs skips u= the same way, and leaves it in its address bar, and so does one from before
puzzle names with l=: it opens the puzzle without its name, and its own link sends none.
A puzzle's name (l=) is free text anyone can write into a link, so it is read as a name typed in
Your puzzles is tidied — invisible and direction-control characters out, spacing made single, cut at
40 characters — and drawn in its own direction (<bdi>) on the Ready card and in History; in
Share's preview of the message it has a line of its own, so its direction cannot run into the rest.
It is drawn nowhere else: not the header, the Solved dialog or the offer to play again. A link
without a time asks no one who sent it, so its name shows at once; it says no more than the message
it came with. The game keeps the name its link gave (title, see Your data) whatever
comes after: a game reopened by a later link keeps the name it has, taking the link's only if it has
none; a fresh attempt from the offer to play a solved puzzle again takes the link's, or else the
earlier game's, as History's Play again takes the game's; and Share sends it on — unless you have a
name of your own for the same numbers in Your puzzles, which wins wherever the puzzle is drawn or
sent.
The mistakes ride in a= rather than a parameter of their own, after the help: m<N> is always
there once the count is known — an unaided, mistake-free solve sends a=m0 — so a link that says
nothing is told apart from one that says none, and x<N> is added when there were candidate
mistakes (a=ch2m1x2: auto candidates, 2 hints, 1 mistake, 2 candidate mistakes). Every version
before reads a= by picking out c, h, k and r and skipping anything else, so a new link
opens there with the same help, its mistakes unsaid; a test holds the old decoder to that. Read
here, a link without m — every older link — leaves the mistakes not recorded, never 0, and so
does one whose counts no game could make (more than 8 wrong numbers a cell, or a strike a cell),
while the time and help still stand.
A daily's link names it — "Sudoku Daily · 13 Oct 2026 · Hard · 5:23" — and carries its date as well as its givens. The link opens as any shared puzzle while the game checks the date against that day's daily (dealt live, or taken from the archive); if the puzzle is that daily, the friend's game is recorded as it, shows in their calendar, and counts towards their streak if they start it on that date. A date that does not match, or has no daily yet, is simply ignored.
Times are on the honour system: the link says what time you claim, and nothing can verify it. It is a game between friends. What the game does do is keep its own records honest — a reset clears the board but not the clock, a game with revealed cells never sets a best time, and neither does playing a puzzle again once you know it, even after deleting the game you saw it in. The one exception is a puzzle you enter yourself: you have seen its givens, typing them, but not the puzzle solved, so its first game here counts as a new puzzle's would. A replay's time is never offered as one to beat, either: sharing it shares the puzzle alone. A friend's head to head counts the same way: only a time that could be raced wins, loses or ties against theirs.
Every browser that shares a time sends its player ID with it (u=, see
Racing friends): ten random letters and digits, made once, the first time it
shares one. That ID, not the name a link gives, is what tells one friend's times from another's —
a link's name is whatever its sender typed that day, and can change from one link to the next. A
link without an ID, from a version before them or with a garbled one, is raced as before, under
its own name.
Every link opened with an ID is noted (sudoku.friends, in src/storage/friends.ts): the ID,
the latest name its links gave and when its latest link was opened, whatever the link goes on to
open — a new game, one you were part-way through, or the offer to play a solved puzzle again. A
link from your own browser's ID is not. Friends, at the foot of History (shown once there is
anyone in it), lists everyone noted, the most recent link first — what each goes by, when their
latest link came, and what their links call them — with Head to head (see
Head to head) and Rename on each. The name you give someone there is the one
you see on every race of theirs: the Ready card, Watch Dan's solve
and the warning before it, the solve played back, Compare solves, the head-to-head when you
finish, the offer to play a solved puzzle again, and History's "vs Dan" — past races included, as names are worked out
as they are drawn, never written into the history. Each race keeps the name its own link gave,
which shows again if you clear the name you gave (save it empty). Saving the name a link gave
keeps it too, whatever their later links call themselves. A race from before player IDs keeps its
link's name for good, and so does a race of a device whose links came in before Friends existed,
until its next link notes it. Other tabs pick a new name up as History or Friends next opens.
The first link from an ID the list does not hold yet asks Who sent this? before anything the link opens — its Ready card, the game you were part-way through or the offer to play a solved puzzle again, each coming once it is answered — the board's place left blank meanwhile, so a rude name in the link is not on show behind it, and the clock never running. (Races already in History with that ID do not count on their own: only the list does — which History's import fills too, see below.) Its field holds the link's name, focused and selected: type over it, or keep it, and Save (or Enter) names them, just as Rename in Friends would — saving the link's own name keeps it whatever their later links say. Saved empty, or skipped (Skip, Escape, the close button or a tap outside), they go by their links' names until you name them in Friends. Either way they were noted as the link opened, so the question is never asked of them again — closing the tab before answering is the same as Skip. A link without an ID, from your own browser, or from anyone already in the list never asks.
An ID belongs to a browser, not a person, so someone who shares from their phone and their laptop arrives as two friends. Once anyone else is in the list, Who sent this? asks below its field Or is it a friend you have already, on another device?, with everyone else to choose from, latest first — never chosen for you, however alike the names, as a name is exactly what a stranger can copy. Choose one and Save, and the new device joins them, under the name you gave them (the name field is disabled meanwhile, greyed out but not hidden, so nothing moves). In Friends, Merge… (once there are two friends) says one is the same person as another, picked from everyone else, latest first, with the latest picked to begin with: the two become one, under the name you gave the one you chose, or else this one's — a merge never makes a name up, and never loses the one you gave. Pressing Merge is all it asks, as a merge can be undone: a merged friend lists their devices, each by the name its links last gave and when, and Split off on any of them makes it a friend of its own again, unnamed, so its races show the name each of its links gave until you name it.
The list keeps up to 200 devices (some 41,000 characters with every name at its longest). Past that, the device heard from longest ago is forgotten — with the name you gave its friend, if it was their last — and its next link is noted as someone new; never the device whose link is being opened. The list is never shed to make room for anything else.
History's Export carries the list to another browser, whose Import adds the devices it has never had a link from, into whatever room is left of its 200 — each joining whichever of its friends holds a device the file groups it with, or else a new friend under the file's name — and never renames, merges or splits a friend it has (see Your data).
An ID proves nothing, as a time proves nothing: anyone holding one of your links can copy its
u=, and their links would then show under the name a friend gave you. It is the honour system,
as times are; trust is on first use.
Head to head, the first of the buttons on each row in Friends, shows how you stand against that friend: History of your games against them alone, titled "You vs Ally" (the name you gave them, else their latest link's; "You vs your friend" for one with no name). Friends opens with focus on the first row's Head to head, and the head to head goes back to Friends as it closes, the list as stored now, focus on the Head to head that opened it. The game behind stays paused throughout, as it does for History.
It lists your games of every puzzle the friend has sent you a time for — a race from any of their devices, or a time or a solve of theirs kept from a link (see Your data and Comparing solves) — that you have played too: every game of such a puzzle, whether it was raced against them, against someone else or no one, a replay or not. Every link of theirs with a time is kept for this, with their solve or without, whatever it opens: so a puzzle you had solved already, whose offer to play it again you turned down, is counted, and so is one whose race a later link took over. (Only links opened since times were kept, in a version that keeps them: a time-only link opened before, on a puzzle you had solved or a race taken over since, left nothing to count.) Never a puzzle you haven't played, however many of their times are kept; never a race from before player IDs, which could be anyone's; and never anything from your own browser's ID. A merged friend's devices count as one, and a merge or a split in Friends regroups them at once: nothing of the head to head itself is stored, and it is worked out afresh each time it opens.
Your time counts when it could be raced — when Share would send it as one to beat (see Racing friends): normally your first game of the puzzle, the time a link to it is set against once you have solved it. A replay, on a board you had seen before, and a solve after watching one are listed all the same, with "Not counted: you'd played it before" or "Not counted: you watched a solve first" where the verdict would be; an unfinished game is listed as History lists it, with no verdict yet. Their time is the game's own race, when that was theirs; for any other game, a solve before a time alone, and one kept before one raced: the solve of the puzzle they sent most recently, kept to compare with, else a race's solve, else the time they sent most recently, else a race's time alone, from another game of the puzzle — the newest game first. (A device all but always sends the one time for a puzzle, the one it could race, so the order matters most between a merged friend's devices.) A game counted is judged as the Solved dialog's head-to-head judges a race: on the two times alone, in whole seconds, the unit times are shown and sent in (5:23.9 is a dead heat with 5:23), the faster winning. Help, mistakes and reveals, yours and theirs, are on the row to see, but never scored.
Under the title, a headline says how the two of you stand over every game counted, whichever tab is on show — the leader's wins first, as a score is said: "You lead 7–4, with 1 dead heat.", "Ally leads 4–2." ("Your friend leads 4–2."), "All square at 3–3." — or "No times to compare yet." A screen reader hears the score as "7 to 4", and the headline as the dialog opens. History's tabs stay, and in place of its stats All has a table by level — Played (every game listed, counted or not), Won, Lost and Dead heats, for Easy to Expert, and for Beyond Expert once there is a game of one — and a level's tab the same four as tiles, with no Games per day, which charts your own playing. Every count is of every game listed, not only the rows built before Show more. Each row is History's own, its race line ("vs Dan 5:40") and all, with a line of its own under it: "Won by 0:42", "Lost by 1:20" or "Dead heat" — after the friend's time ("Ally's time 6:44 · Won by 0:42", with their help and mistakes, as a race line has them) when the row's race was someone else's, or it had none; nothing under a race of theirs still being played, whose race line has their time already. With no games it says what will show: "No games with Ally yet: a puzzle shows here once they've sent you a time for it and you've played it too."; a level with none, "No Hard games with Ally yet."
The rows keep History's actions but Delete: the head to head is about the two of you, and a game deleted would change the score, so the history's upkeep stays in History — as do Friends, Export and Import, which its foot does without. Watch, the race's Watch Dan's solve and Compare solves come back to the head to head as it was — on the same tab, as long, focus on the button pressed — and Share comes back to it too. Resume and Play again go to the game, as from History, and a game that cannot be reopened says so over the game, never behind Friends. Compare solves opens on the friend's solve, when one of theirs plays back, any others still there to choose in its "You vs" heading.
Everything is stored in your browser's localStorage under sudoku.* keys — preferences (with
this browser's player ID, once you share a time), the history (up to 1,000 games, a daily's with
the date it is the daily of and the date you started it — the calendar and the streaks are worked
out from it — each game's mistakes, stamped with the time they were counted at, and the
name a link gave its puzzle), what the dailies of games pruned from the history said (a few bytes a
day, kept for good, so neither a best streak nor the calendar forgets them), the saved state of
unfinished games (up to 50), every game's move log, the puzzles you have seen (up to 2,000, so a
puzzle stays seen after its game is deleted), the puzzles whose shared solve you watched before
solving them (sudoku.watched, up to 2,000 — see Sharing a solve), the friends
whose links you have opened (sudoku.friends, up to 200 devices — each one's ID, the latest name
its links gave and when — grouped into friends with the name you gave each; some 41,000 characters
with every name at its longest — see Friends), the puzzles you have typed in
(sudoku.puzzles, up to 1,000 — each one's numbers, the name you gave it, and when it was first
kept and last changed; under 300 characters each with the longest name there is, so some 0.3
million for a full list at worst and about 0.2 million with names like "The Times #5,123" — see
Your puzzles), the friends' solves your links brought (sudoku.friendSolves, up
to 200 — one per puzzle for each device that sent one, the one received longest ago going first;
some 300 characters each besides its log, so 0.1–0.3 million for a full list and under a million at
worst; kept after a puzzle's games are deleted, as the puzzles seen are, but shown nowhere until you
have solved the puzzle — see Comparing solves), the friends' times your links
brought (sudoku.friendTimes, up to 1,000, as many as the history's games — the time of every
link from a friend's device with one, with their solve or without, one per puzzle for each device,
the one received longest ago going first, and never a solve; some 270 characters each, so some 0.27
million for a full list and under 0.4 million at worst; kept after a puzzle's games are deleted,
but shown only in a , against a game of yours) and the daily puzzles
already dealt (the last 112, so they need not be dealt again). A game raced against a link that
carried a solve keeps it with the challenge, inside its record, held on reading back to the shape
and length of a log in a link (at most 4,000 characters; some 200–1,100 in practice). There are no
accounts and no tracking: the player ID is random, and travels only in links you share with a
time; a puzzle's name, only in links to it and in History's export. Now and then the game fetches
its own page again, to see whether a new version has been deployed ();
nothing goes with it that a reload would not send. A friend's stores
nothing: it is worked out from the history (), ,
and each time it opens.
Every game in the history, finished or not, keeps its move log (see
The move log) under a key of its own, sudoku.moves.<id>, listed in
sudoku.moveLogs — never inside the history, which is rewritten whole as you play, nor the saved
board, which a finished game loses once it is off screen while its log stays. A log is written with
its game, only when it has changed, and goes when its game does: deleted from History, pruned past
1,000, dropped as a glimpse, or replaced by an import. Once a visit, a sweep deletes what a tab
still on an older version, which knows nothing of logs, can leave behind: a log whose game is no
longer in the history, and a finished game's log whose last move is not the solve (that tab played
the game to the end without logging, and dropped the board that would have shown the log to be
short) — as well as a log that no longer reads back at all. A log is some 3–4
characters a move: about 200 for a solve that only places digits, 300 with auto candidates and up
to 1,100 for one that pencils in every candidate. With a full history of 1,000 games, 50 of them
unfinished, everything the game stores comes to about 0.9 million characters, 0.4 million of it
logs, for a mix of those three kinds of solve — 0.75 million if every game only placed digits, and
1.5 million if every game pencilled in every candidate — plus up to 0.3 million for a full Your
puzzles, 0.1–0.3 million for a full list of friends' solves kept (under a million if every one
pencilled in every candidate) and some 0.27 million for a full list of friends' times kept. That is
well under half of the ~5 MB (some 5.2 million characters)
the browser allows the whole of dhensby.github.io, which every project hosted there shares.
When that space runs out, the game makes room, cheapest loss first, trying the write again after each step: the logs of games no longer in the history, then the oldest finished games' logs, about 50,000 characters at a time (an eighth or so of a full history's), until the write fits — so no more than one such chunk beyond what the write needed is ever lost; then the friends' solves kept to compare with, all at once (a key of its own: written without making room for itself, so a solve that does not fit is simply not kept, and dropped first of the friends' solves, as a race keeps its own besides); then friends' solves kept with their challenges (every game's but the one on screen, which keeps the name and time it was racing; only the button to watch the solve goes); then the friends' times kept, all at once (written, like the solves, without making room for themselves, but shed later: each is a head to head's result in a few hundred characters, where a solve gone mostly costs only something to watch or compare, its link's time being kept too, but for a solve kept before times were); and only once those are gone too, the saved boards of all but the 10 most recently played unfinished games, and finished games beyond the newest 300, with their logs. The game on screen, the logs of unfinished games, the preferences, the friends list and your puzzles are never shed. Finished games' logs go first because nothing needs them to carry on playing (a game whose log is shed keeps its record and its count of mistakes, but can no longer be watched). In a test with a full history and the storage filled to the brim, one chunk (130 logs) makes room for the whole of a pencil-every-candidate game played after it; with the quota cut by another 300,000 characters, 754 of the 950 finished games' logs go and every record and board stays. If a log cannot be written even then, its game's old log is deleted rather than left stopping short of the board saved beside it.
A tab left open on an older version after an update carries on saving, so the game keeps what it
does not recognise rather than wiping it: small fields a newer version added to a game's record,
its saved board or the preferences (up to 8 per object, each at most 200 characters of JSON) are
written back untouched, and a theme it does not know is applied as System, with none shown as
chosen in Settings, but kept until you pick one. (So a record's watched flag survives such a
tab, as do a challenge's player ID and the preferences' — though that tab, knowing nothing of the
flag, would show and count the solve's time until it is reloaded; a challenge's solve longer than
those 200 characters, as most are, is dropped by it. The friends' solves kept to compare with are
a key such a tab never reads or writes, so it neither adds to them nor sheds them; and so, to every
tab from before them, are the friends' times kept, so a link opened there keeps no time for a head
to head.) A Beyond Expert game is stored as an Expert
one with a beyond: true flag beside it, and its saved board as an Expert board, so a tab from
before the label reads both, keeps the flag and shows the game as an Expert until it is reloaded. A
game of such a puzzle recorded before the label existed has no flag: it keeps the Expert label it
was recorded with, and Play again from it begins another Expert game. A game of a puzzle you
entered is stored as one from a link is (source: 'shared', or 'replay'
for a puzzle seen before) with an entered: true flag beside it, so a tab from before Enter a
puzzle keeps the game, counts it as a link's game and keeps the flag; its Ready card there says
"Someone shared a Hard puzzle with you." until it is reloaded. A game from a link that named its
puzzle keeps the name as title on its record, tidied as one typed in Your puzzles is — at most 40
characters, and so, even at its worst, well inside those 200 — so a tab from before puzzle names
keeps it, though it shows no name, and sends none in its links, until it is reloaded. A tab from
before the friends list never touches sudoku.friends, so the names given there survive it too,
though it shows each race under its link's name until it is reloaded; and a tab from before Your
puzzles never touches sudoku.puzzles, so the puzzles kept survive it untouched, though it has no
list to show them in.
Some browsers clear a site's storage after a while away (Safari does after seven days without a visit, unless the game has been added to the home screen). Use Export in History to keep a copy, and Import to bring it back or move it to another browser — imported games, the puzzles seen, the puzzles watched and the dailies' record are merged with what is already there, and every unfinished attempt at a puzzle watched, here or in the file, is flagged as watched. The friends list is merged too: a device this browser has never had a link from joins the friend the file has it with — one of yours, if the file groups it with a device you know, or else a new friend under the file's name — into the room left under the cap. A friend you already have is never renamed, merged or split by an import, so importing the same file twice adds nothing. So are the friends' solves kept to compare with, and their times kept for a head to head, each list to its own: one for a puzzle and device not here yet is added, as received before any here, never in place of one here, into the room left under the cap; a version from before the times leaves a newer file's out. Your own player ID is not exported: each browser keeps its own. Your puzzles are merged too: one the file has that is here already — the same puzzle, edited on either side since — keeps the copy changed later, under the name the other gave it if that copy has none; one new here is added, into the room left under the 1,000, the most recently changed first, never forgetting one of yours to make room; and the same numbers kept on both sides under two entries become one, as a save would make them, keeping a name either had. History's message counts them — "Imported 1 new game, updated 0, and 3 of your puzzles." — and importing the same file twice adds nothing. A file from before puzzles were exported has none, and imports as it did; a version from before them leaves a newer file's puzzles out. The file carries every game's move log too; an import takes the log of each game it adds or replaces, leaving out (on its own) any log that does not read back exactly, and never makes room for one by deleting anything: logs go only into space that was free, and if the imported games themselves only fitted once some of the logs here were shed, none of the file's logs are taken.
Unit & component (*.test.ts[x], Vitest + Testing Library): the engine is tested
exhaustively, including a soundness harness that checks every placement and elimination the
grader makes against the known solution — and every step's pattern against its board with
isStepValid, the check a walkthrough must pass before it is shown — golden puzzles pinned per
tier, and property tests over many seeds. Components and hooks are tested through real
interactions. The palette is read straight from the stylesheet and held to its contrast targets
pair by pair, in all three palettes (High contrast's text at 7:1, but for the guide's struck-out
candidate on the same-number fill, held at 4.5:1) and under simulated colour-blindness
(src/styles/contrast.test.ts) — a hinted step's sage tint and its given twin included: every
ink on them, the rules and marks, a clear step from plain cells and givens, and from the same
numbers and the selected block, which can sit among its cells. The tint takes the place of the
selected cell's row, column and box whenever it is on show, so it never meets the peer fill,
which it would be told from by hue alone. History's five level colours are held there too, in
each palette: each 3:1 against the dialog, and any two of them apart in normal vision and under
each simulated colour-blindness. The daily archive is held to the engine in the code, and on a
pull request to the archive main has released, by a guard of its own
(src/daily/archive.guard.test.ts, see The daily archive), and date
arithmetic, streaks and History's games per day are tested across the clocks changing in London
and New York (a 25-hour day is one day), and across a change of time zone between starting a
daily and looking at its streak. The move log is
replayed after every move of random games — every action there is, with reloads in between —
and must rebuild the live game each time; its golden logs hold the reducer to its rules (see
The move log). The game's own logs are checked move by move through the main
hook on a play clock of the test's own, and a full history of them is held under a third of the
storage quota, shedding in the order Your data gives. Mistakes are held to every
rule by a table of scenarios at exact play times (src/core/mistakes.test.ts) — a slip put
right at 2.9 s and at 3.1 s, every way a window closes, Undo and Redo, candidates in both
layers, Check guesses on from the start and switched on part-way — and by random games, whose
counts must never fall and must stay at nothing for a game that never lets a wrong number
stand. What Check guesses marks has golden logs and a checklist entry of its own, and the error
counter is checked to move only once a slip has settled, on the main hook's own play clock.
Help taken is held to History's words for every kind of help, to shortening by measure (full
words, counts, total) and to ticking on a charge only — not for help a game behind Start takes
up as it starts, and for Show me's, only as its walkthrough closes; through the main hook, a hint
asked for again costs and says nothing more, while the next new one says "2 hints used.", and
Check guesses switched on mid-game says it is help. The owner's report — the Expert of 10
October 2026, row 1, column 2, in Auto Candidate Mode — is played through the main hook a hint
at a time: "Step 1 of 7 … column 8", the 2s struck, "Step 2 of 7" counted, asked again for
free, and on to the naked single, every new step counted once; a digit placed elsewhere moving
the cell on to a step not seen, which the bar and tint then never name for free; the tint kept
while the step is made; Show me's pages counted as each is first seen and free on return; and
step hints followed on the Expert dailies always name the step Show me starts with, on a route
that solves its own cell first, never strike an answer, and always solve.
Playback is held to every kind of move a log holds — the cell each outlines, its caption, its
mistakes and the scrubber's marks — to its timing at every speed, long pauses shortened and
bursts spread, to stepping, seeking and its ends, and to refusing, with the reason, a log from
an older or a newer build or a broken one; watching is checked to write nothing. A log with
step hints and Show me's pages in it plays back, each captioned with the step or page it
revealed.
Comparing solves is held to each player's progress (two moves in a second, an Undo, a Redo and a
Reset, and a second ending where the one before ended), the lookups at every second, the
overlaid board's every kind of cell, the marks (a slip forgiven left off), the ticks and where
the end labels go; to its moments (a second of pencilling, or of a number in and out again,
passed over; a hint's and a candidate mistake's kept; every step and mark of either line one, and
nothing changing between two), each player's part in one, stepping from between two, playing and
its waits; its chart to its keys, a press and a drag, a finger's tap, drag and swipe to
scroll, its readout, its table and its board's key; its steps, outlines and captions, each
moment's cells outlined before it is shown and a number drawn afresh as it changes, play at 1×
and 8× and through a press on the chart, what it speaks and when, and its keys, the chart's own
left to it; and its button to the solves it needs, both playing back, writing nothing. The
friends' solves kept to compare with are held to one per puzzle for each device (a newer link
taking its place, the same link again writing nothing, whatever order its fields came in), to
their cap, to costing nothing else when a write is refused, to being shed whole after finished
games' logs and before races' solves, to every other write of the records leaving them alone, and
to an export carrying them and an import only adding. The friends' times kept are held to the
same, but to their own cap of 1,000, to never keeping a solve, to taking the room there is
before the solves, and to being shed whole after races' solves and before saved boards, for a
history save and for any other write alike. The
startup plan passes on a link's checked solve only from a friend's ID, and its time from every
link that names a friend, solve or not, whatever it opens; the offer lists every friend's solve
there is; Compare's choice
of friend lists them one per friend — a merged friend once — the race's or the link's first, then
fastest first, never this browser's own; and in the dialog it renames everything the first was
named in, starts over at 0:00 with focus kept on it, keeps its own keys, and still offers itself
where the friend chosen has a solve that cannot be played back. The Solved dialog and the offer
offer Compare with no race, and once under a time-only race's verdict, focus back on it after.
Share links are held to the versions before them: a copy of the assists decoder every earlier
version shipped must read a new link's help unchanged, skipping its mistakes, and an old link
must read as mistakes not recorded, never 0; a link without reads as no ID, a garbled
costs only the ID, and changes no other parameter; the friends list's operations — noting,
renaming, merging, splitting, the cap — read back exactly what they wrote, a device merged
in and split off again is on its own as it was, and an import never undoes a merge, split or
rename. A friend's head to head is held to what it counts (): to
no result for a game not finished, none for a replay or a solve after watching one, and a win, a
loss or a dead heat in whole seconds — 5:23.9 a dead heat with 5:23 — whatever help either took;
to a puzzle raced against either of a friend's devices, with their solve or their
time alone, or played with no race once a solve or a time of theirs is kept, and every game of it
listed — a fresh solve, a replay, one unfinished and one after watching, only the fresh solve
scored against a time kept — but never a puzzle not played, another friend's, a race from before
player IDs or one, or a time kept, from this browser's own ID; to a race of theirs set against its
own time and every other game against their first — the solve they sent most recently, then a
race's, then the time they sent most recently, then a race's time alone, the newest game first —
a fresh solve against the race a replay of it copied among them; and to each level
a puzzle is shown with, and all of them. races a timed solve, reveals and all, but
never a replay's, a watched one or an unfinished game; leaves this browser's own
ID out of a friend's; and puts a friend's solves first, the rest as they were.
History's friend mode is held to its title, the name in its own direction or "your friend"; its
headline, the score read "7 to 4", and "No times to compare yet." until a game counts; the table
by level on All in place of the stats, Beyond Expert's row once there is a game of one, and a
tier's tiles;
each row's line — only the verdict under the friend's own race, their time first and their help
under it under any other, a replay and a watched solve not counted and why, no verdict for an
unfinished game and no line at all under a race of theirs still being played —
History's row actions but Delete, and none of its foot; what it says with no games, read as it
opens, and with none at a tier; counts over every game listed, not the page built; and coming back
as it stood, focus on the Watch of the row watched — and History without a friend as it was.
Friends opens on the first row's Head to head, and back from a head to head on the one that opened
it; the button opens a friend's by their key, and goes with the rest of its row's while a rename
or a merge is open there. Through the main hook, a head to head opens from Friends with History
read afresh and goes back to Friends, writing nothing; a row's Watch, its friend's solve, Compare
solves and Share each go back to it, and History's own carry no friend; Compare opens on the
friend's solve from their head to head, and on the race's from History; and Resume or Play again
from it — the game on screen, or one that cannot be reopened — closes it to the game, not to
Friends; and a time sent with no solve is kept for it, never to compare, and a solve's time
beside the solve. In the app, it lists one friend's games alone, under the name given them, and
goes back the way it came; counts a time sent with no solve to a puzzle solved already, its offer
turned down, on a line of its own; follows a merge in Friends, both devices' games together, and a
split, each with its own; and watches and compares from a row and comes back to it, Compare
opening on that friend's solve. A solve in a link is held to its puzzle and its time — another
puzzle's log, a time a second or more off, an older version's log, a broken one and a newer
version's (which alone is said) — and the spoiler rule to every way back to a watched puzzle: a
reload mid-game, Play
again, a new attempt from the generator, a game resumed from History, a daily, an import, and the
record pruned past 1,000 while the puzzle stays watched.
A puzzle beyond the techniques is held to its label wherever one shows: calls it Beyond
Expert (where the generator, should it ever fall back on one, still deals an Expert) and
turns the label back into Expert for whatever is dealt next; its record keeps the
flag only as on an Expert game, through a save, an export and an import — kept
when a newer copy has lost it — and through a tab from before the label, which keeps it as a newer
field, while its saved board says Expert; its stats have a row of their own, never among the
Experts', and History's Expert tab leaves it out; a link to one opens labelled Beyond Expert on
its Ready card, in the header, in what Share sends and in History, and a game of one begun from
History is announced as one, and its solve counted in its own stats, never a new best for beating
an Expert's time; and the header's breakpoints for the longer label are pinned in the stylesheet's
tests. A puzzle typed in is held to every line its editor says, in their order — a clash named
however few numbers there are, the first row, column or box to have one, with its number and how
many; too few; a full grid; more than one solution, none, and too complex on the search's own
budget — and graded as its link would be, by the same , Beyond Expert
included; the editor to every key (a number filling and moving on, on to the next row and no
further than the last cell, by its key's place with Shift or Alt held and on the numpad; a blank
from Space, 0 or a dot; Backspace on a number and on an empty cell; Delete; the arrows at the
edges, left and right running on round the rows; a number or a blank held down going in once;
every Ctrl and Cmd chord let through), the pad filling and moving on as a key does, focus going
on with the selection, and Erase erasing in place, its line keeping its words while the same
thing holds, and Play and Share… unavailable, saying why, until there is one solution; a
grid pasted in read however it is laid out, a dot or a 0 for a blank, and only at exactly 81 cells
— never a link — and put in place of the grid typed, the selection kept, from Ctrl or Cmd+V on the
grid and from Paste, which reads the clipboard within the press; and a paste with no grid in it,
or a clipboard refused or missing, said so on the line, afresh each time, until the grid changes;
through the main hook, Play puts a new puzzle behind Start as the player's own, unseen until its
board shows and its time counted as a new puzzle's, setting the game behind aside — dropped if
only glimpsed, a puzzle on its way cancelled — and resumes an unfinished attempt or offers a
solved puzzle again instead; and its record keeps only as , through a save, an
export, an import and a tab from before it. Your puzzles are held to the one rule every save keeps
— the entry being edited, else the one with the same numbers, else a new one at the head of the
list; its time last changed moving only with its numbers; an entry edited into another's numbers
taking its place and keeping a name; nothing written for a save that changes nothing; and past
1,000, the one changed longest ago forgotten, never the one saved, and a newer version's longer
list cut by one only — and to reading back (junk dropped an entry at a time, no two entries with
one id or one grid, the latest first kept first, never capped, a newer version's fields kept), and
to History's import (of a puzzle both have, the copy changed later, the one here on a tie, taking
the other's name if it has none; new ones only into the room left, the latest changed first, and
none past 1,000; one entry of a grid kept on both sides; the same file twice writing nothing;
merged though no game is new, and counted in what the import says, one at least for a merge that
only takes one away); its names to a newspaper's length, tidied as a player's are and short enough
for an older tab to carry; the list to its rows' words, their buttons by readiness, each named
with its row, a rename and a delete in place with focus back where it was, as it is on a row's
Share back from Share; the editor's Name to its place in the header, the title read but not
shown, to every key its own and none the grid's, to Enter going back to the selected cell but not
an IME's Enter, a pad pressed with the mouse leaving focus in it, and to its tidying as it loses
focus; the editor's Save to what it says when, a name changed alone counted in, and its Share… to
the puzzle Play would play, the cell selected kept to come back to, focus going back to Share…
rather than to the grid as the editor opens again; and, through the main hook, Save staying in
the editor; Play, Share… and closing keeping the grid with the name typed (a blank one never, nor
a name typed over one, an entry erased to nothing keeping its numbers and name), the name written
only when Name changed it — one left as it opened leaving another tab's rename, or the name of the
puzzle a grid typed afresh proves to be, as it was — and put back on an entry deleted in another
tab; closing going back to the row's Edit, Share… — the puzzle alone, making no player ID —
back to the editor on the same grid and cell, focus on Share…, and a row's Share, writing nothing,
back on its Share with the list as stored; with the game behind paused throughout. A puzzle's
name in a link () is held to its place — after the result and before the solve, with a time
or without — and to changing no other parameter, so a version from before it reads the rest as
ever; to its line in the message, second in every form, a message without one byte for byte as
it was; to being read tidied, a hostile one as one typed in would be, and as none when nothing is
left; to the game it opens, through a save, an export, an import, a newer copy that lost it and a
tab from before it, which keeps it as a newer field; to a game reopened keeping its own, the offer
to play again and History's Play again passing theirs on; and to the player's own name for the
same numbers winning on the Ready card, in History and in every way of sharing, the editor's
Share… taking the name as just kept, never written into a game.
The version stamp is held to change with any file a build ships, its contents or its name, public
files included, and with nothing else (); the look for a newer version to
its timing — as the page opens, as it comes back after ten minutes or more, every ten minutes or
so while on show, counted from the last look however it came and by the wall clock, never while
hidden — to the page's own address, past the HTTP cache, and to learning nothing from a page it
could not have (a 404, a server error, a page with no stamp, no network); and its offer to
waiting for a dialog to close, being spoken once, leaving focus on the board, and staying away
once dismissed until a newer version still.
Coverage thresholds are enforced in CI, with the engine held to 100%.
Pushing to main runs CI (format, lint, type-check, tests, build, e2e);
once that passes, the same run publishes the commit to GitHub Pages.
To set it up on a new repository, enable Pages once: in Settings → Pages, set Source to GitHub Actions.
The Pages build sets Vite's base to /<repository-name>/ automatically. If you rename the repo,
no change is needed — the base is derived from the repository name at build time.
There is no service worker, so a page keeps running the code it loaded until it is reloaded: a
game left open — an installed one above all, which never navigates — would never see a deploy.
So each build stamps its page with its version, <meta name="version">: a hash of the finished
bundle's files, names and contents, and of the public files, made by
plugins/version.ts once the bundle is complete. And the page fetches its
own address now and then and compares the stamp there with its own
(src/update/check.ts): as it opens, as it comes back into view, and every
ten minutes or so while it is on screen — never within ten minutes of its last look, by the wall
clock (which runs on while a phone sleeps), and never while hidden. A different stamp is a
new version, which the page offers at the foot of the screen with Refresh (see
UpdateToast). A page with no stamp — the dev server's, or a test's —
never looks.
Builds are deterministic, so a merge that changes only docs or tests deploys the same files under
the same stamp, and offers nothing. GitHub Pages lets a browser keep any file for ten minutes
(Cache-Control: max-age=600), which is why the page looks at the page itself rather than at a
file of its own: the look revalidates the browser's copy of the page, so the app opened again
within those ten minutes — from a home screen, say — gets the new version too, and a page opened
from that cache before the look finds out as it opens. A reload asks the server for the page
whatever the cache holds, so Refresh loads the new version either way. Should Pages' own cache
serve the old page for a while after a deploy, the page learns of the new one at a later look.
| Action | Keyboard | Mouse / Touch |
|---|---|---|
| Select a cell | Tab to the board | Click or tap it |
| Move the selection | Arrow keys | Tap another cell |
| Enter a number | 1–9 | Number pad |
| Erase | Backspace / Delete | Erase button |
| Switch Normal / Candidate | Space | Mode buttons |
| Switch while held | Hold Shift or Alt | — |
| Toggle one candidate | — | Click its spot in the selected cell |
| Undo | Ctrl/⌘+Z | Undo button |
| Redo | Ctrl/⌘+Shift+Z, Ctrl+Y | Redo button |
| Pause or resume | P | The timer |
| Hint, check, reveal, reset | — | The "…" menu |
| Show me how to solve a cell | — | Show me, after a hint |
| New game, or today's puzzles | — | The + button |
| Daily calendar and streaks | Arrow keys, Home/End, Page Up/Page Down in the month | The calendar in the header, or + |
| Enter a puzzle | 1–9 fill and move on, Space or 0 leave a blank, Backspace goes back | New game › Your puzzles… › Enter a new puzzle, then tap a cell and a number |
| Paste a puzzle in | Ctrl/⌘+V on the grid | Paste, in Enter a puzzle |
| History, share, settings, help | — | The header (on a phone, the ☰ menu) |
| Friends | — | Friends, at the foot of History |
| A friend's head to head | — | Head to head, on their row in Friends |
| Solving techniques | — | The header, or a hint's question |
| Watch your solve | Space, ←/→, Home/End | Watch your solve, or History's Watch |
| Compare solves | Space, ←/→, Page Up/Page Down, Home/End | Play and the steps, or tap or drag along the chart |
MOVES_VERSIONGOLDEN_SITUATIONSsrc/test/goldenMoveLogs.tsnpm run moves:goldensrc/core/playback.ts prepares it: it replays the log through the reducer once,
keeping the game after every move, works out each move's cell, what a candidate move did to its
digit (pencilled or struck, and any number it cleared — read from the game before and after
it, as analyseMistakes judges a struck answer), which page of Show me a page move showed (the
log keeps only its step's id: the route is walked again from the board before it, held to the
solution and numbered on from the cell's last step hint, as Show me did), and its mistakes
(analyseMistakes), and
places the scrubber's marks; the
timing and the cursor (steer: play, pause, step, seek, speed, and the cue before each move)
are pure functions of it, and usePlayback only waits out each pause, in its two parts, on the
browser's timers. The captions are the UI's
(src/ui/playbackText.ts), as the engine speaks no English.isPlayable, asked only of the History rows
built, and remembered) — play logs nothing after a solve, so a log that goes on past one is
taken as damaged. A game not recorded move by move — begun before logs were kept, or one whose log
stopped (see The move log) — offers nothing; a log recorded under an older
rules version is swept as a visit starts, so History says nothing of it either. Given a log it
cannot play back, the player says why in the board's place: recorded by an older version, by a
newer one (reload to update), damaged, or stopping short of the solve — readMoveLogHeader
tells them apart.The chart: the cells each of you had solved — the puzzle's empty cells holding their answer — over play time, from 0:00 to the end of the longer solve, a step line each: yours solid, your friend's dashed. The mistakes that counted (of both kinds) are triangles on the line, and each move of help — a hint, Show me or a page of it, a check or a reveal — a diamond; yours filled, theirs open, each ringed in the dialog's own colour where it crosses a line, and theirs drawn over yours as an outline, so a spot you share (both at 0 cells at the start, a mistake each in the same second) shows your fill inside their ring. Auto candidates and Checked as entered, switches rather than moves, are not marked: the head-to-head has them. Each line is labelled where it ends with whose it is and the time ("You 5:23", "Dan 6:40"), above the chart; where the faster line's label would run into the other's, it sits beside its own line's end dot instead, inside the chart — never stacked, and never over the other line's end. A legend says the same. The axes are hairlines with a few round times (0:30, 1:00, 5:00…) and cell counts, the full count at the top.
The cursor: a vertical line at the moment on show, with the time beside the speeds — and the counts too, where the panel has room ("2:15 — You 34 cells, Dan 28 cells", cut short rather than wrap under a long name, so the controls hold still); on a phone the time alone, the cursor's dots on the lines showing the counts. The chart is a control too: press or tap to put the cursor there (playing on, if it was), drag along it to scrub (a finger moving up or down scrolls the dialog and leaves the cursor where it was), or, with it focused (it is a slider, which a screen reader hears by the same words), ←/→ a second, Page Up/Page Down ten, Home and End to either end. Between two moments the board, the caption and the outline stay on the earlier one — what the board last changed at — and a step back goes to the moment before that.
The table: Show as table, last, after the board's key and the chart's legend (so it never comes between the board, the chart and the controls that drive them), lists the cells each of you had solved at round times to the end, and every mistake that counted and move of help, when and whose.
Names: one longer than 12 characters is cut short ("Bartholomew…") where it shares a line with others — the chart's labels, its legend and readout, the caption, and the board's key; the heading, the times, the tables and every name a screen reader hears keep it whole.
On a phone the board comes first, and everything that drives it is in view under it as it opens — the caption, the chart (lower, 96–160px by the screen's height), the time and the speeds, and the transport, lowest, in reach of a thumb — the board sized by what they leave (some 250px on a 375×667 phone, 170px on a 320×568 one); the board's key, the chart's legend and the table are scrolled to. On a desktop the same, the chart up to 196px and the board up to 360px, all in view from some 600px of height (on a phone, some 530px). On a phone on its side the board sits beside the rest, all in view: the card takes the screen's height, and the board and the chart are sized from what its body has.
The board: the game's own, read-only (as a playback's), at the moment on show. Givens as ever; a number you both had in a quiet ink, upright; one only you had in your ink, upright, over a solid rule; one only your friend had in theirs, slanted, over a dashed rule; and where you had different numbers (one of you wrong then), both, small — yours top left, theirs bottom right. So it reads without colour, in High contrast and in Windows High Contrast, and at 320px wide. A key under it says which is which, and each cell's name says it in words ("7, yours", "yours 3, Dan's 7").
Time is counted in whole seconds, the unit times are shown in: at 5:23 the board shows every move made by then, so a solve the player was told took 5:23 is finished there.
Colour: two series colours of the theme's own (--series-you, an ultramarine, and
--series-friend, a vermilion, stepped for each palette) for the chart's lines and marks only —
chosen with the data-visualisation palette checks: inside the chart's lightness band, a chroma
that reads as a colour, apart in normal vision and under simulated protanopia and deuteranopia,
and 3:1 against the dialog — and two inks of the same hues (--ink-you, --ink-friend) for the
board's numbers at 7:1. Text is never in a series colour. src/styles/contrast.test.ts holds all
of it in every palette.
What it is worked out from: the two playbacks (src/core/playback.ts) and nothing else, by
src/core/compare.ts: each player's progress, kept a point a second where it changed (an Undo,
an Erase or a Reset can bring it down), the cells solved at any second (a binary search), the
overlaid board at any second (each cell given, empty, both, yours, theirs or different), the
marks from the mistakes analyseMistakes counted and the moves of help, the chart's ticks,
scales and where its end labels go (above the chart, or beside the line's end), the moments a
step or play stops at and each player's part in one (the cells its moves acted on, and the moves
that showed), and the cursor (steerComparison: play, pause, step, seek, speed, and the cue
before each moment) and its waits, timed by the playback's own cueOf — pure
functions, as a playback's are; useComparePlayback only waits out each wait on the browser's
timers, as usePlayback does, and the two players share their controls and keys. The chart draws
what stays put once for the solves and its size, so moving the cursor redraws only the cursor.
The words are the UI's (src/ui/compareText.ts).
sudoku.historysudoku.friendSolvessudoku.friendTimessudoku.friendsu=u=u=src/ui/versus.tsversusOutcomeversusGamestallyVersusisRaceablefriendDevicesfriendFirstratedealtTierbeyondtruepuzzleFromGivensenteredtruel=plugins/version.test.tsEnd-to-end (e2e/, Playwright): full journeys against the built app — playing and solving,
pausing and reloading, share links between two browsers (a solve with a mistake shared, opened in
the other and compared row by row, a link to a puzzle the techniques cannot finish named Beyond
Expert on its card, in the header, in its share text and in History, and a puzzle named in Your
puzzles shared from its row, its name on the other browser's Ready card and in its History, and in
that browser's own link to it), the history, the technique guide, a hint's Show me walkthrough
(and a step hint followed a step at a time, its pages turned with help taken ticking only for
pages not seen before), the daily puzzles on a fixed clock (today's Hard from New game to the end
and its streak, yesterday's from the calendar kept but not counted, a friend's daily link
recognised), mistakes (an obvious slip put right at once, a wrong number where the answer was not
obvious, Check guesses marking and counting one at once with "guesses checked as entered" beside
the time, and the error counter waiting out a slip's 3 seconds of play), sharing a solve between
browsers (one plays first and then watches it for nothing, another watches first and solves it
with no time, the sharer's own link, followed in their own browser, racing no one, one who has
solved it watches without being asked, and a daily's, watched first, kept out of the streak),
comparing two solves between browsers (opened from the Solved dialog and from History at the
start on Play, stepped by key and by button, played on Playwright's clock at 1× and 8× (the cells to fill outlined, still empty, before they fill), the
cursor moved by the chart's keys and by a press, the caption, the readout and the board
following it, focus back on Compare solves, and fitting phones from 320×568 to 390×844 and on
their side, opened at the top with the board, the caption, the chart, the speeds and the
transport all in view, the transport holding still as it steps — and, with two friends' solves to
choose from, the header no taller and the board no smaller), two friends' solves sent to someone
who had solved the puzzle first, each offer closed, then compared from History with either,
chosen in the header and started over (fastest first, as the game raced no one), and the slower
friend's solve compared from their head to head, opening on theirs, focus back on the row's
Compare solves as it closes, friends (a sharer named in the list, then at the prompt, kept
through a reload and a later link calling itself something else — every race, the head-to-head
and History following — the prompt's field typed over and Enter, then Enter again starting the
race, a real sharer's two links carrying the one ID the prompt fills its name from, two devices
taken for one friend at the prompt, split off and merged again in Friends — every race in History
following, through a reload — and a name given carried by History's export to another browser,
naming the race it imports and asking nothing of the sender's next link there; and a merged
friend's head to head, from their row in Friends: the games of the puzzles either device sent a
time for, and neither Dan's nor one raced against no one, all square, the table by level, each
row's race and its line — a replay not counted, a loss by 1:20 to their time with its help and
mistake, a win by 0:42 — no Delete and no foot, their solve watched and focus back on its Watch,
and back to Friends on their Head to head as it closes; split, each device has its own; a time
sent with no solve, to a puzzle solved already, counted once its offer is turned down, through a
reload, with nothing of theirs to watch or compare; and at 320×568 Friends and a head to head
with nothing to scroll sideways, every row's buttons inside it, a long name wrapping inside the
title),
a puzzle typed into Enter a puzzle (a Hard played from its Start card as the Hard it is, shared
from there as the puzzle alone and opened in another browser on the same board; shared from the
editor's Share… as the puzzle alone, kept, back in the editor on the same grid with Share…
focused, nothing started — the game behind carrying on as the list closes — and its link opening
the same board in another browser; one named in Name as it is typed in, the name on its link, back
in Name from Share and on its row, nothing to scroll at a desktop's 1280×720 for the header Name
makes taller; a Hard pasted in on the grid, as Ctrl or Cmd+V would, over what was typed and played
as the Hard it is, and from Paste with focus kept on the grid, words and a clipboard refused said
so on the line; and a clash and more than one solution named, with Play and
Share… unavailable), Your puzzles (a half-typed puzzle saved and listed with its line, then
edited, finished and played from the editor; one renamed, kept through a reload; one deleted; one
shared from its row, focus back on its Share; a grid kept as the editor closes, back on its row's
Edit; two Escapes from the editor back to the game; and the list carried by History's export to
another browser, listed there, names and all, and played),
dialogs giving way to one another (dialogs.spec.ts, read frame by frame at a desktop's size and
a phone's: the first coming in on a scrim that fades in, its card rising or its sheet sliding up;
History giving way to Friends and back on the one scrim, nothing fading or moving; and a press on
the scrim beside the card closing it),
watching a solve with a mistake played back from the Solved dialog and from History (stepped,
scrubbed and played at 8× on Playwright's clock, its mistake marked and captioned, focus back on
Watch as it closes, and fitting a 320px phone and a phone on its side) and the phone layout from
320px wide up and on its side (the calendar's
days measured at 44px at 320px, the head-to-head's every row, hour-long times on one line and a
long right-to-left name kept inside its card, New game and the calendar whole on a phone on its
side, a Beyond Expert puzzle's header whole at 1:23:45, its label shortened to "Exp+" at 320px and
in full at 375px, and the error counter's line with help taken beside it, upright and on its side,
a large phone's header folding its buttons to fit both at 1:23:45 — a daily Medium's wordmark and
tier never clipped, measured to the sub-pixel — and help taken giving way to its counts and then
its total, never cut, beside a counter with a candidate mistake; with neither shown, the board as
large as with the setting off; help taken alone above a 375×667 phone's controls, and on a
320×568 phone beside a hint that keeps to two lines; Friends and Who sent this? upright and on
their sides, a long right-to-left name kept inside Friends' rows, a merged friend's devices and
the prompt's hint, every control 44px — the friend pickers and a merge's buttons among them — and
History's foot giving Friends a row of its own at 320px; History's games per day at 320×568 and
568×320, inside the sheet and in one view of its body with every level keyed and the unfinished
included or not, its key's levels and day labels apart, its table toggle and Include unfinished
44px, and nothing to scroll sideways whether the table is open or shut, each day's name in the
table on one line; Enter a puzzle at 320×568, 568×320,
667×375 and 844×390 with nothing to scroll, every button 44px, Play, Share…, Save and Paste on one
line with the hint in view under them, Name level with the close button and as tall, its text
16px, and every cell at least 24px — 22px on the shortest phone on its side — and a tapped number
landing in the cell tapped, focus back from Name, and moving on to the next, which the next tapped
number fills; Your puzzles at 320×568, 568×320 and 844×390 with nothing to scroll sideways, a long
right-to-left name inside its row, every button
44px (a ready row's Share among them) and the rename field's text 16px; a link's 40-character
puzzle name kept inside its Ready card and Share's preview at 320px; a friend's time with a
40-character name whole on the Ready card at 568×320 and 640×360, and at 640×360 their solve to
watch with a name too, on the Ready and Paused cards; two solves compared by touch, stepped,
played at 8× and tapped on the chart, every button of its transport on screen and 44px; the
offer of a new version lying over the
switch row alone at 320×568 and on its side, its two buttons 44px and every other control still
there to press; and a desktop's header with a daily Medium and help at its widest).
Help taken stays on show through a pause, its count the only one the hidden board leaves beside
the timer's. A new version (update.spec.ts) is deployed to a stand-in for GitHub Pages
(e2e/pages-server.ts: the app under /sudoku/, real 404s, and ten minutes of HTTP caching on
everything): a page left open offers it ten minutes on, and Refresh loads it with the game kept,
paused; and a page the HTTP cache kept from before the deploy offers it as it opens (on Chromium:
Playwright's WebKit keeps no HTTP cache on disk, and the first look displaces the copy it keeps in
memory, so it is never handed an old page). An accessibility pass (a11y.spec.ts) runs axe-core's
WCAG 2.2 A and AA rules over the main states — the board with every kind of mark, the error
counter and help taken in both their places, the Ready and Paused cards (the Ready card with a
puzzle's name too), the menus (the "…" menu's "Hint (1 used)" among them), every dialog (Show
me's, the daily calendar's, a solve played back, the warning before watching a friend's solve, two
solves compared, Share with the solve included, History with Friends at its foot, Friends with a
merged friend's devices, a name being changed and two friends being merged, a friend's head to
head by level with a row of each kind it counts or leaves out, at one level and with no games yet (a bottom sheet
on the phones), two solves compared upright and on its side on the phones, Who sent this? with
friends to choose from, Enter a puzzle at each step on the way to a puzzle — empty, a paste with
no puzzle in it, a clash, too few numbers, more than one solution, a Hard, a right-to-left name
being typed and a grid saved — and Your puzzles empty, with rows, a name being changed and a
delete being confirmed, upright and on their sides on the phones, among them), each guide entry, a
step hint's tinted houses and the offer of a new version (a sheet on a phone held upright, a card
on its side) — in both themes, and the board (its tint included), the dialogs (Enter a puzzle and
Your puzzles among them), the menus, the calendar and the offer of a new version in High contrast,
both chosen in Settings on a dark device and from a dark system asking for more contrast,
measuring its heavier lines and its same-number ring kept off the digits — and allows no
violations. Three projects:
| Project | Device | Engine | Specs |
|---|---|---|---|
chromium | Desktop Chrome | Chromium | everything except the touch suite |
iphone | iPhone 15 | WebKit | touch.spec.ts, the phone pass of a11y.spec.ts, and update.spec.ts |
android | Pixel 7 | Chromium | touch.spec.ts, and the phone pass of a11y.spec.ts |
npm run test:e2e serves its build on a port of its own for each checkout, picked from the
checkout's path, so separate clones and git worktrees can run the suite at the same time, and a
npm run preview left running on 4173 is never in the way. It never tests a server it finds on
that port, which may be serving another build or stop halfway: one there fails the run before it
starts. So let a run finish before starting another in the same checkout — both would build into
its dist/ anyway. Set PLAYWRIGHT_PORT to choose the port, should two checkouts ever land on
the same one.