No description
  • TypeScript 85.5%
  • Shell 9.7%
  • JavaScript 4.3%
  • CSS 0.4%
Find a file
Amaury Coudray 1cbd915692 docs(repo): read the freeze check's VPS target from GitLab's wizard and count stopped previews
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
2026-10-22 12:03:11 +02:00
.agents/skills docs(repo): move the agent tooling to tea and say pull request 2026-10-22 11:43:31 +02:00
.claude fix(repo): name the forgejo-pr plugin's command /forgejo-pr, apart from the pr skill 2026-10-22 11:43:31 +02:00
.forgejo ci: run the Forgejo front tests on one worker, as GitLab's now do 2026-10-22 11:45:42 +02:00
.husky chore(repo): pick the .nvmrc Node once in the pre-commit's run, not on each step 2026-10-20 17:10:18 +02:00
.zed feat(repo): scaffold apps/front with TanStack Router + React + Vite 2026-06-10 14:53:20 +02:00
apps docs(repo): describe Forgejo as the forge in the README and the report comments 2026-10-22 11:43:45 +02:00
deploy ci: carry the app's secrets to Forgejo on switch day, and set them there from owner-setup.sh 2026-10-22 11:43:45 +02:00
docs docs(repo): move the agent tooling to tea and say pull request 2026-10-22 11:43:31 +02:00
infra docs(repo): read the freeze check's VPS target from GitLab's wizard and count stopped previews 2026-10-22 12:03:11 +02:00
lint refactor(repo): name layerOf's sub-conditions and exempt all test support 2026-10-07 10:44:18 +02:00
packages docs(repo): describe Forgejo as the forge in the README and the report comments 2026-10-22 11:43:45 +02:00
scripts feat(repo): port the gitlab-mr plugin and mr-status.sh to Forgejo on tea 2026-10-22 11:43:31 +02:00
.dockerignore fix(repo): let the Puzzle sample into the image's build context 2026-10-18 15:10:54 +02:00
.editorconfig chore(repo): init pnpm workspace and root tooling 2026-04-25 16:19:10 +02:00
.gitattributes fix(front): generate the route tree in oxfmt's style so builds leave it unchanged 2026-10-11 17:32:32 +02:00
.gitignore ci: report tests, cancel on failure and fall back to the last pnpm store, all natively 2026-10-15 22:04:00 +00:00
.gitlab-ci.yml ci: stop GitLab deploying previews and production before the switch 2026-10-22 11:53:33 +02:00
.jscpd.json chore(repo): add jscpd for duplication checks at review 2026-10-04 19:29:08 +02:00
.lintstagedrc.json chore(repo): let lint-staged commit files the formatter ignores 2026-10-04 19:29:08 +02:00
.mcp.json chore(repo): share the Playwright MCP server as an authoring aid 2026-09-01 16:23:30 +02:00
.npmrc build(repo): run every agent shell on the .nvmrc Node with the worktree set up 2026-10-15 23:42:32 +02:00
.nvmrc chore(repo): init pnpm workspace and root tooling 2026-04-25 16:19:10 +02:00
.oxlintrc.json chore(repo): stop working around lint rules that do not fit tests, e2e and the API 2026-10-14 15:34:46 +02:00
.prettierignore feat(repo): scaffold apps/front with TanStack Router + React + Vite 2026-06-10 14:53:20 +02:00
CLAUDE.md docs(repo): move the agent tooling to tea and say pull request 2026-10-22 11:43:31 +02:00
CODING_STANDARDS.md docs(repo): write the test policy into the coding standards and CONTRIBUTING 2026-10-21 17:00:51 +02:00
commitlint.config.js chore(repo): reject agent attribution and add api, contracts scopes 2026-10-04 19:29:08 +02:00
compose.dev.yaml feat(repo): catch dev mail in a shared Mailpit and give each worktree its mail settings 2026-10-14 14:09:48 +02:00
CONTRIBUTING.md test(front): render every story once in a browser smoke test 2026-10-21 17:00:51 +02:00
Dockerfile feat(api): load the Puzzle set after the migrations on every stack 2026-10-18 11:02:13 +02:00
GLOSSARY.md docs(repo): give a Run its own pending Attempt in ADR 0047 and the Run in the glossary 2026-10-21 06:55:06 +00:00
package.json ci: run the Forgejo gate scripts' tests in test:infra and in the checks job 2026-10-22 11:43:31 +02:00
pnpm-lock.yaml fix(deps): move Storybook to 10.6.3, past GHSA-f2ph-cmg5-wxc8 2026-10-22 10:12:57 +02:00
pnpm-workspace.yaml fix(deps): move every flagged dependency to a fixed version, and accept braces until one exists 2026-10-15 22:36:11 +00:00
README.md docs(repo): describe Forgejo as the forge in the README and the report comments 2026-10-22 11:43:45 +02:00
renovate.json5 ci: schedule the audit, the production backup and Renovate on Forgejo 2026-10-22 11:43:45 +02:00
skills-lock.json docs(repo): design a feature's screens in its spec and check them in a browser 2026-10-19 09:55:23 +00:00
tsconfig.base.json chore(repo): add typescript configs and project references 2026-04-25 16:19:10 +02:00
tsconfig.json feat(repo): scaffold apps/api with Hono + Node WS echo + Origin allowlist 2026-06-10 14:13:59 +02:00
tsconfig.lib.json chore(repo): add typescript configs and project references 2026-04-25 16:19:10 +02:00
turbo.json docs(repo): describe Forgejo as the forge in the README and the report comments 2026-10-22 11:43:45 +02:00

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

  1. Pick a green backup run (event is workflow_dispatch for one run by hand), open its page, and download its chess-db-backup artifact, a zip holding chess-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
    
  2. 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
    
  3. Stop production's app, so nothing writes while the data is replaced:

    ssh deploy@<vps-host> docker stop chess-app-1
    
  4. Restore into production's database. --clean drops each object the dump holds before recreating it, and --single-transaction leaves 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
    
  5. 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/nest implements 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, in RoomService; 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 /rpc to 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 in apps/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/ai package)
  • Database persistence
  • Accounts / auth
  • PGN import
  • Clocks / time control
  • Chat

Quality bar

  • Engine — Vitest unit tests + perft suite + (optional) fast-check property 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 and main; 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/contracts stays separate forever or folds into @chess/engine after 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/.