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
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기DaisyChess — free, ad-free, open-source chess analysis, training and play. Web app: play.daisychess.org
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗A modern, minimal chess analysis web app. Play a game yourself or import a PGN, and let Stockfish (or other UCI engines) evaluate every position with a live evaluation bar, best-move suggestions and full-game analysis.
Two analysis backends are offered and can be switched at runtime:
License: GPL-3.0-or-later (see
LICENSE, third-party components inTHIRD_PARTY_NOTICES.md), exceptapps/site(the community website), which is MIT (seeapps/site/LICENSE) because the GSAP animation library it uses has a licence that is not GPL-compatible. Live instance: https://play.daisychess.org · Community site: https://daisychess.org · Source: https://github.com/refaustria/daisychess Run your own: a single host with nginx and systemd is described indeploy/README.md; a Docker Compose setup (web + engine server) is indocker/. Want to contribute? SeeCONTRIBUTING.md.
For Claude Code: this repository is run by an orchestrating Claude Code session that acts as project manager only. It does not implement features itself; it plans, delegates to sub-agents, reviews and integrates. See
CLAUDE.mdand the section Working Model for Claude Code below.
All milestones M0–M10 are implemented (play, browser and server analysis, game review, play vs engine, online import, theming, hardening); the live app is https://play.daisychess.org (ADR-021). Task-level status is in docs/PLAN.md, design decisions in docs/DECISIONS.md (ADR-001..), the threat model in docs/SECURITY.md, deployment in deploy/README.md and docker/.
| Area | State |
|---|---|
| Play (board, move list, undo, flip, FEN/PGN import, keyboard navigation) | done |
| Browser engine (Stockfish 19 WASM, MultiPV, eval bar, arrows, line preview) | done |
| Server engine (native Stockfish 19, WebSocket, limits, Origin check) | done |
| Game review (accuracy, eval graph, key moments, annotated PGN export) | done |
| Engine registry and multi-engine comparison | done (Stockfish; other UCI engines are configurable on the server) |
Human-like engine: Lc0 + Maia 1100–1900 on the server (style: human, 1-node policy move, move probabilities) | server done (ADR-019, M23-1); web (play vs Maia, review hints) in progress (M23-2/3) |
| Theming (board colours, piece sets Cburnett/Letters, coordinates, highlights, sounds, dark/light) | done |
| Accessibility / performance pass | done (axe e2e checks, keyboard-operable tabs; the board itself is mouse/touch only) |
| Play a friend via link (rooms on the server: nickname, time control, colour; reconnect, draw/resign/abort/claim, spectators) | server done (ADR-013); web UI in progress (M16-2) |
| Puzzles (stage 1: lichess puzzle database, rating, daily, rush, filters, offline starter; ADR-023) | done (stage 2, puzzles from your own mistakes, included) |
| Accounts | not built (non-goal for v1) |
| Goal | Description |
|---|---|
| Play | Drag-and-drop board, legal-move highlighting, undo, flip, move list with SAN notation |
| Import | Load PGN (file upload or paste), FEN input, navigate with keyboard arrows |
| Analyze | Live engine evaluation of the current position, top-N lines (MultiPV), best-move arrows |
| Eval bar | Vertical bar next to the board, centipawn → win-probability mapping, mate display (M3) |
| Game review | Analyze all moves of a game, classify (best / good / inaccuracy / mistake / blunder), accuracy per side |
| Engines | Pluggable engine registry; enable/disable engines, run several in parallel for comparison |
| Backends | Switch between browser (WASM) and server (native) analysis at any time |
| Look & feel | Clean, modern board (Lichess-like default: brown/beige squares, cburnett pieces); themes for colors, pieces, coordinates, dark/light UI |
Non-goals (for v1): online multiplayer, user accounts, opening database, cloud storage.
daisychess/
├── apps/
│ ├── web/ # Vite + React + TypeScript frontend
│ └── server/ # Node.js (TypeScript) engine server, WebSocket API
├── packages/
│ ├── engine-core/ # Shared: UCI parser, Engine interface, eval types, cp→% mapping
│ └── ui-theme/ # Board/piece themes (CSS + assets)
├── engines/ # Native engine binaries / NNUE files for the server (git-ignored, fetched by script)
├── docker/ # Dockerfiles + compose for server mode
├── .claude/ # Claude Code agent definitions and project memory
├── CLAUDE.md
└── README.md
apps/web)chessground (the Lichess board library). Pure CSS theming → easy customization.chess.js for move generation, FEN, PGN parsing, SAN.EngineClient interface with two implementations:
WasmEngineClient — loads a WASM engine inside a Web Worker.RemoteEngineClient — talks to the server over WebSocket./): board + eval bar, engine panel, and tabs for Moves, Play, Openings, Live, Friend, Import, Review, Explorer and Settings (all but Moves and Import are lazy-loaded). There is no router.apps/web/README.md.Browser engine builds (from the stockfish npm package, Stockfish 19; the two lite builds plus the full build as an opt-in download):
| Build | Size | Needs COOP/COEP headers | Use |
|---|---|---|---|
lite-single | small | no | default, works everywhere |
lite (multi-thread) | small | yes | if headers available |
full (multi-thread, big NNUE) | ~75 MB | yes | not shipped (idea: opt-in "maximum strength") |
The site must set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp in production to unlock SharedArrayBuffer for multi-threaded WASM. Detect support at runtime and fall back gracefully.
Browser engine memory (ADR-015). Each WASM instance costs ~60 MiB (lite-single) to ~155 MiB (lite, 4 threads) plus a 128 MiB…2 GiB WebAssembly.Memory, so instances are budgeted:
apps/web/src/engine/wasmPool.ts): one holder at a time (a drill check waits for a running review), disposed 20 s after the last use. Server reviews/drills (ADR-014) do not touch it.engine/memoryBudget.ts detects iOS/iPadOS (also the Mac user agent of iPadOS) and low deviceMemory (≤ 2 GiB): there the live analysis runs one browser engine at a time (the picker says "Memory budget: one browser engine at a time on this device"), with at most 2 threads (labels, the Threads select and the engine itself are capped), the multi-threaded build starts through engine-shim/stockfish-memory-shim.js (shared memory maximum 512 MiB instead of 2 GiB), and the live engine is released while a review/drill/game pauses it.window.__daisychess.wasmInstances() reports live/peak/created instances (e2e engine-memory.spec.ts).apps/server)ws for WebSocket, fastify for health/REST endpoints.engines.json): name, binary path, NNUE path, default options (Threads, Hash), enabled flag. Stockfish 19 is the default (official "universal" Linux build, picks the instruction set at runtime); others (e.g. Lc0, Fairy-Stockfish) are optional.scripts/fetch-engines.sh downloads/compiles engines for the host CPU (x86-64 AVX2 / BMI2 / Apple Silicon detection).docker-compose.yml for one-command deployment.JSON text frames over WebSocket (/ws), one message per frame, discriminated by type. The schemas, types and parsers live in packages/engine-core/src/protocol.ts (Zod); the design is recorded in ADR-006. PROTOCOL_VERSION is carried by hello/welcome; a mismatch is answered with error: protocol_mismatch.
// client → server
{ "type": "hello", "protocolVersion": 1, "client": { "name": "daisychess-web", "version": "0.1.0" }, "token": "…" } // token only if the server sets ACCESS_TOKEN
{ "type": "analyze", "sessionId": "tab-1", "searchId": 7, "engine": "stockfish", "fen": "...",
"multipv": 3, "depth": 24, "movetime": 5000, "threads": 4, "hash": 256 } // all after fen optional; no depth/movetime = infinite
{ "type": "stop", "sessionId": "tab-1" } // stops the running search, bestmove follows
{ "type": "release", "sessionId": "tab-1" } // frees the leased engine instance
{ "type": "ping", "t": 1700000000000 }
// server → client
{ "type": "welcome", "protocolVersion": 1, "serverVersion": "0.1.0",
"limits": { "maxDepth": 30, "maxMultipv": 5, "maxThreads": 4, "maxHash": 512, "maxMovetimeMs": 600000, "maxSessionsPerConnection": 2 },
"engines": [{ "id": "stockfish", "name": "Stockfish 19", "enabled": true, "defaults": { "threads": 4, "hash": 256 } }] }
{ "type": "info", "sessionId": "tab-1", "searchId": 7,
"info": { "depth": 18, "multipv": 1, "score": { "cp": 35 }, "pv": ["e2e4", "e7e5"], "nps": 1200000 } } // score = side to move (raw UCI)
{ "type": "bestmove", "sessionId": "tab-1", "searchId": 7, "bestMove": { "move": "e2e4", "ponder": "e7e5" }, "reason": "completed" }
{ "type": "error", "code": "limit_exceeded", "message": "depth 40 exceeds the server limit of 30", "sessionId": "tab-1", "searchId": 8 }
{ "type": "pong", "t": 1700000000000 }
sessionId, client-chosen) leases one engine instance; it is created by the first analyze, freed by release, by an idle timeout or when the socket closes. Several sessions per connection allow multi-engine comparison, bounded by limits.maxSessionsPerConnection.analyze starts a search (searchId, client-chosen, unique per session). A new analyze on a session with a running search stops it first; every accepted search ends with exactly one bestmove (or an error carrying its searchId).info scores are side-to-move, like UCI; the client converts with infoToWhitePov exactly as the WASM client does.ACCESS_TOKEN answers a hello without the matching token (max 256 chars, compared in constant time) with a restricted welcome — engines: [], capabilities: { analysis: false, rooms: true } — and rejects every analyze with error: unauthorized; rooms work without the token. With the token (or on a public server) capabilities is { analysis: true, rooms: true }. Additive: PROTOCOL_VERSION stays 1.invalid_message, protocol_mismatch, handshake_required, unknown_engine, limit_exceeded, no_capacity, session_not_found, engine_crashed, rate_limited, internal_error, unauthorized, forbidden (analysis while playing in a room).welcome.capabilities is { analysis, rooms, reviewStore? } — reviewStore: true when the server stores reviews (ADR-017, below).Rooms — play a friend via link (ADR-013, additive room.* messages on the same socket). The server validates moves (chess.js), runs the clocks and sends the full room.state snapshot to every member after each change; room-level failures come as room.error.
// client → server (all but room.create carry roomId; one room per connection)
{ "type": "room.create", "nick": "Alice", "clock": { "initialMs": 180000, "incrementMs": 2000 }, "color": "random" } // clock: null = no clock
{ "type": "room.join", "roomId": "…", "nick": "Bob" } // takes the free seat (game starts) or spectates; without nick: spectate only
{ "type": "room.attach", "roomId": "…", "playerToken": "…" } // reconnect / reload with the token from room.seat
{ "type": "room.move", "roomId": "…", "uci": "e7e8q" }
{ "type": "room.resign" | "room.abort" | "room.claim" | "room.leave", "roomId": "…" }
{ "type": "room.draw", "roomId": "…", "action": "offer" | "accept" | "decline" }
// server → client
{ "type": "room.seat", "roomId": "…", "playerToken": "…", "color": "white" } // on create / join with a free seat
{ "type": "room.state", "roomId": "…", "status": "playing", "you": "white", "spectators": 0,
"players": { "white": { "nick": "Alice", "connected": true }, "black": { "nick": "Bob", "connected": false } },
"startFen": "…", "fen": "…", "moves": ["e2e4", "e7e5"],
"clock": { "initialMs": 180000, "incrementMs": 2000, "white": 171000, "black": 180000, "running": "white" }, // null without a clock
"drawOffer": "black", "claimableAt": 1700000060000, "result": "1-0", "reason": "timeout", "serverTime": 1700000000000 }
{ "type": "room.error", "roomId": "…", "code": "not_your_turn", "message": "it is not your turn" }
https://play.daisychess.org/#/room/<roomId> (22 URL-safe chars, 128 random bits). The playerToken (43 chars, 256 bits) is kept in localStorage, never in the URL.reason: timeout; a draw if the opponent cannot mate). Displayed time of clock.running = clock[running] - (now - receivedAt).room.claim from claimableAt (60 s); both gone → abandoned. abort only before both sides moved; reason is one of checkmate stalemate draw resign timeout abort abandoned agreement (no result = no game was played).forbidden on analyze. Limits: ROOM_MAX_ROOMS 200, 10 creations / 10 min per address, waiting rooms expire after 15 min, finished rooms are kept 10 min, 10 spectators per room, nick 1–20 chars.room.error codes: room_not_found, room_full, bad_token, not_a_player, not_your_turn, illegal_move, bad_state, too_many_rooms.Review store (ADR-017, additive review.* messages on the same socket). Finished game reviews and position evaluations are kept on the server (SQLite) under the game's hash, so every device gets the same review for the same game. Reads are open to every connection; writes need the access token.
// client → server
{ "type": "review.get", "gameKey": "<sha256 hex>", "startFen": "…", "moves": ["e4", "e5", "Nf3"] } // SAN moves, 2…1000 plies
{ "type": "review.put", "gameKey": "…", "startFen": "…", "moves": […], "preset": "deep",
"record": { "version": 1, "options": { "depth": 18 }, "engine": { "kind": "server", "name": "Stockfish 19", "threads": 4 },
"summary": { /* ReviewSummary */ }, "moveDetails": { "1": { /* best line, evals, swing … */ } } } }
{ "type": "review.positions.get", "optsKey": "mt:300", "fens": ["…"] } // ≤ 64 FENs per message
{ "type": "review.positions.put", "optsKey": "d:18", "entries": [{ "fen": "…", "result": { "eval": { "cp": 20 }, "bestMove": "e2e4", "pv": ["e2e4"], "depth": 24 } }] }
{ "type": "review.stats.get" }
// server → client (one answer per request, correlated by gameKey / optsKey)
{ "type": "review.record", "gameKey": "…", "found": true, "preset": "deep", "record": { … }, "createdAt": 1761000000000 }
{ "type": "review.stored", "gameKey": "…", "stored": false, "reason": "unauthorized" } // unauthorized | disabled | key_mismatch | illegal_game | too_large | superseded | quota | storage_error
{ "type": "review.positions", "optsKey": "mt:300", "results": { "<fen>": { "eval": { "cp": 20 }, "depth": 21 } } }
{ "type": "review.positions.stored", "optsKey": "d:18", "stored": true }
{ "type": "review.stats", "enabled": true, "reviews": 123, "positions": 45000, "bytes": 1200000000, "maxBytes": 5000000000 }
gameKey = SHA-256 of daisychess-review-v1\n<startFen>\n<uci moves> (reviewGameKey in engine-core). The server replays the SAN moves with chess.js and recomputes the key, so nothing can be stored under a foreign key (key_mismatch); a stronger stored preset is never replaced by a weaker one (superseded).review.put, review.record, review.positions.put and review.positions may be up to MAX_LARGE_MESSAGE_BYTES (1 MiB + 128 KiB); every other type keeps the 16 KiB limit. A record is at most 1 MiB of JSON.rate_limited), REVIEW_PUT_PER_IP_PER_DAY (500) writes per address and day (quota), REVIEW_STORE_MAX_BYTES (5 GB) on disk with least-recently-used eviction. Nothing personal is stored: no headers, names or addresses.packages/engine-core)Engine interface: load(), setOptions(), analyze(fen, {depth, movetime, multipv}) → async iterable of EngineInfo, stop(), dispose().info … score cp/mate … pv …, bestmove).cpToWinProbability(cp) using the Lichess sigmoid (50 + 50 * (2 / (1 + exp(-0.00368208 * cp)) - 1)), mate handling, eval-bar clamping.The server can also play (and predict) like a human. Maia (CSSLab, GPL-3.0) is a set of nine Lc0 networks trained to predict the moves lichess players of a given rating actually play: maia-1100 … maia-1900. Each runs as an Lc0 process (engines/lc0/lc0, built by pnpm build-lc0; weights in engines/maia/ from pnpm fetch-engines) with a 1-node search: go nodes 1 returns the policy head's most likely move, no search strength is added. Everything runs server-side only; nothing of Lc0/Maia is shipped to the browser.
Protocol (additive, PROTOCOL_VERSION stays 1):
// welcome.engines[] gains style/rating/maxNodes for human-like engines (absent for Stockfish)
{ "id": "maia-1500", "name": "Maia 1500", "enabled": true, "defaults": { "threads": 1, "hash": 16 },
"style": "human", "rating": 1500, "maxNodes": 1 }
// welcome.limits gains maxNodes (default 10000, env MAX_NODES); analyze gains an optional node limit
{ "type": "analyze", "sessionId": "play", "searchId": 3, "engine": "maia-1500", "fen": "…", "nodes": 1 }
// the last info of a search carries the top-5 policy (how often 1500 players play each move here)
{ "type": "info", "sessionId": "play", "searchId": 3,
"info": { "depth": 1, "multipv": 1, "score": { "cp": 99 }, "pv": ["e2e4"], "nodes": 1,
"policy": [{ "uci": "e2e4", "p": 0.5022 }, { "uci": "d2d4", "p": 0.2334 }, { "uci": "c2c4", "p": 0.0497 }, …] } }
{ "type": "bestmove", "sessionId": "play", "searchId": 3, "bestMove": { "move": "e2e4" }, "reason": "completed" }
analyze without nodes on an engine with maxNodes runs exactly maxNodes nodes (Maia: 1); nodes above the engine's maxNodes or above limits.maxNodes is limit_exceeded. depth/movetime may be combined (whichever ends first). A checkmated/stalemated position gives bestMove: null and no info.info.policy (PolicyEntry { uci, p }, p as a fraction 0…1, most probable first, at most 10 entries, the server sends 5) is parsed from Lc0's VerboseMoveStats lines (parseLc0MoveStats/topPolicy in engine-core) and sent once per search as one more info (the last one, plus policy) right before bestmove. Other engines never send policy.MAIA_MAX_PROCESSES, default 3; registry group: "maia"): at the cap the least recently used idle Maia process is replaced, when all are busy analyze gets no_capacity. Maia sessions count against the same per-connection/per-IP session caps and need the access token like every analysis; the fair-play lock (forbidden while playing in a room) applies too.MAIA=off disables the nine entries; they are also disabled (one warning) when the binary or a weights file is missing. /health/detail lists them with group: "maia" and processGroups.maia {processes, max}.+0.3, -1.7, M4), always from White's perspective, flips with the board..pgn file or paste text; multi-game files show a picker.https://play.daisychess.org/#/room/<id>) to your friend. No account and no server token needed.daisychess.trainer): a variation drilled without a mistake comes back after 1, 2.5, 6 … days; one with a mistake is due again at once. "Due" filters the due variations, "Train due" starts the earliest. The live analysis is paused during a drill, and no drill runs during live, friend or engine games (starting one ends the drill)./puzzles/v1/, plus a 6,000-puzzle starter bundled in the app so it works offline). Solve by moving on the board or by typing the move in SAN; wrong move shows "Try again", with hint, "Show solution" and "Analyse" (opens the position in the analysis board). The first attempt of a puzzle is rated: a Glicko-2 puzzle rating (stored only in this browser, localStorage) picks puzzles near your level./puzzles/v1/ unreachable the app falls back to the bundled starter.daisychess-puzzles, at most 500 games) with the side you played (from the game source, the PGN names matched against Settings → Puzzles "My names", the lichess login and the import usernames; otherwise asked once). "Find puzzles in my games" takes the reviews (local cache; the server review store only when a token is set), lets the engine check each of your mistakes (multipv 2; the puzzle is kept only if the best move is clearly better, e.g. a missed mate) and stores it with a spaced-repetition schedule. "Train due" solves the due ones (unrated, a correct first try pushes the next review out, a miss brings it back at once); each puzzle has "Open game" (jumps to the ply) and delete. Search can be cancelled and pauses during human games. Everything stays on the device (docs/PRIVACY.md).THIRD_PARTY_NOTICES.md). Requests for the static files carry no identifier (docs/PRIVACY.md).hello.token; the server enforces it with ACCESS_TOKEN). The token is stored per device. Without it the UI says "No server access token on this device — enter it in Settings"; with a stored token the server refused it: "Server access token was rejected — check it in Settings"; only real connection failures say "Server unreachable". Those messages carry an "Open Settings" button that focuses the token field. Setup link: "Copy link for another device" (enabled when a token is stored) copies https://<origin>/#setup-token=<token>; opening it on another device stores the token there, removes the fragment from the address bar and shows "Server access token saved on this device". Anyone with the link can use your engine server: open it once, then delete it from your messages. Opt-in full-strength browser build (wasm:full, ~99 MB, one-time download confirmation).MAX_THREADS) and is released when the run ends. If the server is unreachable, refuses the token, is busy (session cap) or drops mid-review, the rest of the review runs in the browser (already analysed positions are kept) and the Review tab shows "Server unavailable (reason) — reviewing in the browser" (in Auto a missing/wrong token falls back silently). The progress line says where it runs ("Reviewing on server (Stockfish 19, 8 threads)…").daisychess-reviews, in memory only in private mode), keyed by the start position and the moves (headers do not matter). Loading a game that was reviewed before shows its review at once ("Loaded from cache (reviewed 25 Oct, deep, server)") without running an engine; Start with the same or a weaker preset is disabled ("Already reviewed (deep)"); Deep after Fast runs but reuses every position the Fast run already searched to depth 18 or more. Settings → "Review cache" shows the number of stored reviews and clears them.capabilities.reviewStore), a game not found in the local cache is looked up on the server and, if someone has reviewed it before, shown at once ("Loaded from server (reviewed 25 Oct, deep)") and cached locally. Reviews finished with the access token set are uploaded silently, as are the analysed positions, so a Deep review on an iPad reuses the Fast positions a laptop searched. Reading needs no token; the store keeps only moves and evaluations (no headers or names) and is capped on disk (REVIEW_STORE_MAX_BYTES, default 5 GB, least recently used reviews dropped first). Settings → "Review cache" shows "Server: N reviews · X GB of Y GB". "Browser" as review engine never contacts the server.[%clk h:mm:ss(.f)] / [%emt …] comments (lichess exports are requested with clocks=true; chess.com PGNs carry %clk) keep per-move remaining and spent time; the missing value is derived from the TimeControl header (spent = before − after + increment), daily games (1/86400) are treated as having no live clock. Outside live/engine/friend games the player bars show each side's static remaining time at the displayed move. The review shows "Used 0:42 (12% of remaining) · 0:18 left" per move, a clock graph under the eval graph, a per-player time summary (average, longest think, mistakes in time trouble) and a "time trouble" badge on mistakes/blunders made with less than max(10% of the initial time, 30 s) left; the guided tour appends "(in time trouble)". All of it is derived from the loaded game at render time — the engine review and the cached/stored review records are unchanged — and the annotated PGN export writes back.localStorage.A node lends the engines of one machine (Stockfish, optionally Lc0 with the Maia nets) to a DaisyChess hub (ADR-020). It only connects out to the hub over one WebSocket: no open port, no domain, no TLS certificate, works behind NAT.
dc1_…) in the portal can use it. A token can list several of your nodes and optionally fall back to the pool.NODE_POOL=on plus the pool switch in the portal). The hub spot-checks pool results; nodes whose answers disagree are removed from the pool, and a repeat offender is banned from it./portal/ of the hub (https://play.daisychess.org/portal/), log in with lichess, create a node and copy its node token (dcn_<id>_<secret>, shown once).git clone https://github.com/refaustria/daisychess && cd daisychess
cp docker/node.env.example docker/.env.node # set NODE_TOKEN, limits, NODE_POOL (HUB_URL: wss://hub.daisychess.org/link)
docker compose -f docker/docker-compose.node.yml --env-file docker/.env.node up -d
docker compose -f docker/docker-compose.node.yml logs -f node # "link: registered with the hub"
Hardware. Minimum 1 CPU and 512 MB; 2+ CPUs and 1 GB recommended. Each concurrent search uses MAX_THREADS cores and about MAX_HASH_MB + 80 MB of RAM, so keep MAX_SESSIONS x MAX_THREADS <= NODE_CPUS. amd64 uses the Stockfish universal binary, which picks the fastest instruction set at start (CPUs without AVX2 work, just slower); arm64 (Raspberry Pi 4/5, Apple Silicon, Graviton) is built in the image. Lc0 + Maia (human-like play) is optional: MAIA=off on very small machines. Details and sizing: deploy/README.md.
Privacy. Your node sees the positions (FEN) and search settings of the people who use it, but no IP addresses, names or accounts. Do not log or keep them. Read docs/PRIVACY.md and docs/TERMS.md first.
The hub is apps/server with ROLE=hub: everything the standalone server does, plus the /link endpoint for nodes, token routing, the shared pool, spot-checks, a SQLite store for operators, nodes and tokens, and the operator portal API. The portal itself is the static SPA apps/portal, served under /portal/.
The public hub (ADR-021) runs the app and portal on play.daisychess.org (PUBLIC_ORIGIN) and takes node links on wss://hub.daisychess.org/link (/link on play.daisychess.org is an alias). Every URL is configurable (PUBLIC_ORIGIN, ALLOWED_ORIGINS, the nodes' HUB_URL), so a self-hosted hub uses its own names.
Required environment (all variables with defaults: apps/server/.env.example, section "ROLE=hub"):
ROLE=hub
PUBLIC_ORIGIN=https://hub.example.org
ALLOWED_ORIGINS=https://hub.example.org
HUB_ADMIN_LICHESS=your-lichess-name # becomes administrator at first portal login
# HUB_STORE_PATH defaults to apps/server/data/hub.sqlite (not reproducible: back it up)
nginx must proxy /link (WebSocket, limit_req/limit_conn), /api/portal/ and serve /portal/ (static, own CSP); lichess login needs the loopback egress proxy on 127.0.0.1:8581 because the service unit has no outbound network (deploy/nginx/lichess-egress.conf.snippet). ROLE=standalone restores the plain server at any time. Deployment, backups and rollback: deploy/README.md ("Hub runbook"); security model: docs/SECURITY.md section 6.
| # | Milestone | Deliverable |
|---|---|---|
| M0 | Repo scaffold | pnpm workspace, lint/format/test setup, CI (GitHub Actions), engine-core skeleton |
| M1 | Play | Board, legal moves, move list, flip/undo, FEN/PGN import, keyboard navigation |
| M2 | Browser engine | WASM Stockfish worker, WasmEngineClient, eval bar, MultiPV panel, arrows |
| M3 | Server engine | apps/server, engine pool, WebSocket protocol, RemoteEngineClient, backend switch in UI |
| M4 | Game review | Batch analysis, move classification, accuracy, eval graph, annotated PGN export |
| M5 | Engines & themes | Engine registry (browser + server), multi-engine comparison, theming UI |
| M6 | Polish & deploy | COOP/COEP hosting, Docker image, docs, performance pass, accessibility |
Each milestone is broken into GitHub issues by the orchestrator before any implementation starts.
The Claude Code session in this repo is the project manager / orchestrator. Its job:
docs/PLAN.md.docs/DECISIONS.md (ADR style).| Tier | Model | Use for |
|---|---|---|
worker | Claude Sonnet 5.5 (claude-sonnet-5-5) | Regular tasks: components, tests, styling, docs, small bug fixes, scripts |
senior | Claude Opus 5.5 (claude-opus-5-5) | Hard tasks: engine pool & concurrency, WebSocket protocol, WASM worker integration, performance work, tricky bugs |
architect | Claude Fable 5.1 (claude-fable-5-1) | Architecture, cross-cutting design, protocol decisions, security review, anything the senior tier failed on twice |
Routing rules:
worker. Escalate to senior when the task touches concurrency, process management, engine protocols, or performance.architect for any task that changes interfaces in packages/engine-core, the WebSocket protocol, or the repo structure — and for unblocking tasks after two failed attempts.Sub-agent definitions live in .claude/agents/ (one file per tier with its model set). See CLAUDE.md for the exact prompts and conventions.
pnpm lint and pnpm test pass.engine-core, component tests for UI, integration for server).any types without justification.Requirements: Node >= 20, pnpm. Ports: web dev server 5180, engine server 8580 (ADR-001); Docker publishes the UI on 8080.
pnpm install
pnpm fetch-engines # downloads native Stockfish 19 (NNUE embedded, checksum-verified) and the Maia nets into ./engines
pnpm build-lc0 # builds Lc0 (CPU/Eigen, no AVX needed) into ./engines/lc0 for the Maia engines (ADR-019); needs meson, ninja, g++, libeigen3-dev, zlib1g-dev
pnpm dev:web # http://localhost:5180 (browser mode works without the server)
pnpm dev:server # http://127.0.0.1:8580 (/ws, /health); needs the engines above
pnpm lint # eslint + typecheck of all packages
pnpm test # Vitest, all packages
pnpm e2e # Playwright (builds the web app, serves it on :4180); first run: pnpm --filter @daisychess/web exec playwright install chromium
pnpm build
Server configuration is read from the environment (see apps/server/.env.example; no .env auto-loading, use node --env-file):
| Variable | Default | Meaning |
|---|---|---|
PORT / HOST | 8580 / 127.0.0.1 | listen address |
ALLOWED_ORIGINS | same host as the request | comma-separated scheme://host[:port] allowed to open /ws, or * (ADR-008); production sets https://play.daisychess.org |
MAX_SESSIONS | 4 | global cap on leased engine instances |
MAX_SESSIONS_PER_IP | unset | per-client-address cap, must not exceed MAX_SESSIONS |
MAX_SESSIONS_PER_CONNECTION, MAX_DEPTH, MAX_MULTIPV, MAX_THREADS, MAX_HASH_MB, MAX_MOVETIME_MS | see .env.example | per-request limits announced in welcome.limits |
ENGINE_IDLE_MS, SESSION_IDLE_MS | 300000, 600000 | idle timeouts |
ENGINES_CONFIG | apps/server/engines.json | engine registry |
LOG_LEVEL | info | pino level |
ACCESS_TOKEN | unset (public) | shared token for analysis (ADR-010); without it clients are restricted to rooms (ADR-013) |
ROOM_MAX_ROOMS, ROOM_MAX_SPECTATORS, ROOM_CREATE_PER_IP, ROOM_CREATE_WINDOW_MS, ROOM_WAITING_TTL_MS, ROOM_FINISHED_TTL_MS, ROOM_IDLE_TTL_MS, ROOM_CLAIM_GRACE_MS | 200, 10, 10, 600000, 900000, 600000, 7200000, 60000 | room limits and lifetimes (ADR-013) |
REVIEW_STORE | on | off disables the server-side review store (ADR-017); is then false |
deploy/README.md.docker compose -f docker/docker-compose.yml up --build -d # web UI + /ws + /health on http://localhost:8080
docker compose -f docker/docker-compose.yml down
server (docker/server.Dockerfile) runs Node 22 with Stockfish fetched at build time (STOCKFISH_VERSION / STOCKFISH_ARCH build args) as non-root under tini; port 8580 is only reachable inside the compose network. web (docker/web.Dockerfile, docker/nginx.web.conf) serves the build with COOP/COEP headers and proxies /ws and /health. Configure with docker/server.env; the registry is docker/engines.docker.json.
Stockfish and Fairy-Stockfish are GPLv3; shipping their binaries or WASM builds makes this project GPLv3 as well. chessground is GPLv3, chess.js is BSD-2. This repository is licensed under GPL-3.0-or-later (see LICENSE; bundled and downloaded components are listed in THIRD_PARTY_NOTICES.md).
%clkwelcome.capabilities.reviewStoreREVIEW_STORE_PATH | apps/server/data/reviews.sqlite | SQLite file of the review store (directory created; needs a writable path — see deploy/README.md) |
REVIEW_STORE_MAX_BYTES | 5000000000 (5 GB) | disk cap of the review store; least recently used positions, then reviews, are evicted down to 90 % |
REVIEW_PUT_PER_IP_PER_DAY | 500 | review/position writes per client address and UTC day (reason: quota) |
MAX_NODES | 10000 | cap on analyze.nodes (ADR-019), announced as limits.maxNodes |
MAIA | on | off disables the Lc0 + Maia engines (registry group maia, ADR-019); they are also off when engines/lc0/lc0 or a net in engines/maia/ is missing |
MAIA_MAX_PROCESSES | 3 | total Lc0 processes of all Maia levels (~16 MB RSS each) |