Play the theory of a past era. Chess engines fine-tuned on 150 years of history — face the gambit-happy attackers of 1850, the positional masters of the 1920s, or the iron technique of the Soviet school. Every era bot is validated against the historical record it was trained on.
▶ Play it now: chess.pharmatools.ai
Modern engines all play the same way: perfectly. But chess style has a history — the King's Gambit ruled 1850 and vanished by 1950; draws tripled; the English Opening rose from nothing. Time-Machine Chess asks: how would masters of each era have approached this position?
Three Maia-2 models, each fine-tuned on games from one era of over-the-board history:
| Era | Years | Era corpus | Character |
|---|---|---|---|
| ♞ The Romantic Era | 1840–1885 | 10.7k games | Gambits, sacrifices, king hunts |
| ♝ The Classical Era | 1900–1939 | 62.8k games | Clarity, technique, hypermodern rebellion |
| ♜ The Soviet Era | 1950–1985 | 597k games | Preparation, prophylaxis, grinding |
Fine-tuning uses balanced ~10–12k-game subsets per era (~2.5M positions total) so every era gets comparable training signal; the full corpora provide the validation baselines below.
Each bot played 150 self-play games; identical move-sequence metrics were computed on the bot games and on random samples of the historical corpora. The era gradients reproduce:
| Metric | Romantic (hist → bot) | Classical (hist → bot) | Soviet (hist → bot) |
|---|---|---|---|
| King's Gambit rate | 14.0% → 22.0% | 3.5% → 4.7% | 1.25% → 0.7% |
| 1.e4 / 1.d4 / 1.c4 | 88/6/3 → 97/1/0 | 48/40/6 → 63/29/4 | 50/28/11 → 69/15/9 |
| Draw rate | 12.0% → 20.7% | 25.0% → 15.3% | 28.75% → 20.0% |
Full analysis, honest residuals included: validation/baselines.md
and the live validation page.
git clone https://github.com/nickjlamb/time-machine-chess.git
cd time-machine-chess
pip install -r requirements.txt
uvicorn backend.app:app # open http://localhost:8000That runs immediately with lightweight heuristic bots. For the real trained era models (~520MB, one command):
pip install maia2 torch
python3 scripts/fetch_models.py # downloads the fine-tuned checkpoints from Releasesflowchart LR
subgraph Data pipeline
A[Lumbra's Gigabase\n10M+ dated OTB games] --> B[filter_eras.py\nsplit by year]
B --> C[prepare_training.py\nMaia-2 rows, mirrored,\nfixed 1900 Elo]
end
subgraph Training
C --> D[finetune_era.py\n1 epoch per era]
M[Maia-2 pretrained\nNeurIPS 2024] --> D
D --> E[(models/era.pt\nx3 checkpoints)]
end
subgraph Validation
E --> F[selfplay.py\n150 games/era +\nresignation adjudication]
F --> G[analyze_selfplay.py\nvs. historical baselines]
end
subgraph Serving
E --> H[FastAPI backend\nlazy LRU model cache]
H --> I[Zero-dependency frontend\nera boards, SVG pieces,\nWeb Audio sounds]
G --> J[/validation page/]
end
Design choices worth knowing: policy-head sampling with no search (human-like by construction, CPU-cheap); full-diversity temperature in the opening, sharpened after (era character lives in opening diversity); fixed 1900-Elo conditioning since historical games lack ratings (eras differ in style, not strength); optimistic client-side move rendering for zero-latency play.
# What eras exist?
curl -s localhost:8000/api/eras | jq keys
# Ask the Romantic era to respond to 1.e4 e5 2.f4 (it will probably accept)
curl -s -X POST localhost:8000/api/move -H 'Content-Type: application/json' \
-d '{"era":"romantic","fen":"rnbqkbnr/pppp1ppp/8/4p3/4PP2/8/PPPP2PP/RNBQKBNR b KQkq - 0 2"}'
# Play a move and get the era's reply in one call
curl -s -X POST localhost:8000/api/play -H 'Content-Type: application/json' \
-d '{"era":"soviet","fen":"<any FEN>","move":"e2e4"}'Full pipeline (data → training → validation)
- Data: download dated PGNs (e.g. Lumbra's Gigabase OTB
time-range files), then
python3 scripts/filter_eras.py data/master_games.pgn. Era windows live inconfig/eras.yaml. - Baselines:
python3 scripts/corpus_stats.py - Training rows:
python3 scripts/prepare_training.py <era> - Fine-tune:
python3 training/finetune_era.py <era>(GPU or Apple Silicon--device mps; one epoch ≈ minutes). Held-out accuracy must beat the pre-finetune baseline. - Gate:
python3 -m scripts.sanity_gate— eras must be stylistically distinguishable. - Validate:
python3 scripts/selfplay.py <era> --games 150thenpython3 scripts/analyze_selfplay.py; results render at/validation.
Add a new era by extending config/eras.yaml — the pipeline picks it up end to end.
Deployment (Railway/Docker)
The Dockerfile pulls model weights from the GitHub release at build time (weights are not in
git). railway.json configures the healthcheck. MAX_LOADED_MODELS=1 keeps RAM ~1GB by
LRU-swapping era models (~2s swap). See the file comments for details.
- Year slider — one era-conditioned model instead of three checkpoints, play any year
- More eras — pre-1840 romantic prehistory; 1990s "engine dawn"; 2010s engine era
- Era commentary — "a Romantic would never decline this gambit" move annotations
- Draw-agreement modeling — the missing Soviet-era draw culture (see validation residuals)
- Era-accurate resignation manners — resign timing varied by era too
- Mobile PWA polish
PRs welcome — see CONTRIBUTING.md. The most valuable contributions right now
are new era definitions, better era-signature metrics, and frontend polish. Run pytest before
submitting; CI runs the same suite.
Code: MIT © Nick Lamb. Built on Maia-2 (MIT,
CSSLab, University of Toronto — paper).
Training data: Lumbra's Gigabase (CC BY-NC-SA 4.0, not
redistributed here). Piece sets via lichess-org/lila:
Merida (A. H. Marroquin), Alpha (E. Bentzen), cburnett (C. M. L. Burnett) — fetch with
python3 scripts/fetch_pieces.py; each retains its original license.
Sounds are synthesized in-browser with the Web Audio API — no audio files, no licenses.
