open-chess-server 0.1.3

A Lichess-compatible chess server: the Board, Bot, Challenge and OAuth APIs on Postgres, with post-game fair-play screening
Documentation

open-chess server

A Rust chess server that speaks the Lichess API: the Board, Bot, Challenge and OAuth (PKCE) endpoints, the event, game and move streams, and game export, with Lichess's JSON. Anything written for Lichess talks to it by changing the base URL: a host site's Lichess backend, Lichess Board API apps, and lichess-bot.

It keeps everything in its own Postgres schema (chess): players, API tokens and sessions, challenges, games, moves and clocks, ratings and fair play. People make accounts on it (/signup, /login, /account for tokens), and a host site's players can sign in with the site (see "Plugging into a site" below). Sites and dashboards follow its games through a live feed (GET /api/feed, server-sent events).

Database rights. The role needs to create and write its schema. It may share a database with a host site, but reads nothing of the site's after a one-time import of the site's chess players (CHESS_SITE_SCHEMA).

Lichess client / bot / site ──▶ open-chess × N ──▶ Postgres
           host site ◀── /api/feed ──┘   ▲         chess.*   players, tokens, sessions, games,
                                         │                   challenges, ratings, fair play
                                         └─ LISTEN
fair-play worker × M (Stockfish) ───▶ the same database

Crates

The server is the top of a stack; the lower crates are what a bot (for this server or for lichess.org) reuses.

Crate What
chess-core Rules on cozy-chess (MIT): legal moves, SAN, standard-castling UCI, threefold / fifty-move / insufficient-material draws, Fischer clocks, Lichess status codes and speeds, PGN, Glicko-2. No I/O.
chess-proto Lichess's JSON as Rust types (events, challenges, users, exports), checked against Lichess's examples.
chess-client Async client for the Board, Bot and Challenge APIs, with NDJSON streams. Works against lichess.org or this server.
chess-engine Async UCI driver (Stockfish), behind an Engine trait.
chess-analysis The training app's engine, pure Rust and wasm-safe: alpha-beta search, win/draw/loss odds, move explanations, tactical motifs, openings, 3-piece tablebases, photo matching, NNUE evaluation. See docs/engine.md.
chess-wasm chess-analysis for the browser (the PWA in web/chess).
chess-fairplay Post-game screening: accuracy, ACPL, engine-match rate, move-time patterns, the training-app fingerprint, a 0–100 score.
chess-server This server (open-chess).

shakmaty, the usual Rust chess crate, is GPL-3.0, which deny.toml doesn't allow, so cozy-chess handles move generation and the rest is written here.

API

The endpoints (the Lichess API, live game streams, OAuth and scopes, and open-chess's own additions) are in docs/api.md; using the server with Lichess tools and bots is in docs/lichess.md.

Anti-cheat

Bots are allowed and openly marked (title: BOT). For people, these checks apply:

  • Separate APIs. Bot accounts can't play through the Board API, and a human account can only become a bot before it has played a game.

  • No live feed. The public move stream runs three plies behind live games. Game streams are for the two players only.

  • Post-game screening. When a game with a human player ends (20 plies or more), it's queued for the worker (open-chess analyse). Stockfish evaluates every position at multi-PV 3. Each human side then gets:

    • accuracy and ACPL, on Lichess's win-percentage scale;
    • how often their move was the engine's first, or top three, choice (opening, forced moves and decided positions are left out);
    • how even their move times were;
    • whether they spent longer on critical positions (people do);
    • the training-app fingerprint: in the positions where Stockfish and the training app's own engine disagree on the best move, which one the player chose. Honest players choose either or neither; someone copying the app's suggestions chooses the app's move almost every time.

    A screening model folds these into a 0–100 score and decides when a game is flagged (and when the fingerprint alone is enough). The model is data, chess_fairplay::Model as JSON, given in CHESS_FAIRPLAY_MODEL (a file path or the JSON itself). Keep a tuned model private: its numbers tell people what to avoid. Without one the worker uses an untuned example and says so in its log.

  • Human review, never automatic bans. Flagged games wait in /api/fairplay/queue. A reviewer's engine verdict marks the account: /api/user then shows tosViolation, and with CHESS_BLOCK_MARKED on the account can't start new games. A clean verdict lifts the mark. The host site decides what happens next: chess.accounts.marked_at and chess.fairplay_reports are there to read.

The score is a screening heuristic, not proof: strong players have accurate games. Tune the model on your own players before relying on it.

Assistance labels. The training app can switch its engine help on during games on this server (never on Lichess, where it's cheating). When it does, it says so: POST /api/board/game/{id}/assist with on=true or false goes into chess.assist_marks, and the game's reports carry declaredAssist. This is ground truth for training the screening model on known assisted and unassisted games; screening itself never relies on it, because a cheating client wouldn't send it.

Scaling

Every request and every clock decision goes through Postgres:

  • A move locks its game row, validates and applies the move with chess-core, charges the clock, writes, and sends pg_notify in one transaction.
  • The database clock (clock_timestamp()) is the only clock, so replicas never disagree about time used.
  • Each replica keeps one LISTEN connection (on Neon's direct host, derived from the pooled one) and passes changes to its own open streams. A move sent to one replica reaches a stream held by another.
  • A sweeper on every replica flags clocks, aborts unstarted games and lapses challenges. It claims rows with FOR UPDATE SKIP LOCKED, so replicas don't collide.

So the API scales by adding replicas (HPA on Kubernetes, CHESS_INSTANCES on Cloudflare), and fair play by adding workers. Each player may hold 8 event streams and 40 game streams per replica.

Configuration

Everything is environment (each variable is also a command-line flag: CHESS_PUBLIC_URL is --public-url; open-chess serve --help lists them).

Variable Default
DATABASE_URL — The server's database (its own, or shared with a host site in its own schema; pooled host fine)
CHESS_LISTEN_DATABASE_URL derived For LISTEN, if the direct host can't be derived from a -pooler host
CHESS_SCHEMA chess The server's schema
CHESS_SITE_SCHEMA esports A host site's schema in the same database whose chess players and their chess tokens are imported once, keeping their ids (skipped if it isn't there)
CHESS_SIGNUP true Anyone may make an account (/signup); off, only existing accounts and the host site's players sign in
CHESS_PUBLIC_URL http://localhost:8090 Where clients reach the API; a path in it (e.g. /chess) is where the API is mounted
ESPORTS_PUBLIC_URL http://localhost:8080 The host site, for "Sign in with the site"
ESPORTS_LOGIN false "Sign in with the site": its players sign in here through it, and its server asks for their tokens (POST /api/esports/token). Needs ESPORTS_SECRET
ESPORTS_SECRET — Shared with the host site (32+ characters): it signs sign-ins and token requests with it
CHESS_SITE_NAME open-chess What players know the site as: the consent page, PGN event names, GET / (the app shows it)
CHESS_BIND / CHESS_METRICS_BIND 0.0.0.0:8090 / 0.0.0.0:9465 API; /metrics, /healthz, /readyz
CHESS_POOL_SIZE 8
CHESS_SWEEP_MS 500 How often each replica's sweeper looks for flags, unstarted games and lapsed challenges
CHESS_APP_DIR — (the image sets /usr/share/open-chess/app) The Chess lab app's files, served at /app/
CHESS_APP_THEME — The app's look: CSS, or a .css file's path, served as /app/theme.css (docs/api.md)
CHESS_TABLEBASE_URL https://tablebase.lichess.ovh A Lichess-compatible tablebase server for /api/esl/tablebase; empty turns it off
CHESS_LAG_COMP_MS 100 Given back per move for the round trip
CHESS_CHALLENGE_TTL_SECS 20 Real-time challenges lapse without keepAliveStream
CHESS_FIRST_MOVE_SECS 30
CHESS_GONE_AFTER_SECS / CHESS_CLAIM_WIN_SECS 20 / 10 Opponent gone, then the win (or draw) can be claimed
CHESS_PUBLIC_DELAY_PLIES 3
CHESS_FAIRPLAY / CHESS_BLOCK_MARKED true / true
CHESS_STOCKFISH found Worker: engine path
CHESS_ANALYSIS_DEPTH / _THREADS / _HASH_MB 14 / 1 / 64 Worker
CHESS_FAIRPLAY_SKIP_PLIES 16 Worker: opening plies left out of screening
CHESS_FAIRPLAY_MODEL example model Worker: the screening model (JSON file path or inline JSON). Keep yours private
CHESS_FAIRPLAY_FLAG_SCORE / _MIN_MOVES from the model Worker: override the model's flag score and minimum moves
CHESS_FAIRPLAY_APP_DEPTH / _APP_NODES 6 / 400000 Worker: the training app's "Normal" search, for the fingerprint. Keep in step with web/chess/app.js
CHESS_FAIRPLAY_APP_NET — Worker: the NNUE file the app ships (web/chess/nets/default.nnue), if any
CHESS_VISION_ENDPOINT / _MODEL / _API_KEY — / qwen3-vl:8b / — Photo reading: an OpenAI-style /chat/completions vision model (Claude at https://api.anthropic.com/v1, or Ollama)
CHESS_VISION_LOCAL_MODEL — Our board reader (crates/chess-vision): a .safetensors file from chess-vision-train, run on this server's CPU before any vision model is asked
CHESS_VISION_MIN_CONFIDENCE 0.5 The reader's answer is used without review when no square is unsure and the chance all 64 are right is at least this
CHESS_VISION_REVIEW / _REVIEW_MAX true / 5000 Keep photos that weren't read for sure for the Review page (at most this many waiting); open-chess photos export --out DIR writes the confirmed ones as a training set
RUST_LOG info Log filter (debug, open_chess_server=debug …)

Running

open-chess serve             # the API (runs migrations first); also what `open-chess` alone does
open-chess analyse           # the fair-play worker (and, when idle, the dictionary's photo positions)
open-chess analyse --once    # screen what's waiting, then exit
open-chess migrate           # schema only
open-chess bot create --name stockfish-1800 [--days 365]   # prints the bot's token once
open-chess bot list
open-chess photos export --out DIR [--reviewed-only]      # confirmed photos, for chess-vision-train
open-chess healthcheck       # exit 0 if this machine's server answers /healthz (the image's HEALTHCHECK)

Get it from a release: the binary for Linux, or the image ghcr.io/offenedatenmodellierung/open-chess (built by the Dockerfile from the release's binaries): one image for both roles (Stockfish included), and :slim, distroless with the API and the app but no Stockfish. To deploy:

  • Kubernetes: deploy/k8s/chess.yaml (an example).
  • Cloudflare: deploy/cloudflare (Containers behind a Worker; an example).

Plugging into a site

The server has its own accounts; a host site plugs in as a client of it, with a secret they share (ESPORTS_SECRET, and ESPORTS_LOGIN=true):

  • Signing in. The sign-in page offers "Sign in with ": /auth/esports sends the browser to the site's /chess/sso?return=…&nonce=…, and the site sends it back to /auth/esports/back?a=… with a signed statement of who is signed in there: base64url(json) . base64url(HMAC-SHA256(secret, the first part)), the JSON {"sub": "<the site's player id>", "name", "email", "role", "roles", "nonce", "exp": <Unix seconds>}.
  • Tokens for its players. The site's server calls POST /api/esports/token with Authorization: Bearer <secret> and a form (sub, name, email, role, roles, scopes, days or hours, label); the answer is {"token", "username", "scopes", "expires"}. POST /api/esports/revoke (label, optionally sub) revokes the tokens of that name it asked for, e.g. a match's when it ends; GET /api/esports/players lists its players linked here ({"players": [{"sub", "username"}]}).
  • Either way the site's player is linked to a player here (made on first use, with a chess account named after them; the site's roles carry over, so its managers review fair play).
  • Following games. The site reads GET /api/feed (every game's start, moves and end, with rating changes; resumable), and the public APIs.

A deployment that used to share the site's players (this server's first versions read them from the site's schema) imports them once, with their ids and chess tokens, when this version first starts (CHESS_SITE_SCHEMA).

esports-lab, the (private) application this server was extracted from, is the reference host. Its native chess backend is a Lichess client pointed at this server: its watcher gets both players a short-lived token per game (board:play, challenge:read, challenge:write, named chess match {fixture}) from POST /api/esports/token, and revokes them when the game ends. Fixtures keep backend = 'native', the game id in external_id, and the competition state in the site's tables.

Players play on the site's own board page (#/play/{fixture}), which asks the site for its token (GET /api/me/chess/play/{fixture}) and then talks to this server's Board API directly from the browser ([chess] native_public_url). Any other Board API client works too.

Tests

cargo test -p open-chess-core -p open-chess-proto -p open-chess-client \
           -p open-chess-engine -p open-chess-fairplay -p open-chess-server
# End to end: two replicas on one Postgres, games, clocks, OAuth, bots, fair play
CHESS_TEST_DATABASE_URL=postgres://…?sslmode=disable cargo test -p open-chess-server --test e2e

CI runs the end-to-end suite against a Postgres service with Stockfish installed.