An in-a-row strategy game. Nineteen modes across fourteen rule sets — classic Tic Tac Toe, larger boards, Gomoku and Gomoku Pro, Misère, Gravity, Connect Four, Vanish, Ultimate, Wild, Order & Chaos, Obstacles, Powers, Pente, Qubic and Quantum — against a six-level AI with six rival personalities, a journey, an arcade of daily challenges, gauntlets and roguelite runs, puzzles and lessons, a friend on the same device (or a 2v2 table), or a stranger on a seasonal rated ladder.
One codebase runs everywhere: as a web app, as an installable PWA, as a desktop app on Windows, macOS and Linux, and as a native app on iOS and Android.
| Area | What there is |
|---|---|
| Modes | Classic 3×3 · Grid 6 · Grid 9 · Gomoku 13/15 · Gomoku Pro (exact five, swap) · Misère · Gravity · Connect Four · Vanish · Ultimate · Wild · Wild 4 · Order & Chaos · Obstacles · Powers · Pente · Qubic 4×4×4 · Quantum |
| Play | Pass-and-play series, solo or 2v2 teams · vs AI (6 levels, blitz clocks, handicap starts) · practice with hints and engine suggestions · ranked · casual · private rooms · games by mail |
| Solo | A journey of six rivals with their own play styles, 42 stages and boss fights · hundreds of proven win-in-N puzzles, a daily puzzle and Puzzle Rush · 18 interactive lessons · the Arcade: daily challenge, gauntlet, roguelite runs |
| Progress | XP and levels, 32 solo achievements, an earned collection of piece sets, win effects, frames, titles and victory lines, all synced to the cloud once you have an account |
| Look and feel | Six art styles (Pastel, Tabletop, Neon Arcade, Paper Craft, Ink & Zen, Chalkboard) in light and dark · particle effects, slow-motion wins and tactical callouts · adaptive generative music · rival worlds · Low/Balanced/High graphics |
| Competitive | Glicko-2 per mode, seasons with tiers and divisions, arenas and brackets, bot backfill, live games to watch, fair-play analysis |
| Social | Friends, presence and direct challenges, games by mail, web push notifications |
| Languages | English, Español, Français, Deutsch and فارسی (right-to-left) |
| After the game | Match history, deterministic replays you can step through, achievements, per-mode statistics |
The profile name and avatar, preferences, solo progress, local series score, and unfinished local/bot games are stored on the device. They work without a server account. Profile edits for an existing online account are queued and synced when the connection returns. A server account is created when online play or password setup needs one.
Once a device has an account, its solo progress is also kept in the cloud: the
whole progress blob is uploaded and merged with what other devices uploaded,
by rules that only ever keep the better value (the larger count, the union of
solved puzzles, the later streak), so signing in on a second device merges both
and nothing is lost. Change
8000
s made offline are queued and sent when the
connection returns (features/cloud).
Previously downloaded online history, replays, and leaderboard snapshots are cached locally. Current rankings, multiplayer, password setup, sign-in, and reports require the server. Online ratings and match results remain authoritative on the server.
The web app must be loaded online once so its service worker can download the
game bundle; native builds include that bundle. To verify the production PWA,
run npm run build:web, start npm run preview -- --port 4173,
then run npm run audit:offline from the repository root.
| Layer | Choice | Why this one |
|---|---|---|
| Language | TypeScript 7 | The Go-native compiler. Checks the frontend, shared game code, server, and build scripts together. |
| UI | React 19 | The design is a handful of screens with local state; React's ecosystem is the one everything else here targets. |
| Build | Vite 8 (Rolldown) | Rust bundler, sub-second production builds, first-class worker and PWA support. |
| Routing | TanStack Router | Type-safe routes and type-safe search params, which is where the mode, the difficulty and the room code live. |
| Styling | Tailwind CSS v4 | CSS-first @theme config, so the palette lives in one file as real CSS variables. Zero runtime. |
| Shell | Tauri 2 | The one toolchain that covers desktop and mobile from a web frontend. Binaries are a few MB rather than a bundled browser. |
| State | Zustand | Only the connection and the account need cross-screen state; everything else is component state. |
| Server | Hono + ws on Node |
Small, standards-based, and deployable anywhere that runs Node. No vendor lock. |
| Storage | SQLite (better-sqlite3) |
Accounts, ratings, matches and replays outlive a restart. Synchronous API, real transactions, one file to back up. |
| Validation | Zod 4 | The server treats clients as hostile, so every inbound frame is parsed rather than cast. |
| Test | Vitest 4 + Testing Library | Same config format as Vite. The engine and the server run headless; the client runs against jsdom, because its reconnect and keyboard behaviour is not observable without a DOM. |
| Lint | oxlint (+ tsgolint) | Rust linter with type-aware rules built on TypeScript 7. typescript-eslint does not yet support TS 7. |
The deliberate trade-off is web technology everywhere rather than native UI per platform. The design leans on gradients, backdrop blur and SVG, all of which port unchanged this way; a React Native build would have to re-author every screen and would still need a wrapper for desktop.
Run every npm command from the repository root. This is one Vite app with
one package.json, one TypeScript config, and a sibling src-tauri/ directory,
following Tauri's Vite integration.
dooz/
├── package.json dependencies and commands for every target
├── index.html Vite entry point
├── vite.config.ts web/PWA and Tauri frontend build
├── src/
│ ├── main.tsx React entry point
│ ├── router.tsx routes and validated search parameters
│ ├── game/ game code in one place
│ │ ├── engine/ pure rules, variants, replay model, and AI search
│ │ ├── bot/ worker and React hooks for the AI opponent
│ │ ├── components/ board, players, controls, and result reveal
│ │ └── useGame.ts local game state; feedback and hints live alongside it
│ ├── screens/ route screens and their composition
│ ├── features/ accounts, online play, journey, puzzles, and progress
│ ├── components/ shared UI and artwork
│ ├── lib/ preferences, audio, API, and UI utilities
│ ├── protocol/ validated wire format shared with the server
│ └── styles/ theme, animations, and fonts
├── src-tauri/ Rust shell, capabilities, native icons, and mobile projects
├── server/ authoritative multiplayer server and SQLite storage
├── public/ static browser/PWA assets
├── assets/brand/ editable artwork sources
└── scripts/ browser audits, icon generation, and engine benchmarks
Start with src/router.tsx to find a screen, src/game/useGame.ts for local
play, and src/game/engine/index.ts for the shared game API. Imports beginning
with @/ resolve to src/ in Vite, tests, and the server's tsx runner.
The server imports the same pure rules and protocol directly; separate npm
packages and generated TypeScript declarations are no longer needed. The online
client sends a cell index and renders the authoritative board returned by the
server.
A game is a GameConfig — a variant, a board size and a run length — plus the
moves played. Everything else is derived.
const game = createGame({ variant: 'gomoku', size: 15, winLength: 5 });
const next = applyMove(game, 112); // null if the move is illegal
replay(game.config, X, next!.moves); // the same position, rebuiltVariants live behind one interface (create, canPlay, legalMoves, apply)
and are registered in a single map, so adding one touches two files and nothing
else: the server, the client and the AI only ever see GameState. Four of the
six share an implementation — classic, gomoku, misere and gravity differ
only in the board they use, in which empty squares are open (gravity allows the
lowest free square of each column), and in who a completed line belongs to.
vanish keeps each player's three newest marks and lifts the oldest when a
fourth is placed; it needs no extra state, because the marks on the board are
always the last three each player played, so the move list says which leaves
next. ultimate is the one with state beyond the board, and it carries it in a
field the others leave null.
A square can also be blocked — nobody's, never empty, and the end of every
line through it. Connect Four is gravity on a 7×7 board whose top row is
blocked (the board draws it as 7×6), and Obstacles lays a point-symmetric
handful of rocks out from config.seed, which every new game draws fresh
(withSeed) and every replay reads back. Moves are integers, but not always
squares: Wild and Order & Chaos let the mover place either mark, coded
cell + cells * mark; Powers codes its one-time bomb and wall above the cell
range the same way and keeps the charges left in GameState.extra; and an
opening rule — pro or the pie-rule swap — wraps any placement variant
without it knowing. Gomoku Pro (exact five, swap) carries the gomoku ladder;
the solved boards — 3×3 classic, misère and vanish — are casual only.
Three more rule sets bring state or geometry of their own. Pente (13×13,
five in a row) captures: a placed mark that flanks exactly two opposing marks in
a line takes them off the board, only the mover captures, and five captured
pairs also win; the counts ride in GameState.extra and the board sees the
taken pair off. Qubic is four in a row in a 4×4×4 cube, stored as an 8×8
board whose quadrants are the four layers, with its own table of 76 lines.
Quantum tic-tac-toe follows Goff: each move is a spooky mark in two squares,
and a move that closes a cycle of entanglement obliges the other player to
measure it. To keep turns strictly alternating, that choice is the first half
of the measurer's own move code (91 × (1 + choice) + follow-up); the
follow-up is a spooky pair, the forced ninth mark, or nothing when the collapse
ended the game. When one collapse completes lines for both players, the line
whose newest mark has the lower subscript wins — Goff's full point — and since
subscripts are unique that tie-break is always decisive. Per-variant extra
crosses the wire in the snapshot, validated per kind.
Because nothing in the rules reads a clock or a random number, a match is stored as its config, its opener and its move list. That is what makes replays exact rather than approximate: the client re-runs the same engine over the same moves.
Six levels that differ in three independent ways, not in how much noise is added to one engine:
- How far ahead they look. Beginner does not search; master searches until its clock runs out.
- Which tactics they may use. Every level takes a win it can see and blocks a loss it can see — missing those is not "easy", it is broken. Fork-finding unlocks at medium, which is where a human stops being able to win with a simple trap.
- How close to the best move they insist on playing. Weaker levels pick uniformly among the root moves within a score window, so they play a defensible move that is simply not the strongest. A forced win is never subject to it.
On top of a level sits an optional style (bot/style.ts): which of the
moves the level allows it prefers. Weights on building and on blocking, a pull
to the centre or the rim, an appetite for moves that bring two lines to the
brink at once, an opening book per mode, a copycat's mirror. A level with slack
lets its style choose inside the window instead of a coin; a level without
slack only between moves its search scored exactly alike, plus a defensive
leaning damped into the evaluation itself. Wins and blocks are taken before a
style is asked, so the journey's rivals sit on the same rungs as the bare
levels — BENCH_RIVALS=1 npm run bench runs the ladder with their styles.
Under that sits an iterative-deepening alpha-beta search with a transposition table (Zobrist-hashed, with a second hash verified on lookup), killer moves, threat-based move ordering, and a quiescence extension that follows forced replies only. Positions are scored by their win-windows, with open and closed threats valued separately — the difference between a three that must be answered now and one that is already half dead.
3×3 is solved outright from hard upwards. On the larger boards the search is bounded by wall clock, always has a complete answer in hand when time runs out, and can be cancelled mid-search when the position it was thinking about is no longer on screen. Under gravity the same search runs with one candidate per column instead of the neighbourhood rule, which is both narrower and exact, so the strong levels afford two extra plies.
Vanish is solved completely. Its positions are two short queues of squares — 73,450 of them reachable — and once both players have three marks down every position has exactly three moves, so a retrograde analysis labels every one of them won, lost or drawn, with the exact distance to the end, in about 200ms the first time the bot is asked. (The result: a first-player win in thirteen plies, but only from an edge opening; the corners and the centre let the second player hold.) Levels are weakened by how far down that distance they may see, the same principle as the search's depth limit. Because draws exist and neither side is forced to end one, a game with no line is drawn after fifty plies.
The mark-choice games get a search of their own (bot/marks.ts): a window
there matters by whether its marks agree, not whose they are, and every square
is two moves. Wild's 3×3 board is searched to the end — a first-player win —
and Order & Chaos scores Order's still-live windows, with Chaos's forced spoils
found exactly. Blocked squares are dead ground to the line evaluation; under
the exact rule a window flanked by its owner's mark is worth nothing, because
it can only make an overline. Under the swap rule the bot takes an opening mark
near the middle and offers one just outside that zone, and in Powers it spends
the bomb only to stop a double threat or to break through into one.
Pente, Qubic and Quantum each have a search of their own, sharing only how a
difficulty becomes a move (bot/rule-searches.ts). Pente's board undoes
captures as well as placements and scores captures, capture threats and
exposed pairs alongside its five-windows; when the opponent can win next move,
the root narrows to the moves that stop it — capturing out of the line counts.
Qubic keeps a running count per line, wins on any open three, answers a single
threat without spending a ply, and calls two threats lost. Quantum searches
shallowly through the rules engine itself, and every level refuses a move that
lets the opponent win at once. Practice hints cover Pente and Qubic exactly;
Quantum has none, since a quantum move is a pair of squares and often a
collapse, and no single square wins on its own.
npm run bench # the full strength ladder
npm run bench:speed- The journey. Six rivals — Pip, Mo, Sage, Bruno, Vex and Nyx, one per bot level — each with a chapter of seven stages across the modes, ending in a boss stage with a twist. Each plays in a style of their own and has a signature habit to learn and punish: Pip copies your moves, Mo blocks lines before they are threats, Sage holds the centre, Bruno rushes his longest line, Vex sets quiet traps, Nyx announces a forced win when it sees one. Some stages let the rival open, start with marks on the board, or ask for something besides a fast win for the third star — a double threat, no hints, never facing a threat. Every stage is winnable; none of them puts the player against a level that plays 3×3 perfectly. The rivals talk, from pools of thirty-odd lines each, dealt so none repeats until the pool is spent: greetings per mode, their first threat, a block, a fork, a comeback, the end coming — all read off the same exact threat check the hints use. Stage ids are what stars are saved under, so the original eighteen kept theirs.
- Puzzles. A pack of 580 win-in-N positions from real bot games, each one proved
by the generator in
scripts/bench/puzzles.tsagainst every defence, with a unique solution and the most stubborn defence recorded — so the client plays it back without searching. One of them is the daily puzzle, the same for everyone on the same day, with a streak for solving on consecutive days and a spoiler-free result to share; each pass through the pack turns every puzzle by another symmetry of the board, so a daily never comes round the same way twice. A wrong answer is not just refused: the defence that beats it is played and named before it is lifted. The pack's index ships with the app and its positions load on demand. - Puzzle Rush. Three minutes, five strikes, one try per puzzle, with the puzzles climbing in rating as the score does. The best run is kept.
- Lessons. Short interactive lessons per mode — open and closed threes, forks, both ends, the four-three, sending in Ultimate, gravity's floors, misère's mirror, vanish timing — each step a position where the move to find is checked by the engine in the test suite.
- Progress. Records against each bot level, streaks, stars and solved
puzzles are kept on the device (
features/progress). They unlock three piece sets — Neon, Chalk and Candy — alongside the original Classic set. Nothing here is trusted by the server; it is cosmetics and a profile card.
npm run puzzles # regenerate the 580-puzzle pack and its index: ~35 minutes, byte-identical every runEvery sound is synthesised with the Web Audio API at the moment it plays
(src/lib/sound.ts) — no audio files,
for the same reasons the artwork is inline SVG: nothing to download, works
offline, and it can follow the game. A mark's pitch comes from where it lands,
higher up the board and further right being higher up a D major pentatonic
scale, so a game played out is also a short tune; X is plucked and O is a round
bloop, so the two players are told apart by ear. Gravity marks whistle as they
fall, vanish marks leave with a breath of air, and a win climbs to a chord.
The whole palette stays in one pentatonic key, so any two sounds — two marks,
a win and the music — sit together. The optional ambient score
(lib/music.ts) is generated the same way and
never repeats. It is off by default, starts only after a gesture (browsers
insist), and pauses in the background. Effects, music, volume and vibration are
separate settings.
npm install
npm run dev # client on http://localhost:3000
npm run dev:server # multiplayer server on http://localhost:8787
npm run dev:all # both at onceOnline play needs both. In development the client finds the server on its own;
for anything else see .env.example
and server/.env.example.
The server creates its SQLite database on first run (./data/dooz.sqlite by
default). It is the only thing that needs to persist between deploys.
docker build -t dooz-server .
docker run -d -p 8787:8787 -v dooz-data:/data --env-file server/.env dooz-serverThe image runs the TypeScript server with tsx, keeps the database and its
backups in the /data volume, and has a health check on /health, which also
reports uptime, memory, database size, push, notice, telemetry and backup
counters. Serve the web build (npm run build:web) from any static host or the
same reverse proxy, and set ALLOWED_ORIGINS and, behind a proxy,
TRUST_PROXY=1.
- Backups. An online SQLite backup (consistent while the server runs) is
taken every
BACKUP_INTERVAL_HOURSintoBACKUP_DIR, keeping the newestBACKUP_KEEP;POST /admin/backupstakes one on demand. To restore, stop the server and copy a backup overDATA_FILE. - Deploys do not drop games. On
SIGTERMthe server writes every game in progress (rules, moves, clocks, seats) to the database before closing the sockets, and the next process seats everyone back in it; clients reconnect through the ordinary resume flow and the downtime is charged to nobody's clock. Games by mail live in the database anyway.
These need a Rust toolchain; everything else does not.
npm run dev:desktop # Windows, macOS or Linux window
npm run build:desktop # installers in src-tauri/target/release/bundle
npm run init:android # scaffolds src-tauri/gen/android
npm run dev:android # needs Android Studio + NDK
npm run init:ios # scaffolds src-tauri/gen/apple
npm run dev:ios # macOS + Xcode onlygen/ is generated, not committed, and tauri android init scaffolds it with
Tauri's own launcher icons. The init:* scripts therefore run tauri icon
straight afterwards, which overwrites them with the dooz icon. If a device ever
shows the Tauri logo, the project was scaffolded some other way — npm run icons fixes it without a re-init.
The mobile and desktop builds must be given VITE_SERVER_URL: they are
served from tauri://localhost and have no origin to infer a server from. The
dev:*/build:* Tauri scripts run through
scripts/tauri.mjs, which adds that server to the
webview's content security policy (connect-src) and passes the result to the
Tauri CLI with --config, so the static
tauri.conf.json never names a deployment.
npm run tauri:config prints what a build will use.
Native features live behind src/platform, which works - or
quietly does nothing - on every target:
- Haptics: the platform's own feedback styles on phones
(
tauri-plugin-haptics),navigator.vibratein browsers, nothing on desktops. - Notifications:
tauri-plugin-notificationnatively, the Notification API on the web. - Deep links:
dooz://play/online?code=ABCD(ordooz://join/ABCD) opens the app at that room, both on launch and while it runs; on Windows and Linuxtauri-plugin-single-instancehands the link to the open window. Invite links onhttps://open the app on phones once App Links / Universal Links are set up: setTAURI_APP_LINK_HOSTto the host ofVITE_PUBLIC_URL, serve/.well-known/assetlinks.json(Android packagegames.bazimazi.doozand the release key's SHA-256) and/.well-known/apple-app-site-association(<TeamID>.games.bazimazi.dooz, path/play/online*) from that host, and enable Associated Domains for the App ID. - Desktop updates:
tauri-plugin-updater, off unlessTAURI_UPDATER_ENDPOINTSandTAURI_UPDATER_PUBKEYare set (see.env.example). When an update exists the game offers it in a small card; nothing installs until the player taps it. - Windows: size and position are remembered (
tauri-plugin-window-state) and a second launch focuses the first. - Store services: achievements, leaderboards and purchases have interfaces
with no-op implementations in
game-services.ts; Steamworks, Game Center and Google Play Games need the owner's accounts and are marked as TODOs.
The Desktop workflow builds Windows, macOS and
Linux installers when run by hand or on a v* tag (which also drafts a GitHub
release). Signing and updater secrets are optional and listed at its top.
The brand asset guide lists the editable sources,
web/PWA icons, native launcher sets, and promotional images. Run npm run icons
from the repository root to regenerate the entire set and refresh scaffolded mobile projects.
The icon colors match the game's pink X, amber O, and indigo canvas.
Set VITE_PUBLIC_URL to the deployed web app address when building for the web
so social cards use absolute image URLs.
npm test # rules, invariants, AI strength, server, protocol, client
npm run typecheck # all TypeScript source
npm run lint # oxlint, type-aware
npm run format # prettier
npm run audit:a11y # contrast and naming, against a real browser
npm run audit:experience # teaching, touch, recovery and motion
npm run audit:sheet-back # repeated selections and every sheet dismissal method
npm run audit:navigation # return paths, settings, scrolling and phone layouts
npm run audit:performance # production preview on port 4180, phone layout at 6x CPU slowdownThe player experience review records the research, observed friction, implemented improvements, and next playtest priorities.
Every device gets an account on first launch — no form, no email. The server issues an id and a token; the token is the only credential, and only its hash is stored. Attaching a password later is optional and is what lets the same account be used on a second device.
An account holds one live connection. A second one takes over and the first is told why, which is the behaviour a reconnect after a crash needs and the behaviour two tabs fighting over one seat does not.
Everything that a result depends on:
- whether a move is legal, and whose turn it is;
- whose clock is running, and whether it has run out;
- what a disconnection costs, and when an abandoned game is awarded;
- what a rating changes by.
The client sends a cell index and renders what comes back. It runs the same rules, but only to draw a board it has already been given — never to decide one.
They are kept apart because they are different games socially. Only ranked moves a rating; leaving one early is a resignation. Private rooms are never rated, and their codes come from the CSPRNG — a non-cryptographic generator's state can be recovered from a handful of observed outputs, which would make every later code predictable.
Ranked matchmaking pairs players inside a rating band that widens the longer either has waited, because a perfectly fair match that never happens is worse than a slightly lopsided one that does. Queues are indexed by rating bucket, so a search looks at its neighbours rather than everybody, and somebody you have just played is passed over for a few seconds while anyone else will do.
Ratings are Glicko-2: each carries a deviation that shrinks as you play and grows while you are away, so a new or returning player moves quickly and a settled one does not. A deviation above 110 is shown as provisional. Ratings from before the switch were kept as they were, with a deviation estimated from how many ranked games stood behind them.
Seasons run eight weeks (SEASON_LENGTH_DAYS). Each has
its own record and ladder; at the end the standings are archived, everyone who
placed gets a title for the tier they finished in, and ratings are pulled a
quarter of the way back to the start with their deviation raised, so the five
placement matches of the next season count for a lot. Tiers run Bronze to
Diamond in three divisions each, then Master; both ends draw the lines from
one shared table.
The ladder lists players who finished their placements and whose rating has been tested recently (current deviation at most 150), ordered by rating. A conservative "rating minus two deviations" was the alternative; it was turned down because the ladder would then show a different number from the profile.
A casual search that finds nobody in 20 seconds (BOT_BACKFILL_SECONDS) is
offered a server bot near the player's rating. Bots are
accounts flagged as such, labelled on every seat, never rated, and think on
worker threads - a bounded pool with a queue limit
and a per-job timeout - so a search never stalls the event loop.
Arenas run on a schedule (every three hours for thirty minutes by default): join any time, get paired again as soon as a game ends, 2 points a win, double while on a two-win streak, +1 for winning berserk. Private single-elimination brackets for 4, 8 or 16 are shared by code. Ranked and casual games in progress are listed publicly to watch; private ones never are. Watchers of a rated game - ranked or tournament - see it two plies behind until it ends, so an engine run on what they see always answers a position that no longer exists.
After every ranked game on a board large enough to have real choices, a worker replays it and compares each non-trivial move with the strongest engine's first choice, alongside how evenly the moves were timed. Repeated games between one pair with one-sided results, and ranked games thrown in the first few moves, are recorded too. None of it bans anyone: an open high-severity flag keeps the account off ladders and out of rewards until a moderator clears or confirms it.
Every account has a friend code (XXXX-XXXX, from the room-code alphabet);
friends can also be found by name prefix. A request needs accepting, and a block
is one-sided and silent - the blocked player simply cannot find, ask, challenge
or invite the blocker again. Friends see each other's presence: online, looking
for a game, or in one.
A challenge opens a private room for the challenger and sends the friend a notice with its code; accepting is joining the room. A game by mail (correspondence) gives each move one, two or three days, lives entirely in SQLite - so it survives restarts and deploys - and is always casual: a game played over days invites outside help nobody can rule out. Running out of time on a move loses; a reminder goes out half a day before.
Notices travel over the game socket when one is open, through a small inbox
the app polls while it is on screen, and as Web Push to players who turned
notifications on in Settings. Push is implemented on node:crypto (VAPID
signing and RFC 8291 payload encryption, checked against the RFC's worked
example); node scripts/generate-vapid-keys.mjs makes the key pair. Push
endpoints arrive from clients, so the server only ever posts to an allow-list
of the browsers' push services (PUSH_HOSTS).
Reports from players land in a queue read through an admin API (/admin/*,
Authorization: Bearer $ADMIN_TOKEN, compared in constant time; the API does
not exist without a token of at least 24 characters). A moderator can see the
match a report is about, the reported account's history and its anti-cheat
flags, and mute (no emotes, requests, challenges or invitations), suspend
(refused at hello and on every API route but data export and deletion) or
rename it; sanctions can be temporary and are revocable. Display names pass a
small built-in filter on the way in that undoes leetspeak, accents and
separators, and checks short words only as whole words, so "Scunthorpe" and
"Dickens" are fine.
Recovery codes (eight, shown once, stored hashed, each single-use) get a player back into an account without a password, and using one signs out every other device. Profile → Account and data downloads everything the server holds about the account as JSON (tables are discovered by their foreign key to the account, credentials stripped), or deletes it: live and correspondence games are resigned first so opponents get a result, then every row keyed to the account goes. Opponents keep their match history, with the deleted side shown as a former player.
The app sends anonymous usage events (session start, game start and finish
with the mode, onboarding complete, screens) and crash reports (window errors,
unhandled rejections, the route error screen) to the game's own server, in
batches. No account id, name, device id or address is sent or stored, URLs lose
their query strings, and nothing is sent when the player turns it off in
Settings or the browser sends Do Not Track / Global Privacy Control. The server
keeps raw events for TELEMETRY_RETENTION_DAYS, a daily rollup indefinitely,
and groups crashes by fingerprint; /admin/telemetry reads them.
Nothing. Beyond the rules above:
- Every inbound frame is parsed against the schema, and every outbound one is parsed again by the client — including cross-field checks, so a board that disagrees with the config it claims is rejected rather than rendered.
- Nothing but
hellois accepted before a connection has proved who it is. A connection that has not done so within ten seconds is closed. - The WebSocket upgrade checks
OriginagainstALLOWED_ORIGINS. The CORS middleware does not cover this: a WebSocket handshake is not subject to the same-origin policy, so without the check any page could open a socket here. - Credentials are compared in constant time. Passwords go through scrypt with a per-account salt; tokens, which are 32 bytes of entropy, through SHA-256.
- A wrong password and an unknown name produce the same answer, after the same work.
- Inbound messages are rate-limited per connection, emotes far more tightly than that, account creation and sign-in per address, failed sign-ins per account name, frames are size-capped, dead sockets are dropped by heartbeat, and there are ceilings on sessions, rooms and spectators so no map can grow without bound.
- A declined draw offer cannot be repeated until the position has changed.
- The client address is the socket's own.
X-Forwarded-Foris read only whenTRUST_PROXY=1, because without a proxy that header is whatever the client chose to send.
The palette comes from the
Figma file and lives in
src/styles/theme.css under
the same names it has there (b1–b10, g1–g10, p1–p3, y1–y3).
Components never reach for those directly. They use the semantic layer above
them — canvas, surface, ink, stroke, mark-x — which is what makes the
light theme a block of redefinitions rather than an audit of every file. Both
themes were measured: every piece of text meets WCAG AA against the surface it
is actually painted on, compositing included.
Artwork is inline SVG rather than image files: it scales to any display, needs no network, and recolours with the theme.
The game defaults to its full animation experience on web, PWA, desktop, and mobile. Settings → Graphics lets players choose a quality preset. Changes apply immediately and survive relaunches:
- Low: static visuals, no particles, blur, glow or shadows, immediate results and sheet dismissal. Recommended for slower phones.
- Balanced: gameplay animations, a still background, 16 celebration particles instead of 56, no blur or glow, and shorter result pauses. Screen changes skip the full-document view transition.
- High (default): the full animation and effects experience.
OS/browser reduced-motion settings and legacy saved reducedMotion values are
intentionally ignored. Graphics quality is always an explicit player choice.
Future contributions must keep graphics quality, animation timing, view transitions,
decorative effects, and result reveal delays independent of device motion
settings. Do not add prefers-reduced-motion overrides, motion-reduce or
motion-safe utilities, motion preference detection, or a reduced-motion
toggle unless the project owner explicitly changes this policy. Contributor
instructions also live in AGENTS.md.
npm run audit:graphics checks preset switching, persistence and gameplay on
mobile and desktop, including static win lines and chalk marks on Low.
npm run audit:animations and
npm run audit:experience check that full animations
remain enabled when the browser reports reduced motion.
The board is a role="grid" of role="row"s with a roving tabindex, so it is a
single tab stop and the arrow keys move within it — a 15×15 board would
otherwise put 225 stops in the page's tab order. Home and End jump to the ends
of a row. Cells are never disabled, only aria-disabled: a disabled button
cannot take focus, and with a roving tabindex that would leave the grid with no
tab stop at all the moment somebody played the anchor cell. Every cell is
labelled by position and contents, and on an Ultimate board by which small board
it belongs to and how that board stands.
Overlays that set aria-modal honour it: focus moves in and is restored on
close, Tab wraps inside the panel, and the rest of the page is inert for as
long as the panel is up. Turn and status changes are announced through a live
region; a clock announces itself only once it is nearly out, because announcing
every second of both clocks would make the board unusable. The page does not
block zoom. Motion follows the animation policy above.
There are tests for the parts a refactor can silently break — the tab stop, the
arrow keys, the grid structure, the focus trap, and the roles and names of every
composite control. Contrast, target size and "is every control named" need
layout and colour, so they are checked against a real browser by
npm run audit:a11y.
The interface is translated with a small library-free layer in
src/i18n. Each area of the app has its own catalog in
src/i18n/messages; English is the source and fixes the
keys, every other language is a partial copy that falls back to English, and a
test checks each translation has the same keys, placeholders and plural forms.
Plurals use the locale's own rules and numbers its own digits. Persian sets
dir="rtl" on the document, so layouts use logical properties; boards and
other coordinate drawings are pinned to left-to-right. The language follows
the device until the player picks one in Settings.
To add a language, add it to locales.ts and give each
catalog a translation; untranslated messages show in English meanwhile.
The game began as one build per framework - React, Vue, Svelte, Solid and
vanilla JavaScript - written as a comparison between them. The React one was the
most complete, and src/ is the rebuild of it. The others are in the history
rather than the working tree.
MIT - see LICENSE.