8000 GitHub - bazimazi/dooz: dooz (Tic Tac Toe) on 3×3, 6×6 and 9×9 boards · GitHub
[go: up one dir, main page]

Skip to content
bazimaziPublic

About

dooz (Tic Tac Toe) on 3×3, 6×6 and 9×9 boards

Resources

Stars

5 stars

Watchers

1 watching

Forks

Repository files navigation

dooz

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.


What it is

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

Offline use

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.

The stack, and why

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.

Layout

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.

The engine

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, rebuilt

Variants 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.

The AI

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

Playing alone

  • 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.ts against 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 run

Sound

Every 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.

Getting started

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 once

Online 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.

Deploying the server

docker build -t dooz-server .
docker run -d -p 8787:8787 -v dooz-data:/data --env-file server/.env dooz-server

The 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_HOURS into BACKUP_DIR, keeping the newest BACKUP_KEEP; POST /admin/backups takes one on demand. To restore, stop the server and copy a backup over DATA_FILE.
  • Deploys do not drop games. On SIGTERM the 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.

Desktop and mobile

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 only

gen/ 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.vibrate in browsers, nothing on desktops.
  • Notifications: tauri-plugin-notification natively, the Notification API on the web.
  • Deep links: dooz://play/online?code=ABCD (or dooz://join/ABCD) opens the app at that room, both on launch and while it runs; on Windows and Linux tauri-plugin-single-instance hands the link to the open window. Invite links on https:// open the app on phones once App Links / Universal Links are set up: set TAURI_APP_LINK_HOST to the host of VITE_PUBLIC_URL, serve /.well-known/assetlinks.json (Android package games.bazimazi.dooz and 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 unless TAURI_UPDATER_ENDPOINTS and TAURI_UPDATER_PUBKEY are 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.

Game artwork

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.

Everything else

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 slowdown

The player experience review records the research, observed friction, implemented improvements, and next playtest priorities.

Online play

Identity

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.

What the server decides

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.

Ranked, casual, private

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, seasons and tiers

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.

Bots, tournaments and watching

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.

Fair play

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.

Friends, challenges and games by mail

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).

Moderation

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.

Your account and data

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.

Telemetry

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.

What the server assumes about clients

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 hello is 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 Origin against ALLOWED_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-For is read only when TRUST_PROXY=1, because without a proxy that header is whatever the client chose to send.

Design

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.

Animation policy

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.

Accessibility

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.

Languages

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 earlier versions

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.

Licence

MIT - see LICENSE.

About

dooz (Tic Tac Toe) on 3×3, 6×6 and 9×9 boards

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

0