- TypeScript 85.5%
- Shell 9.7%
- JavaScript 4.3%
- CSS 0.4%
Step 1 runs before the Forgejo wizard, so $VPS_ADMIN_TARGET was empty, ssh never connected and grep printed a misleading 0. The step now reads it from the GitLab wizard's state, and docker ps --all counts stopped preview containers too. Refs #498 |
||
|---|---|---|
| .agents/skills | ||
| .claude | ||
| .forgejo | ||
| .husky | ||
| .zed | ||
| apps | ||
| deploy | ||
| docs | ||
| infra | ||
| lint | ||
| packages | ||
| scripts | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitattributes | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| .jscpd.json | ||
| .lintstagedrc.json | ||
| .mcp.json | ||
| .npmrc | ||
| .nvmrc | ||
| .oxlintrc.json | ||
| .prettierignore | ||
| CLAUDE.md | ||
| CODING_STANDARDS.md | ||
| commitlint.config.js | ||
| compose.dev.yaml | ||
| CONTRIBUTING.md | ||
| Dockerfile | ||
| GLOSSARY.md | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| renovate.json5 | ||
| skills-lock.json | ||
| tsconfig.base.json | ||
| tsconfig.json | ||
| tsconfig.lib.json | ||
| turbo.json | ||
chess-project
A TypeScript monorepo for a real-time chess game. The engine, server, and client are built from scratch with minimal external runtime dependencies. Production-grade scaffolding, learning-driven scope.
Status
The engine is feature-complete for the rules. Two players can share one screen at /local, or play each other across browsers through a Room: create one from the home page, send the /room/$code link, and play with the server as the source of truth. Seats survive a reload, a Player can resign or make a draw or restart Offer, and a third opener watches as a Spectator.
The Rooms and play actions increment has shipped as features; what remains is the Playwright suite covering it at the browser seam (#191).
| Package | State |
|---|---|
@chess/engine |
Full rules, FEN / PGN / SAN / UCI, perft harness. No I/O, no move search. |
@chess/contracts |
The oRPC contract — the room, seat, move, resignation and offer resources, their Zod payloads and errors, the SSE change stream. No engine. |
@chess/api |
NestJS implementing the oRPC contract under /rpc, behind the origin guard. In-memory Rooms seated by Guest, with eviction. |
front |
Home page, /local and /room/$code. TanStack Router + Query, zustand, shadcn design system, Storybook. |
@chess/e2e |
Playwright, Chromium only, two or three browser contexts per test against the real API and front. Runs as test:slow. |
Running it
Node 24, pnpm 10 and Docker.
pnpm install
pnpm db
pnpm dev
pnpm install gives each worktree its own port block and its own database, and writes them to apps/front/.env.local and apps/api/.env.local, so several worktrees can run pnpm dev at once. No server falls back to a default port or database: if one is missing, pnpm env:sync regenerates the files. pnpm db starts the one Postgres container every worktree shares (compose.dev.yaml) and creates this worktree's database in it; the API's dev script migrates it and seeds the Demo Guests' Games before the server starts, pnpm --filter @chess/api db:seed:dev seeds them again, and pnpm --filter @chess/api db:studio browses it. pnpm env:sync also writes a gitignored .claude/launch.json on the same ports, so the Claude desktop app's preview server can start dev or storybook on the .nvmrc Node, and a Claude Code session in a linked worktree runs pnpm db at start. The gate is the five commands in CONTRIBUTING.md; the browser suite runs outside it with pnpm turbo run test:slow.
The Puzzles are a sample of the Lichess puzzle database (CC0), committed gzipped at apps/api/puzzles/lichess-sample.csv.gz; pnpm --filter @chess/api db:puzzles:dev loads it, and every stack's deploy runs db:puzzles after the migrations. To redraw it, download lichess_db_puzzle.csv.zst from that page, outside the repo, decompress it with zstd -d (Node's zstd stream cannot read its skippable frames), and run pnpm --filter @chess/api puzzles:sample <path to the .csv>: it keeps about 50,000 well-played Puzzles, a few hundred per curated Theme in each 200-point band (apps/api/scripts/sampling.ts), drops any line the engine rejects, and overwrites the sample.
Deploy
Production runs on the owner's VPS at https://chess.core-project.space, as a Docker Compose stack behind a shared Traefik that terminates HTTPS. The API serves the built front and /rpc from one origin, in one image built once by Forgejo Actions and pushed to the Forgejo registry. Once the ci run (.forgejo/workflows/ci.yml) is green on main, its production job deploys that commit.
Every pull request from the repository gets a preview at https://pr-<n>.chess.core-project.space, kept out of search engines (the preview job) and removed when the pull request closes (preview-close.yml). At most five previews run at once: a new one first removes the one deployed longest ago, and the nightly preview-reap.yml removes any whose pull request is no longer open. A fork's pull request never deploys.
Each stack runs its own Postgres beside the app, on a network of its own with no published port, its data in a volume a preview's removal takes with it (ADR 0032). deploy/up.sh brings the database up, runs the migrate step and then the step loading the Puzzle set to completion, and only then replaces the app, so a failed migration fails the deploy while the old app keeps serving. A preview's database is capped in memory and connections (deploy/compose.preview.yaml) and uses a committed throwaway password; production's comes from the DATABASE_PASSWORD repository secret, which deploy/owner-setup.sh sets. Postgres reads it only when the volume is first created, so changing it later takes an ALTER ROLE as well.
The owner browses and edits every stack's data in one Drizzle Gateway on the VPS, reached through an SSH tunnel. Each deploy joins it to the stack's database network, warning without failing when it is not running, and a preview's removal takes it off first. It connects as the gateway role, which the migrate step ensures on every stack: it reads and writes rows but cannot change the schema. Production's role password comes from the GATEWAY_DATABASE_PASSWORD repository secret; the Gateway itself is applied by hand, both through deploy/owner-setup.sh.
Rooms live in memory, so every redeploy, of production or a preview, drops them. The reasoning is in ADR 0049 and ADR 0048; the CI VPS in infra/forgejo/vps/README.md; the shared proxy, applied by hand, in deploy/proxy/README.md.
Every night the backup workflow (.forgejo/workflows/backup.yml) dumps production's database over SSH (deploy/backup/dump-database.sh), restores the dump into a throwaway Postgres on the CI VPS (deploy/backup/verify-backup.sh), encrypts it with age to the public key in deploy/backup/backup-recipient.txt (deploy/backup/encrypt-backup.sh), and keeps only the encrypted file, as the run's chess-db-backup artifact kept 30 days. Any failed step fails the run. The owner alone holds the private key; deploy/owner-setup.sh makes the pair. Its schedule runs it at 02:41 UTC; "Run workflow" on main runs it on demand.
Restore production from a backup
Run these from the repo root, with age and tea installed, the private key at <key-file>, and <vps-host> the VPS's address (the VPS_HOST repository secret).
-
Pick a green backup run (
eventisworkflow_dispatchfor one run by hand), open its page, and download itschess-db-backupartifact, a zip holdingchess-db-<date>.dump.age. Forgejo's API lists runs but serves no artifact, so the download is the page's, signed in:tea api "/repos/{owner}/{repo}/actions/runs?workflow_id=backup.yml&status=success&limit=10" \ | jq -r '.workflow_runs[] | "\(.created) \(.event) \(.html_url)"' mkdir -p backup && unzip chess-db-backup.zip -d backup -
Decrypt it, then check it restores locally (needs Docker):
age --decrypt --identity <key-file> --output chess.dump backup/chess-db-<date>.dump.age deploy/backup/verify-backup.sh chess.dump -
Stop production's app, so nothing writes while the data is replaced:
ssh deploy@<vps-host> docker stop chess-app-1 -
Restore into production's database.
--cleandrops each object the dump holds before recreating it, and--single-transactionleaves the database as it was if any statement fails. A table created after the dump is kept as it is.ssh deploy@<vps-host> docker exec -i chess-db-1 pg_restore --username=chess --dbname=chess \ --clean --if-exists --single-transaction --exit-on-error < chess.dump -
Start the app again, check https://chess.core-project.space answers, and delete the local copies:
ssh deploy@<vps-host> docker start chess-app-1 rm -r chess.dump backup backup.zip
If production's database container is gone, a production deploy (retry the production job of main's last pipeline) recreates it empty and migrated; then restore from step 3.
Goals
- Real-time PvP chess across browsers. Two players join a room via shareable link and play a complete game with the server as source of truth.
- A chess engine built from scratch. Full rules, FEN/PGN, perft-validated. The algorithmic centerpiece.
- Production-grade scaffolding. Strict TypeScript, real test suites, CI on day one.
- Modern Vite ecosystem. Vite, Vitest, Oxlint, Oxfmt, with Turborepo orchestrating.
Architecture
chess-project/
├── apps/
│ ├── front/ # React + TanStack — player UI
│ ├── api/ # NestJS + oRPC — game server
│ └── e2e/ # Playwright — the browser-seam suite
├── packages/
│ ├── engine/ # rules, FEN, PGN, perft (no I/O)
│ └── contracts/ # oRPC contract shared by api + front
├── docs/
│ ├── adr/ # architecture decision records
│ └── agents/ # how agents use the issue tracker and labels
├── scripts/ # per-worktree dev-port assignment
├── tsconfig.{base,lib}.json
├── .oxlintrc.json
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
@chess/engine— pure domain logic, no I/O. Vitest unit tests + perft suite. Source layout in ADR 0026:util → core → state → rules → search → notation → game, one public barrel.@chess/contracts— the oRPC contract (@orpc/contract) and the wire's vocabulary as Zod schemas: Room code, Guest, Room snapshot and changes, Offers. Chess shapes belong to the engine, so nothing here imports it. oRPC 2 is a beta, pinned exactly (ADR 0027).@chess/api— NestJS on Express (ADR 0027).@orpc/nestimplements the contract on Nest controllers, over oRPC's OpenAPI protocol under/rpc, with room changes pushed over SSE (ADR 0019). The Guest travels in a header, never a URL, and is hashed on arrival (ADR 0042). Rooms live in memory, inRoomService; the engine is the source of truth for legality.front— React 19 + TanStack Router / Query, Tailwind 4 + shadcn. Client-owned state is zustand's, server-owned state is Query's: an online Game is a Query cache entry nudged over SSE (ADR 0019). Vite proxies/rpcto the API in dev and preview. Hand-rolled board (click-to-move, highlights). Lichess Staunty piece SVGs (CC BY-NC-SA 4.0), vendored tinted (ADR 0013).@chess/e2e— Playwright against both servers, started by the config on this worktree's ports. Locally it drives the dev servers; CI drives the built bundles (E2E_TARGET=built). Its rules are inapps/e2e/CODING_STANDARDS.md.
apps/front/src/ is organised vertically — a domain slice (game/, auth/, games/, home/, local/, room/) owns its components, its state and its I/O together, with app/, routes/, design-system/ and lib/ around it. The rules are in apps/front/CODING_STANDARDS.md; the reasoning, and the alternatives rejected, in ADR 0043, and where server state lives in ADR 0019.
Tech stack
| Layer | Choice | Why |
|---|---|---|
| Monorepo | Turborepo | Caching + topological task graph |
| Package manager | pnpm | Strict isolation, fast, workspace:* |
| Language | TypeScript | Strict mode, project references |
| Frontend | React 19 + TanStack | Best ecosystem, TanStack for routing and data |
| UI | Tailwind 4 + shadcn (base-luma, Base UI) |
Owned components, themed by CSS variables |
| Client state | zustand (local game) + TanStack Query (server) | Client-owned and server-owned state differ (ADR 0019) |
| Backend | NestJS + @orpc/nest on Express |
Nest's structure, with the contract's types kept |
| RPC | oRPC 2 (beta, pinned exactly) | One typed contract for api and front (ADR 0020) |
| Build / dev | Vite | Standard 2026 toolchain |
| Test | Vitest | Same engine as Vite, native ESM |
| Browser tests | Playwright (Chromium) | The Room feature across real browsers |
| Component docs | Storybook 10 | A local design mirror, outside the gate |
| Linter | Oxlint | Rust-based, fast, type-aware via tsgolint |
| Formatter | Oxfmt | Theme-consistent with Oxlint |
| CI | Forgejo Actions, self-hosted | Gate, previews and deploys on one runner |
v1 scope
In:
- All chess rules: piece moves, castling, en passant, promotion, check / mate / stalemate
- Draws: 50-move rule, threefold repetition, insufficient material
- FEN parse + serialize
- PGN export
- Game state machine (
pending→active→finished+ result enum) - Perft test harness pinned to Lichess reference values
- Real-time PvP over HTTP + SSE: create room, join, make move, resign, draw and restart Offers, reconnect via the browser's Guest, spectate
- Engine + API + front all green in CI
Out (deferred to v2+):
- AI opponent (planned as separate
@chess/aipackage) - Database persistence
- Accounts / auth
- PGN import
- Clocks / time control
- Chat
Quality bar
- Engine — Vitest unit tests + perft suite + (optional)
fast-checkproperty tests. No coverage threshold is enforced today; the suite is the bar. - Contracts — no tests of its own. The schemas are exercised through the API and front suites that call them, and the typechecker covers the rest.
- API — unit tests beside each module (Room,
RoomService, origin middleware, the SSE change stream). - Front — Vitest + React Testing Library, no coverage threshold. Component tests inside their vertical; one router-level integration test mounting the real router.
- E2E — Playwright at the browser seam,
pnpm turbo run test:slow. No retries: a flake fails the run. - CI — the five commands from
CONTRIBUTING.md, plus the browser suite as a separate job, on pull requests andmain; green required to merge.
Decisions deliberately deferred
These iterate cheaply once coding starts; pinning them now would over-plan.
- Engine public API surface (driven by tests)
- Whether
@chess/contractsstays separate forever or folds into@chess/engineafter v1
How the work is planned
The engine was built in isolation as a library. Everything after it is built in increments that each span contracts + API + front and end with something playable, instead of finishing one layer at a time.
There is no numbered roadmap and no version label. One increment is in flight at a time, and a brief in docs/ defines it — its scope, its settled decisions, its open questions and the ADRs it owes. When an increment ships, its brief is deleted — the README and the ADRs carry what it settled — and the next one is written.
- Shipped — the scaffold,
@chess/engine, local play at/local, and the Rooms and play actions features: home page, Room codes and shareable URLs, Guests and reconnection, resign and Offers, spectators. The proof-of-concept WebSocket round-trip that preceded them was deleted rather than extended (ADR 0019). - In flight — the Playwright suite covering the Room feature (#191). No brief yet names the next increment.
Issues live on the self-hosted Forgejo; an agent works one with the implement skill, or a whole spec with implement-spec. Conventions are in docs/agents/.