explorer / README.md
noobpwnftw's picture
Deploy ChessTB Explorer on Hugging Face Spaces
c03d895
|
Raw History Blame
59.1 kB
metadata
title: ChessTB Explorer
emoji: ♟️
colorFrom: green
colorTo: blue
sdk: docker
pinned: true
app_port: 7860
license: gpl-3.0
short_description: 'Interactive tablebase probing: WDL/DTZ/DTC/DTM/DTM50'

ChessTB Explorer ♟️

Interactive tablebase probing interface with WDL/DTZ/DTC/DTM/DTM50 metrics.

This Hugging Face Space is deployed from PunisherChess/ChessTB-Explorer-Render at commit 4969e401fa41ef1b86458cac4b6a31f9b11e08d0. The original authors' license and third-party notices are retained.

The application probes the public chesstb/chesstb bucket over HTTP byte ranges, using the explorer's existing bounded page cache. Sources are searched per table with raw generator output under full/ preferred over shipping tables at the bucket root. Castling materials use full/castling/ and castling/, respectively. Availability of each metric depends on the published tables for that material.

ADMIN_TOKEN and FLASK_SECRET_KEY are configured as Space secrets. The probing library follows the maintained add-chesstb-tablebases branch of noobpwnftw/python-chess.

Deploy to Render

A single-page web application for exploring ChessTB chess endgame tablebases. Paste or build any position on an interactive board and instantly see every legal move ranked by Distance to Zeroing (DTZ), Distance to Conversion (DTC), Distance to Mate (DTM), and DTM under the 50-move rule (DTM50) - straight from the tablebase, with no chess engine involved.

The backend is a small Flask application that probes the tablebase through a modified fork of python-chess (the chess.chesstb module - see Installation). The frontend is a React app built around Chessground (lichess.org's board component) and chessops for move generation/validation, supporting both Standard chess and Chess960 (Fischer Random).

This tool only displays tablebase results - it does not generate, verify, or ship any tablebase data itself. See Getting the tablebase files below.

The probe endpoints are unauthenticated by design - evaluating a position is this app's public functionality. The /admin cache dashboard is gated behind ADMIN_TOKEN, a secret configured by the deployment (see Security notes).


Features

  • Interactive board: drag-and-drop moves, click-to-place pieces from the spare-piece trays, pawn promotion dialog, board flip (orientation persisted locally across visits), undo/redo.
  • Chess960 (Fischer Random) support: a Chess960 Mode switch in the settings panel changes castling interpretation without altering the current board position: dragging a king onto its own rook castles the Chess960 way, and X-FEN/Shredder-FEN rook-file castling-rights letters (e.g. HAha) are accepted in the FEN box. Carried through the shared URL (&variant=chess960), the saved in-progress game, and PGN import/export ([Variant "Chess960"]). Off by default.
  • Lock: a padlock button beside the FEN box that restricts board interaction to legal moves only: dragging, click-to-move, and the spare-piece trays are limited to the side to move's legal destinations, and off-board drops are disabled. Unlocked by default; a manual toggle is remembered locally across visits. Auto-play also engages Lock for the duration of a run (unless already locked by hand), and disables the padlock button until the run stops.
  • Four ranked move tables side by side: DTZ, DTC, DTM, and DTM50 columns, each independently sorted best-move-first. Exact metric ties prefer immediate checkmate, then use SAN order with promotions along the same path ordered queen, rook, bishop, knight. A better-scoring underpromotion still ranks first. Includes:
    • score text colour-coded by outcome (win / cursed win / draw / blessed loss / loss)
    • a warning dot on any move flagged cursed win or blessed loss, with a hover tooltip explaining the 50-move-rule nuance; a DTZ score that may still be a ply off after the moves are cross-checked shows a second tooltip line
    • an info dot on a draw by insufficient material, keeping the score text itself reading as a plain "Draw"
    • dot messages on touch or pen input: tapping a dot shows its tooltip text as a notice and does not play the move
    • an outcome summary (wins / draws / losses / unknown) across legal moves, respecting the halfmove clock and 50-move rule. It uses DTM50 outcomes, or DTC when DTM50 is unavailable. Without either, clock-independent draws, cursed wins and blessed losses count as draws; unresolved wins/losses count as unknown.
    • an optional pinned Root Row above rank 1, showing the current position's own score for each metric as a reference point - toggled from the settings panel (Show Root Row, off by default) and persisted locally
  • Best-move arrows: the top DTZ, DTC, DTM, and DTM50 moves are drawn as colour-coded arrows directly on the board.
  • Auto-play: automatically plays the best move for any of the four metrics on a timer. The per-move delay is set from the settings panel (Autoplay Delay, 0s–2.5s in 50ms steps, 1.25s by default). The countdown starts after the current position's tablebase results are displayed, giving you the full delay to read them before the next move. A metric's button is disabled when its table isn't present for the current material, or when the position is already a draw.
  • PGN import/export: paste a PGN to load its full move tree, including variations, at the starting position. Step through it or click any move in the PGN panel, or download the current game as a PGN file.
  • CSV export of the current move table, including the Root Row line ahead of rank 1 when that setting is on.
  • Move-list / PGN panel with full undo/redo and click-to-jump - jumping to an earlier point in the line re-uses the cached probe instead of re-querying the tablebase.
  • Shareable positions: the FEN (and, in Chess960 Mode, the active variant) is written to the URL hash on every move, so a link to the page reproduces the exact position and ruleset.
  • Session persistence: the in-progress game (not just the position) survives a trip to the admin dashboard and back.
  • Board and piece set: Libre Brown board, CBurnett piece set.
  • Streaming probes: /probe/stream reports progress via Server-Sent Events while child positions are being probed, so the UI shows a live progress bar instead of a blank pause on slower lookups.
  • Hover pre-fetch: hovering a move in the table warms the cache for the resulting position before selection.
  • Admin cache dashboard (/admin, behind ADMIN_TOKEN) - live hit-rate stats for both LRU caches, thread-pool configuration, and a one-click cache-clear button.
  • Machine-readable API: the full HTTP API is described by an OpenAPI 3.0 document served at /openapi.yaml.
  • Keyboard shortcuts: Enter Apply, F Flip, C Clear, ←/→ Back/Forward. The Board Settings panel lists them on devices with hover.

Architecture at a glance

Browser (React app - see frontend/)
  ├─ engine/board.js      - chessops-backed position/rules state, chessground
  │                           wrapper (drag/drop, history, arrows), active variant
  ├─ engine/tablebase.js  - talks to /probe/stream, exposes results as state
  ├─ engine/theme.js      - board/piece-set theming
  ├─ hooks/useExplorer.js - FEN box, PGN, auto-play, settings, keyboard shortcuts
  └─ components/          - the rendered page (board, results table, PGN panel, dialogs)
        │  HTTP (JSON) / SSE (text/event-stream) - {fen, variant}
        ▼
Flask app (app.py)
  ├─ /probe, /probe/stream   - evaluate a FEN under a variant, rank every legal move
  ├─ /admin, /admin/cache/*  - cache dashboard + stats API
  └─ /openapi.yaml           - API specification
        │
        ▼
tablebase_router.py - searches TABLEBASE_PATH in order for every material,
including castling positions and recursive dependencies
        │
        ▼
chesstb.open_tablebase(...) per source   (noobpwnftw's modified python-chess fork)
        │
        ▼
ChessTB tablebase files - local disk, or a remote http(s) URL (probed
in place over byte ranges by default, or downloaded and cached to local
disk per material - see remote/remote_direct.py, remote/remote_fallback.py,
and "Getting the tablebase files" below)

The React app is built with Vite into static/dist/ (app.js, app.css), which the Flask template loads as same-origin assets, as the Content-Security-Policy (script-src 'self') requires. See Frontend build below.

This covers the main explorer page's (index.html) module graph - see Project structure below for the complete file set, including admin.js (the /admin dashboard's client script, a plain server-rendered page with no build step).

The Python modules and the frontend engine/ modules carry a file-level docstring/header comment describing their responsibilities, and the move-ranking algorithm itself - including its treatment of the 50-move rule - is documented inline in app.py above evaluate_all_moves() and the _effective_move_wdl/ _effective_distance/_order_rank helpers it calls.


Frontend build

The explorer page is a React app (frontend/) built with Vite into static/dist/, which the Flask app serves alongside its own static assets. The build output is checked in, so running the app needs no frontend server or build step. Rebuild after changing anything under frontend/src/:

cd frontend
npm ci
npm run build      # writes static/dist/app.js, app.css, admin.css, fonts/

frontend/src/engine/ and frontend/src/vendor/ hold the position/rules engine (chessops + Chessground) and the tablebase client, consumed by the React components in frontend/src/components/. Shared UI primitives (Icon, Button, Segmented, Panel) are in frontend/src/components/ui/. Piece SVGs and the piece-set stylesheet are in static/ and are linked directly from the page template.

Styling

Stylesheets are in frontend/src/styles/ and are bundled by Vite into static/dist/app.css (explorer page) and static/dist/admin.css (/admin). tokens.css defines the colour, type, spacing, radius, elevation, motion, and control-size custom properties; other files reference them instead of literal values. Files are imported into the reset, vendor, and app cascade layers by index.css and admin.css.

Token group Custom properties
Type scale --text-xs 12 px, --text-sm 13 px, --text-md 14 px, --text-lg 16 px, --text-xl 20 px
Spacing --space-1 to --space-6 (4 px to 32 px)
Controls --control-h, --control-h-sm (44 px on touch-primary devices)
Board --board-light, --board-dark, --board-highlight, read by engine/theme.js

Sizes are authored in rem, so the large-screen root font size scaling applies to all text and controls.

Page layout

Viewport Layout
Above 1180 px Three-column CSS grid (board, results, PGN). The results and PGN columns take the board column's height and scroll internally.
768 to 1180 px Board row above a results and PGN row; the page scrolls.
Below 768 px Three horizontally swipeable screens (board, results, PGN) with a bottom navigation bar (ScreenNav). Header and navigation heights include the device safe-area insets, and screen heights use dvh. The header shows the subtitle below the title, and the board is sized to the visible height so the whole board screen fits.

The side-to-move toggle and the board form one group (.board-group) as wide as the board, with a fixed small gap between them at every screen size. Free vertical space in the board column sits above the group.

The results panel is a results size container. At 49 rem wide and above it shows the four metric columns; below that it shows one metric at a time, selected with the metric tabs, regardless of viewport width. The desktop layout reserves 50 rem for this panel before growing the board, so the four-metric view can appear as soon as the three columns fit. The results table is a fixed header table above a scrolling body table, so the scrollbar starts at the first moves row. The body reserves no scrollbar gutter: while its rows overflow, its table is widened by the scrollbar's measured width, so columns keep the header's position and width whether or not a scrollbar shows. The PGN move-number column width is set by --pgn-rank-w in tokens.css.

Display scaling

Sizing is CSS-only and is defined in frontend/src/styles/layout.css.

  • Text: above 1180 px, the root font size of the explorer page (html.explorer) is 100% of the browser default up to a 1536 x 780 window and rises to at most 125% (20 px), following the window's width and height. Browser zoom and the browser default font size apply.
  • Board: above 1180 px, --board-size fills the window height in multiples of 8 px, from 20.5 rem up to --board-size-max (720 px, defined in tokens.css), and leaves at least 40 rem for the results table. The left column is as wide as the board, so it is capped at 720 px as well. The width the board column does not use goes to the results column, so the total content width stays constant.
  • Tablet layout: at 1180 px and below, the root font size is the browser default and --board-size is --board-size-cap, defined in tokens.css as min(720px, clamp(18.75rem, min(23.4375vw, 41.6667vh), 31.5rem)).
  • Phone layout: below 768 px, --board-size is the smaller of --board-size-cap (min(360px, 88vw) up to 480 px wide) and the height that remains after the header, bottom navigation, and the controls above the board, with a floor of 14 rem. The board screen therefore fits the visible height regardless of browser toolbar size. Below the floor, the screen scrolls from its top.
  • --board-fit-h includes the fixed height above the board in the left column and requires an update when that height changes. This applies to both the desktop definition in layout.css and the phone definition in responsive.css.
  • html.explorer is set in templates/index.html and frontend/index.html. /admin does not use it.

Prerequisites

  • Python 3.10+
  • Git, available on PATH - pip install -r requirements.txt fetches the python-chess fork directly from GitHub (chess @ git+https://...), which requires git to be installed even though the package itself is Python.
  • A ChessTB tablebase directory or URL: either a local directory on disk, or an http(s):// base URL serving the same layout remotely with no local download (see Getting the tablebase files). The app starts without one, but every probe will fail until TABLEBASE_PATH points at the required sources. See Configuration reference.
  • A modern browser (the frontend uses fetch, ReadableStream, and CSS container queries).

Installation

  1. Create and activate a virtual environment:

    python -m venv .venv
    source .venv/bin/activate      # Windows: .venv\Scripts\activate
    
  2. Install dependencies:

    pip install -r requirements.txt
    

    This pulls in the modified python-chess fork (not the plain PyPI chess package; see the comments in requirements.txt for why that distinction matters).

  3. Get the tablebase files: see the next section. When using the remote (no-download) option requires no fetch; continue to step 4 and point TABLEBASE_PATH at the URL.

  4. Configure config.py: open it and set TABLEBASE_PATH to the directory (or URL) from step 3:

    TABLEBASE_PATH = "/data/chesstb"
    

    or, for the remote option:

    TABLEBASE_PATH = "https://huggingface.co/buckets/chesstb/chesstb/resolve"
    

    This alone covers every piece count. See Getting the tablebase files for the ordered source list, which includes shipping tables for both ordinary and castling materials.

    Every other setting in config.py has a default and a comment explaining what it does - see Configuration reference for the complete list. Edit the values directly. To enable the header's source repository button, set SOURCE_REPOSITORY_URL there too.

  5. Run the app:

    python app.py
    

    By default (DEBUG = False in config.py) this serves the app via waitress, a production-grade pure-Python WSGI server - see Running in production below. Set DEBUG = True in config.py to use Flask's development server (auto-reload, interactive debugger) for local development.

  6. Open http://127.0.0.1:7860 in a browser.

    The built frontend (static/dist/) is checked in; see Frontend build when changing frontend/src/.


Getting the tablebase files

Where the tablebase data lives is a separate question from where this app runs - Option A below points TABLEBASE_PATH at a plain http(s):// URL, so it works identically regardless of which platform serves the app itself (Render, Hugging Face Spaces, a local machine, etc.); remote/remote_source.py speaks plain HTTP byte-range requests, nothing more, and has no dependency on any one host. See Deploying to Render for the app side of that distinction.

ChessTB tablebase files are available two ways:

Option A: Remote, no local download (recommended)

Tables are available in the ChessTB bucket. Shipping tables are grouped by metric; raw generator output is under full/. Both use castling/ for tables with castling rights. WDL/DTZ cover 3–8 pieces; DTC/DTM/DTM50 cover 3–6 pieces.

Set TABLEBASE_PATH to the bucket root:

TABLEBASE_PATH = "https://huggingface.co/buckets/chesstb/chesstb/resolve"

The explorer prefers raw generator output under full/, falls back to shipping tables, and selects castling/ paths automatically, including for recursive dependencies. This applies to Standard chess and Chess960. Local roots use the same layout. An explicit list of source directories is also supported.

REMOTE_MODE selects the remote access mode, which stays fixed for the life of the process:

  • "direct" (default): remote/remote_direct.py probes the remote tables in place, fetching only the REMOTE_PAGE_SIZE_BYTES-sized byte ranges each probe reads and keeping them in an in-memory LRU bounded by REMOTE_PAGE_CACHE_BYTES. Nothing is written to disk. This uses chess.chesstb's table-source seam (Tablebase.WDL_FILE / _TableFile._open_source), which the pinned fork (see requirements.txt) provides.
  • "download": remote/remote_fallback.py fetches each table in full the first time a probe touches its material and caches it in a temporary local directory (bounded by REMOTE_PAGE_CACHE_BYTES as an on-disk budget, evicting least-recently-used files, removed entirely when the app stops). Every probe after the first is then a local mmap read.

"direct" is the better default for browsing across many materials, which is what this app does. "download" is preferable when a long session repeatedly probes a small set of materials, or on a high-latency link: a ChessTB probe is not a narrow read - a dropped-frame table is reconstructed by walking its children, and pawn positions reach promotion sub-tables - so one cold probe can open several materials and issue several fetches against each. That is also why REMOTE_PAGE_SIZE_BYTES should stay large. See each module's docstring for the full design.

Both remote modes cache successful signed CDN redirects until 60 seconds before expiry, allowing subsequent byte-range reads to bypass the resolver. A failed cached URL is retried through the original URL to obtain a fresh redirect. Resolver and CDN connection pools stay open for reuse. Complete cached responses are returned immediately by /probe/stream, without rebuilding the board or queuing child probes. Queued child work is cancelled when a stream disconnects or its pre-warm deadline expires; already running probes can finish and warm the shared cache. Table contents and paths are assumed to remain fixed; existence and size metadata stay cached for the process lifetime, including missing files. Restart the app after adding or moving tables.

Remote access suits a machine without a couple of terabytes of local disk for the full tablebase set. Its costs are probe latency on first touch of any given material (depending on the connection to the CDN), temporary local disk usage in "download" mode, and the requirement of a network connection. Remote access uses the requests package (see requirements.txt).

Option B: Local directory, over FTP

The same tables are also distributed over FTP for a fully local, offline setup:

ftp://chessdb:chessdb@ftp.chessdb.cn/pub/chesstb/

Any FTP client works, for example:

# lftp
lftp -e "mirror --parallel=4 /pub/chesstb/ /data/chesstb; quit" \
     ftp://chessdb:chessdb@ftp.chessdb.cn

# curl (single file)
curl "ftp://chessdb:chessdb@ftp.chessdb.cn/pub/chesstb/<path-to-file>" -o <local-file>

Tablebase sets are large and grow quickly with piece count - check available disk space before mirroring the full archive, and consider mirroring only the required subsets (piece counts). Point TABLEBASE_PATH at the selected directory.


Regardless of how it is configured, if TABLEBASE_PATH is unset, missing/unreachable, or points at a location with no usable tables, the app still starts (with a warning in the logs) - every /probe request will then return an error, and /health reports "degraded".


Configuration reference

All configuration lives in config.py, as plain Python values. Open it and edit the settings directly - each one has a comment above it explaining what it does. The table below is the complete reference.

The defaults target a single process on Hugging Face CPU Basic (2 vCPU, 16 GB RAM). The decoded-block cache has a shared 512 MiB budget, and each of the four default remote sources has a 2 GiB page cache budget, totaling 8.5 GiB of byte-cache budgets. Caches fill on demand; this is not a cap on process memory. Root-response and child-probe caches are limited by entry count, with memory use depending on response size. The remaining RAM accommodates those results, table/index metadata, Python object overhead, and active requests. Revisit the budgets if you add remote sources or run multiple application processes.

Setting Default Description
TABLEBASE_PATH Public bucket root (also readable from a TABLEBASE_PATH env var) Directory containing the ChessTB tablebase files, or an http(s):// base URL serving the same layout remotely (e.g. a Hugging Face storage bucket - see Getting the tablebase files). Required for probing to work. A list of directories and URLs, tried left-to-right (a hybrid source), is accepted as a literal in config.py only. On Render, set this from the service's Environment tab (or render.yaml's prompt) instead of editing the file.
DEBUG False True runs app.py via Flask's own dev server (auto-reload, detailed tracebacks) instead of waitress. Leave False - which serves via waitress - for anything reachable from another machine. See Running in production.
HOST "0.0.0.0" Interface the server (waitress, or the dev server if DEBUG = True) binds to.
PORT 7860 (also readable from a PORT env var) Port the server listens on. Render injects its own PORT at deploy time and this picks it up automatically - nothing to set for a Render deployment.
WAITRESS_THREADS 4 Worker threads in waitress's request-handling pool. Only relevant when DEBUG = False. Needs to be more than 1 so a long-lived /probe/stream connection can't block other requests.
PROBE_THREADS 2 Worker threads in the probe thread pool for evaluating a position's legal moves in parallel. Set an explicit integer to match the host's CPU count, or None to scale automatically (min(16, cpu*2)).
PROBE_PARALLEL_THRESHOLD 4 Minimum number of child positions before probing switches from sequential to the thread pool.
PROBE_TIMEOUT_SECS 30 Wall-clock timeout for each probe phase. /probe gives the root probe and the child-move batch independent budgets, so worst-case /probe latency can approach 2 × PROBE_TIMEOUT_SECS. Also bounds how long a request waits on another thread's in-flight probe of the same FEN before giving up with a retryable probe_timeout error.
EVALUATE_CACHE_SIZE 16384 Max entries in the root-FEN result cache (full JSON responses).
PROBE_CACHE_SIZE 262144 Max entries in the child-position probe cache (raw chess.chesstb.ProbeResult objects - WDL/DTZ/DTC/DTM/DTM50 together).
BLOCK_CACHE_BYTES 536870912 (512 MiB) Size, in bytes, of chesstb's own internal cache of decoded/decompressed tablebase blocks (shared across the WDL/DTZ/DTC/DTM/DTM50 tables). Raising this trades RAM for fewer repeated disk reads/HTTP fetches + decompressions across a session - most worthwhile when PROBE_PARALLEL_THRESHOLD is set high enough that probing runs mostly serially.
REMOTE_MODE "direct" Remote tablebase entries only. "direct" probes the remote tables in place over byte ranges (nothing written to disk); "download" fetches each table in full on first touch and caches it on local disk. See Getting the tablebase files.
REMOTE_PAGE_CACHE_BYTES 2147483648 (2 GiB) Remote tablebase entries only. Soft budget, in bytes, per remote source in TABLEBASE_PATH, shared across that source's tables: the in-memory page cache in "direct" mode, the on-disk cache of whole downloaded files in "download" mode. See Getting the tablebase files.
REMOTE_PAGE_SIZE_BYTES 262144 (256 KiB) Remote tablebase entries only. Size, in bytes, of one page. In "direct" mode this is the granularity of every fetch, so it trades over-fetching against round trips per probe; in "download" mode it is only the chunk size used while streaming a full file down.
REMOTE_TIMEOUT_SECS 20 Remote tablebase entries only. Per-HTTP-request timeout for existence/size checks and the download itself.
REMOTE_MAX_RETRIES 3 Remote tablebase entries only. Attempts for a single remote request before it's treated as failed.
REMOTE_POOL_MAXSIZE None Remote tablebase entries only. Size of each remote backend's HTTP connection pool. None computes max(PROBE_THREADS * 2, 20).
PROBE_RATE_LIMIT "60 per minute" Per-client-IP request limit on /probe and /probe/stream, using flask-limiter's string syntax. Left empty, rate limiting is disabled entirely. See Security notes.
ADMIN_LOGIN_RATE_LIMIT "5 per minute" Per-client-IP request limit on /admin/login, same string syntax as PROBE_RATE_LIMIT, applied independently of it. Covers both failed and successful login attempts. Left empty, login attempts are not rate limited. See Security notes.
PREFETCH_RATE_LIMIT "30 per minute" Per-client-IP request limit for hover prefetch requests marked with X-Prefetch: 1. Left empty, prefetch requests share PROBE_RATE_LIMIT. See Security notes.
TRUSTED_PROXY_COUNT 1 (also readable from a TRUSTED_PROXY_COUNT env var) Number of trusted reverse-proxy hops in front of this app. In production this configures waitress's own trusted_proxy_count (waitress parses X-Forwarded-For/X-Forwarded-Proto and corrects REMOTE_ADDR/wsgi.url_scheme itself, ahead of Flask); Werkzeug's ProxyFix makes the same correction for the DEBUG = True dev-server path, which doesn't run under waitress. Both rate limiters key on the corrected address, and request.scheme/request.is_secure read the corrected scheme. 1 matches this project's own render.yaml topology (Render's edge/load-balancer, once). Set it to match the deployment's reverse-proxy chain, or set it to 0 for a deployment with no reverse proxy in front of it at all. See Security notes.
ADMIN_TOKEN "" (from ADMIN_TOKEN env var) Shared secret required to reach /admin and /admin/cache/*. Read from the environment rather than hardcoded, so it can be set as a platform secret. Left unset, admin routes respond 503 rather than running unprotected. See Security notes.
FLASK_SECRET_KEY "" (from FLASK_SECRET_KEY env var) Key used to sign the admin session cookie. Read from the environment for the same reason as ADMIN_TOKEN. Left unset, a random key is generated at process startup instead, so every restart requires logging in again.
SESSION_COOKIE_SECURE False (also readable from a SESSION_COOKIE_SECURE env var, "true"/anything else) Whether the admin session cookie set by /admin/login requires HTTPS. Independent of DEBUG. Defaults False to match a plain-HTTP local installation; set to "true" for any deployment the browser reaches over HTTPS (including behind a TLS-terminating reverse proxy such as Render's - render.yaml already sets this explicitly for that deployment). See Security notes.
SOURCE_REPOSITORY_URL "" (also readable from a SOURCE_REPOSITORY_URL env var) Repository URL for the source repository button shown in the header of every page. Edited directly like any other non-secret value here, or set as an env var on a given deployment. Left empty, the button is omitted.

Invalid values (wrong type, out of range) cause the app to log an error and exit at startup rather than run with a silently-wrong configuration.


Running in production

python app.py serves the app via waitress, a production-grade, pure-Python WSGI server, whenever DEBUG in config.py is False (the default) - no separate gunicorn/waitress command or extra process is needed on top of the app itself. Setting DEBUG = True in config.py switches app.py over to Flask's own dev server instead (auto-reload + interactive debugger), which is meant for local development only and should not be used for anything reachable from another machine.

# Production (default): DEBUG = False in config.py: serves via waitress
python app.py

# Local development: set DEBUG = True in config.py first, then run the
# same command: Flask dev server, auto-reload + debugger
python app.py
  • HOST / PORT control the interface and port waitress binds to, same as for the dev server. PORT is also readable from a PORT environment variable (see Configuration reference), which is what lets the same image bind correctly on Render - or any other platform that assigns the port at deploy time - with no edits.
  • WAITRESS_THREADS (default 4) sizes waitress's own pool of request-handling threads. This needs to be more than 1 for the same reason the dev server needs threaded=True: /probe/stream holds a connection open via Server-Sent Events for the whole duration of a probe, and a single-threaded server would let that one connection block every other request - including the browser's own concurrent requests for CSS/JS/piece images on first page load. It's independent of PROBE_THREADS, which sizes the thread pool used internally to parallelise tablebase probing rather than to serve HTTP requests.
  • TRUSTED_PROXY_COUNT (default 1) configures waitress's own trusted_proxy_count, so request.remote_addr and request.scheme reflect the real client IP and scheme behind Render's edge/load-balancer rather than that proxy's own address/hop - see Security notes.
  • Waitress handles /probe/stream's streamed response natively; no additional configuration is needed for SSE to work correctly.
  • The probe endpoints are unauthenticated by design; only /admin and /admin/cache/* require a credential (see Security notes).

Deploying to Render

This project deploys to Render as a single Docker web service, built straight from this GitHub repository. Render watches the connected branch and rebuilds/redeploys automatically on every push.

There are two ways to set it up:

Option 1: Blueprint (render.yaml, recommended)

This repository ships a render.yaml at its root describing the service (Docker runtime, health check, environment variables). Render finds it automatically:

  1. Push this repository to GitHub.
  2. In the Render Dashboard, click New → Blueprint and connect the repo.
  3. Render reads render.yaml and requests the one setting required before the first deploy can run: TABLEBASE_PATH - the http(s):// base URL of the remote tablebase (see Getting the tablebase files). ADMIN_TOKEN and FLASK_SECRET_KEY are generated automatically.
  4. Click Apply / Create. Render builds the image from this repo's Dockerfile and deploys it.

Every subsequent push to the connected branch redeploys automatically. To change TABLEBASE_PATH, ADMIN_TOKEN, or any other setting later, edit it from the service's Environment tab in the Render Dashboard; see Configuration reference for which settings are env-overridable this way versus which live only in config.py.

Option 2: Manual Web Service (no render.yaml)

The service can also be configured manually without the Blueprint file:

  1. Push this repository to GitHub.
  2. In the Render Dashboard, click New → Web Service and connect the repo.
  3. Set Runtime to Docker (Render should detect the Dockerfile at the repo root automatically).
  4. Under Environment, add:
    • TABLEBASE_PATH: required; same as Option 1 above.
    • ADMIN_TOKEN: optional but recommended; any secret string, or use Render's "Generate" button. While it is unset, /admin responds 503.
    • FLASK_SECRET_KEY: optional; when omitted, a random key is generated at process startup and admin sessions don't survive a restart.
    • SESSION_COOKIE_SECURE: set to true. Render always terminates TLS at its edge, so the browser reaches this deployment over HTTPS even though the app itself just speaks plain HTTP to Render's proxy; config.py's own default (false) is tuned for a local plain-HTTP installation instead. (Option 1's render.yaml sets this automatically - it is a manual step here because Option 2 skips that file.)
    • SOURCE_REPOSITORY_URL: optional; the repository URL, which enables the header's source repository button.
    • Leave PORT alone - Render sets it itself, and config.py already reads it (see Configuration reference).
  5. Under Health Check Path, set /health (matches the route in app.py; Render won't mark a deploy healthy until this returns 200, which itself only happens once the sources in TABLEBASE_PATH have opened successfully).
  6. Click Create Web Service.

Choosing a plan

Probing is CPU-bound (block decompression on every request), so a dedicated vCPU - Render's Starter tier or above - gives lower probe latency than the Free tier's shared/limited CPU. Free works for casual or low-traffic use; the service spins down after inactivity (a slow first request after waking up) and probes are slower under load. Either way, REMOTE_MODE = "direct" (the default) means no persistent disk is needed regardless of plan - see config.py's "Remote (URL) tablebase entries only" section and Getting the tablebase files.


Usage guide

  • Set a position: type or paste a FEN into the FEN box and press Enter or Apply. The move rankings and best-move arrows update automatically. The castling-availability field accepts - or any combination of K, Q, k, q (each requiring a matching king and rook on their home squares); in Chess960 Mode it additionally accepts X-FEN/Shredder-FEN rook-file letters (a-h / A-H). A position with a castling right is evaluated the same as any other, reported as not covered if the loaded tablebase set has no table for it (see TABLEBASE_PATH in Configuration reference).
  • Chess960 (Fischer Random): toggle Chess960 Mode in the settings panel to switch castling interpretation. This does not move any pieces or randomize a new starting position - it only changes how castling moves/rights on the current board are interpreted, and drops the current move line the same way a board edit does (a line built under one variant isn't reinterpreted as the other). With it on, drag a king onto its own rook to castle; the destination square and resulting rook position follow standard Chess960 rules regardless of where the king and rook started. The setting is carried in the shared URL (&variant=chess960), the saved in-progress game, and PGN import/export (a [Variant "Chess960"] header).
  • Edit the board directly: drag pieces around the board, drag a piece off the board to remove it, or click a spare piece in either tray and then click a square to place it (click again / press Esc to cancel). Board edits reset the current move line the same way Clear does.
  • Lock the board: click the padlock button beside the FEN box to restrict drag-and-drop, click-to-move, and the spare-piece trays to legal moves for the side to move; the button turns gold while active. Click again to unlock. The choice is remembered locally across visits. Starting auto-play locks the board automatically for the run (unless already locked) and disables the padlock button until it stops.
  • Play moves: click any move in one of the four ranked tables to play it, or drag a piece on the board through a legal move.
  • Navigate history: the Undo/Redo arrow buttons or ←/→, or click any move in the PGN panel to jump straight to that point in the line.
  • Auto-play: click the ▶ button above the DTZ, DTC, DTM, or DTM50 columns to have the app play that metric's best move on a timer (delay set by Autoplay Delay in settings, 1.25s by default); click again (the button shows ■ while running) to stop. Any manual navigation stops auto-play.
  • Import/export a game: Import in the PGN panel opens a dialog to paste PGN text. Import Game loads the full game and its variations into the PGN panel, starting before the first move; Export downloads the full game and its variations as chesstb_game.pgn with standard headers.
  • Export the move table: the CSV button downloads the current DTZ/DTC/DTM/DTM50 rankings as a CSV file.
  • Share a position: the URL updates live with #fen=...; sending that link reproduces the exact position.
  • Change settings: the ⚙ button in the header opens a Show Root Row switch for the results table, a Chess960 Mode switch, and an Autoplay Delay slider; the choices are persisted across visits.
  • Admin dashboard: the gauge icon in the header opens /admin, prompting for the ADMIN_TOKEN on first visit, then showing live hit-rate stats for both caches and the thread-pool configuration, auto-refreshing every 5 seconds.

API

The full HTTP API - /probe, /probe/stream, /health, /admin/cache/stats, /admin/cache/clear, /admin/login, /admin/logout - is documented as an OpenAPI 3.0 specification served live by the running app at:

http://127.0.0.1:7860/openapi.yaml

openapi.yaml documents every endpoint's request/response shapes; the ranking algorithm behind moves_dtz / moves_dtc / moves_dtm / moves_dtm50 is documented inline in app.py's own module docstring and its evaluate_all_moves() function. /admin/cache/* require an Authorization: Bearer <ADMIN_TOKEN> header or an authenticated session cookie from /admin/login - see Security notes.

Quick example:

curl -X POST http://127.0.0.1:7860/probe \
     -H "Content-Type: application/json" \
     -d '{"fen": "4k3/8/8/8/8/8/8/4K2R w - - 0 1"}'

variant is optional and defaults to "standard" when omitted. It is never inferred from the FEN itself - the orthodox starting array is itself a legal Chess960 starting position, so the same FEN text is ambiguous without it:

curl -X POST http://127.0.0.1:7860/probe \
     -H "Content-Type: application/json" \
     -d '{"fen": "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", "variant": "chess960"}'

Project structure

.
├── LICENSE                 # GPL-3.0-or-later license text for this project
├── THIRD_PARTY_LICENSES.md # Licenses and attributions for bundled third-party code, fonts, and the piece set
├── Dockerfile               # Container build used for Render (and any other Docker host)
├── render.yaml              # Render Blueprint: service definition, health check, env vars
├── app.py                  # Flask backend: routes, probing, caching
├── config.py               # All configuration, as plain Python values - see "Configuration reference"
├── openapi.yaml            # OpenAPI 3.0 specification, served at /openapi.yaml
├── requirements.txt
├── README.md
├── .gitattributes, .gitignore, .dockerignore
├── tablebase_router.py     # Ordered local/remote source lookup for every material
├── tablebase_opening.py    # Per-material table-opening lock policy, shared by tablebase_router.py and both remote backends
├── dtz_anchor.py           # Resolves rounded DTZ values by cross-checking the moves of a position
├── remote/
│   ├── remote_source.py     # Generic HTTP byte-range client shared by both remote backends - see its own module docstring
│   ├── remote_direct.py     # REMOTE_MODE="direct": probe remote tables in place over byte ranges - see its own module docstring
│   └── remote_fallback.py   # REMOTE_MODE="download": whole-file download, cached to local disk on first touch - see its own module docstring
├── templates/
│   ├── index.html          # Main explorer UI - mounts the React app (see frontend/)
│   └── admin.html          # Cache dashboard
├── frontend/                # React app for the explorer page - see "Frontend build"
│   ├── package.json, package-lock.json
│   ├── vite.config.js       # Builds into ../static/dist with fixed filenames (no cache-busting hash)
│   ├── index.html, admin.html  # Build entries (script and stylesheet)
│   └── src/
│       ├── main.jsx, App.jsx
│       ├── engine/          # Position/rules engine and tablebase client (chessops + Chessground)
│       ├── vendor/          # Vendored Chessground (script and base stylesheet) + chessops, bundled by the build
│       ├── hooks/useExplorer.js  # FEN box, PGN, auto-play, settings, keyboard shortcuts
│       ├── components/      # Board, results table, PGN panel, dialogs, settings panel
│       │   └── ui/          # Icon, Button, Segmented, Panel
│       ├── styles/          # Tokens, base, layout, component and chess stylesheets - see "Styling"
│       └── assets/fonts/    # Inter and JetBrains Mono (woff2)
└── static/
    ├── dist/                # Built frontend (see "Frontend build"): app.js, app.css, admin.css, fonts/
    ├── css/
    │   └── pieces-cburnett.css   # Piece-set stylesheet linked by templates/index.html
    ├── js/
    │   └── admin.js          # Admin dashboard client
    └── pieces/cburnett/     # Piece-set SVGs

Security notes

  • The /probe and /probe/stream endpoints are intentionally unauthenticated - evaluating a chess position is this app's public, core functionality. /admin and /admin/cache/* are gated behind ADMIN_TOKEN (see below); every other route requires no credential.
  • Rate limiting on /probe and /probe/stream is controlled by PROBE_RATE_LIMIT, and on /admin/login separately by ADMIN_LOGIN_RATE_LIMIT (both in config.py's "Rate limiting" section), each applied per client IP via flask-limiter. A client over either limit gets a 429 with a JSON error body. PROBE_RATE_LIMIT's default, "60 per minute", covers normal browsing and autoplay at its default delay - raise it if legitimate autoplay at a fast delay setting gets throttled, lower it if the deployment sees abuse, or set either limit to "" to disable it entirely. ADMIN_LOGIN_RATE_LIMIT defaults to "5 per minute", tight enough to slow down repeated ADMIN_TOKEN guesses. Both limiters key on request.remote_addr as corrected by TRUSTED_PROXY_COUNT (see below) and their state lives in-process (memory://), which is sufficient for a single-container deployment (e.g. one Render Web Service instance) but isn't shared across multiple replicas.
  • TRUSTED_PROXY_COUNT (config.py's "Rate limiting" section) is what request.remote_addr, and therefore both rate limiters above, is corrected against, so it reads the real client IP from X-Forwarded-For rather than the address of the reverse proxy in front of this app. The same setting also governs whether X-Forwarded-Proto is trusted, correcting request.scheme / request.is_secure (and any externally-generated URL) when TLS terminates at that reverse proxy rather than at this app itself. In production (DEBUG = False) this configures waitress's own trusted_proxy_count: waitress parses both headers and corrects REMOTE_ADDR/wsgi.url_scheme itself, before Flask ever sees the request, so it takes priority here. Werkzeug's ProxyFix makes the equivalent correction for the DEBUG = True Flask-dev-server path, which doesn't run under waitress at all. Defaults to 1, matching this project's own render.yaml topology; set it to match the deployment if that differs, or to 0 for a deployment with no reverse proxy in front of it at all.
  • Admin access is controlled by ADMIN_TOKEN (config.py's "Admin" section), read from the environment rather than hardcoded - set it as a platform secret (e.g. a Render environment variable, generated automatically by render.yaml's generateValue: true) rather than committing it. Visiting /admin with no active session shows a login form; a correct token there starts a signed, HttpOnly, SameSite=Lax session cookie (Secure too, where configured - see SESSION_COOKIE_SECURE below). API clients can instead send Authorization: Bearer <ADMIN_TOKEN> directly, no session needed. Token comparison uses hmac.compare_digest (constant-time, resistant to timing attacks). Leaving ADMIN_TOKEN unset does not open the admin panel to everyone - every admin route responds 503 instead.
  • SESSION_COOKIE_SECURE (config.py's "Admin" section) controls whether the admin session cookie above requires HTTPS, independently of DEBUG - DEBUG is a development/runtime behavior switch, not a reliable indicator of the deployment's transport security, so DEBUG = False alone does not imply HTTPS is in front of this app. Read from a SESSION_COOKIE_SECURE env var ("true" or anything else, case-insensitive), defaulting False to match this project's own plain-HTTP local installation - a browser won't send a Secure cookie back over a plain HTTP connection, so /admin/login would otherwise appear to succeed while every subsequent authenticated /admin/* request came back 401. Set to "true" for any deployment the browser reaches over HTTPS (including behind a TLS-terminating reverse proxy - the browser's own connection is what the Secure attribute governs, not the internal hop between the proxy and this process); render.yaml already sets this explicitly for the Render deployment it configures, since Render always terminates TLS at its edge.
  • The app serves via waitress, a production-grade WSGI server, by default (DEBUG = False in config.py) - see Running in production. Only set DEBUG = True (which switches to Flask's own dev server) for local development, never for anything reachable from another machine.
  • A Content-Security-Policy is set on every response: scripts and fonts are restricted to 'self' (no inline scripts, no third-party sources); styles allow 'self' plus 'unsafe-inline' (needed for inline style attributes set by the React app and in admin.html); images allow 'self' plus data:; connect-src/default-src are 'self' too, so fetch/SSE calls can only reach this same origin. object-src, base-uri, and form-action are locked to 'none'/'self'/'self', and frame-ancestors 'self' https://huggingface.co allows the Hugging Face Space page to embed the app. X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin are set too. Request bodies are capped at 4 KB.
  • config.py itself holds no secrets - ADMIN_TOKEN and FLASK_SECRET_KEY are the only two secrets, and they are read only from the environment so the file stays safe to commit as-is. Every other setting is a non-sensitive value.

Troubleshooting

Symptom Likely cause
Every probe returns "Position not covered by the loaded tablebase." Check TABLEBASE_PATH and the startup logs. Set the tablebase root; full/ and castling/ directories are resolved automatically. For an explicit source list, include every required directory.
A FEN is rejected with "Invalid castling availability" The submitted castling-availability field is something other than - or a combination of K, Q, k, q (or, in Chess960 Mode, rook-file letters a-h/A-H), or names a castling right without a matching king and rook on the board.
A FEN with a-h/A-H castling-rights letters is rejected even though the position looks fine Those letters are only accepted with Chess960 Mode on (settings panel, or variant: "chess960" on the API) - Standard mode only accepts K, Q, k, q, matching Standard chess's fixed castling-rook squares.
Dragging a king onto its own rook doesn't castle Chess960 Mode is off - enable it in the settings panel (or send variant: "chess960" on the API) for Chess960-style king-onto-rook castling; in Standard mode a king can only reach its rook's square by regular king movement.
/health returns 503 degraded The tablebase failed to open - check TABLEBASE_PATH and file permissions.
ModuleNotFoundError: No module named 'chess.chesstb' Plain PyPI chess was installed instead of the fork. Re-run pip install -r requirements.txt and confirm it pulled chess from noobpwnftw/python-chess (the add-chesstb-tablebases branch) rather than plain PyPI chess - see the note at the top of requirements.txt.
ModuleNotFoundError: No module named 'config' config.py is missing from alongside app.py. It ships with the repository - if it was deleted or moved, restore it from the repo (or re-clone) and re-apply any edits, such as TABLEBASE_PATH.
App logs Configuration error: ... and exits immediately A value in config.py is the wrong type or out of range for that setting (e.g. a string where an integer is expected, or a negative cache size) - the error message names which setting and why. Fix it in config.py and re-run python app.py.
Admin dashboard panels stuck on "Loading…" Check the browser console for a CSP violation and verify that the app is running and reachable at the /admin URL.
A drag, drop, or spare-piece placement on the board is silently rejected The board is Locked - the padlock button beside the FEN box is highlighted gold. Lock restricts board interaction to legal moves and disables the spare-piece trays and off-board drops; click the padlock again to unlock and edit freely.
Probing feels slow on positions with many legal moves, or doesn't scale with PROBE_THREADS Confirm lz4 is installed (pip show lz4, or python -c "import lz4") - without it, chess.chesstb's block decompression runs in pure Python and holds the GIL, so PROBE_THREADS can't achieve real parallelism. pip install lz4 (already in requirements.txt) and restart. Otherwise, tune PROBE_THREADS, PROBE_PARALLEL_THRESHOLD, and PROBE_TIMEOUT_SECS in config.py. Note that tablebase_router.py guards each material's first open with a lock keyed by (kind, material), so two threads racing to open the same never-before-seen material collapse onto one read, while distinct materials of the same kind open concurrently. If any configured tier uses REMOTE_MODE="download", opens fall back to one lock shared per kind, so several distinct never-before-seen materials of that kind then open one at a time instead - either way, this only affects the very first probe against any given material.
ModuleNotFoundError: No module named 'waitress' The active environment's dependencies are out of date - re-run pip install -r requirements.txt to pick up waitress. app.py imports it unconditionally at the top of the file, before config.DEBUG is even read, so setting DEBUG = True does not avoid this - waitress has to be installed either way.
Every probe on a remote TABLEBASE_PATH returns "Position not covered..." or /health is degraded Confirm the URL is reachable and correct (try opening TABLEBASE_PATH/wdl/KQK.lzw - or any small material's .lzw - directly in a browser). Check the startup log's "TABLEBASE_PATH opened remotely at: ..." line (or a warning in its place) for the actual failure.
A probe occasionally returns 503 with error_code: "probe_timeout" Another concurrent request was already probing the same position and didn't finish within PROBE_TIMEOUT_SECS. This is transient and retryable - the original probe has usually landed in the child-probe cache before a retry, so the retry is inexpensive. Frequent occurrences suggest raising PROBE_TIMEOUT_SECS, or that a slow remote TABLEBASE_PATH is the underlying bottleneck.
Remote probing feels slow, or repeated probes against the same material keep hitting the network Check REMOTE_TIMEOUT_SECS/REMOTE_MAX_RETRIES aren't causing retries on a slow link, and consider raising REMOTE_PAGE_CACHE_BYTES - a too-small budget evicts cached pages ("direct") or downloaded files ("download") before later probes can reuse them. GET /admin/cache/stats surfaces this per tier as `tablebase_cache.<low
On Render, the deploy never goes live / stays stuck "deploying" Render's health check (healthCheckPath: /health in render.yaml, or the equivalent field on a manually-created service - see Deploying to Render) only passes once /health returns 200, which itself requires the sources in TABLEBASE_PATH to have opened successfully. Check the service's Logs tab for the same "opened remotely at: ..." / warning line described above.
On Render, the service responds but the page never loads / connection refused Typically a PORT mismatch. Confirm config.py reads PORT from the environment (see Configuration reference) and that nothing else in a custom start command overrides HOST/PORT.

Credits

License

ChessTB Explorer is licensed under GPL-3.0-or-later; see LICENSE. Bundled third-party components and their licenses are listed in THIRD_PARTY_LICENSES.md.