A functional chess library: positions are values, moves are pure transitions, a game is a fold.
use caissa::Position;
let position = ["e2e4", "e7e5", "g1f3"]
.into_iter()
.try_fold(Position::default(), Position::play)?;Or in operator notation (-> is not overloadable in Rust; >> is the
arrow-shaped operator that is):
use caissa::notation::*;
let position = (Position::default() + (e2 >> e4) + (e7 >> e5) + (g1 >> f3))?;An action carries no more than the player's intent — a from-square and a
to-square — because the position already knows everything else: what
stands on the square, whether the target is a capture. The one exception
chess forces is promotion, which gets its own action variant (h7h8q,
or h7 >> h8 >> queen).
Position is a plain Copy value, so persistence is free: every
transition yields a new position and the old one stays valid. History,
search trees, undo, and variations are all "keep the old value".
Errors are data. One Rejected enum covers everything from unparseable
input to IntoCheck { king } — a single rule ("the resulting position may
not leave your king attacked") that covers pins, moving into check, and
ignoring check.
Each layer adds one concern, built from the one below:
| Layer | Concern | Highlights |
|---|---|---|
reduce |
legality | the pure transition Position × Action → Result<Position, Rejected> |
Game |
memory | memoized fold (history), jump notation game[n], game[Terminus - 1], Mode::{Playing, Played} |
Timeline<T> |
chronology | timestamps as data, realtime replay via frames(), Snodgrass interval (ended() == None while playing) |
Clocked<T, D> |
obligation | per-player budgets, spent derived as a fold, claim_flag — an unclaimed flag keeps the game playing |
The timestamp type is generic: tests use plain integers, a real recorder
uses Instant or DateTime<Utc>. The core never reads a clock — time
enters as data, and the only effect in a speedchess replay is the sleep
between frames:
for frame in timeline.frames() {
std::thread::sleep(frame.think_time); // exactly as long as the player thought
render(frame.position);
}Position implements Display:
8 ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜
7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟
6 · · · · · · · ·
5 · · · · · · · ·
4 · · · · ♙ · · ·
3 · · · · · · · ·
2 ♙ ♙ ♙ ♙ · ♙ ♙ ♙
1 ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖
a b c d e f g h
Castling needs no notation of its own: it is the king's two-square move
(e1g1 / e1c1, UCI-style) — a king can never legally travel two squares
any other way, so intent stays from–to. O-O is import-time sugar. En
passant is likewise just the diagonal pawn move onto the skipped square.
Both cost Position its first memory — castling rights and the en-passant
square ride along in the value, exactly the fields FEN has always carried.
SAN is resolved, not interpreted: "Nf3" parses into a description, and
resolution filters the legal actions down to the unique match — so SAN
inherits every rule the reducer enforces, disambiguation included. PGN
import is then just the fold the crate is built on:
let game = caissa::import(OPERA_GAME_PGN)?;
assert_that!(
game.mode(),
eq(Mode::Played(Ending::Checkmate { winner: Color::White }))
);
println!("{}", game[Terminus]);game.apply("e4"), game.apply("Nbd2"), and game.apply("O-O") all
work directly; UCI and SAN mix freely at every call site.
And games write themselves back out, newspaper-style — game.score()
(also its Display) emits minimally-disambiguated SAN with check and
mate suffixes supplied by the reducer, and game.figurines() gives the
publication figurine form:
1. e4 e5 2. Nf3 d6 … 16. Qb8+ Nxb8 17. Rd8# 1-0
1. e4 e5 2. ♘f3 d6 … 16. ♕b8+ ♞xb8 17. ♖d8# 1-0
Emission is resolution's inverse, and the tests hold it to that:
import(game.score()) reproduces the game.
Chess is now rules-complete: every way a game can legally move or end is in the reducer and the layers above it.
The project's vocabulary and direction live in docs/vision.md — the load-bearing terms (prefix sharing, the interpreter, derived-never-stored, domain equivalence) and the open horizons: playing, learning, and game encodings that might make learning more powerful. Per-feature decision records sit beside it in docs/, and the works it draws on are collected in docs/references.md.
- Pure reducer — from–to actions, one error type, errors as data —
v0.1.0 - Full piece movement, captures, promotion —
v0.1.0 - Check, checkmate, stalemate — one rule: never leave your king attacked —
v0.1.0 - Castling (the king's two-square move) and en passant —
v0.1.0 -
Mode::{Playing, Played}— O(1) game-over gating, no move checking after the end —v0.1.0 - Memoized history with jump notation:
game[n],game[Terminus - 1]—v0.1.0 - Timestamped
Timeline, realtime replay, Snodgrass interval (ended() == Nonewhile playing) —v0.1.0 -
Clockedspeedchess: per-player budgets, derived clocks, flag claims —v0.1.0 - Operator notation:
e2 >> e4,position + action—v0.1.0 - Board
Display(Unicode, rank 8 up) —v0.1.0 - The interpreter: actions expand from prototypes into
Edits, applied by a total evaluator —v0.2.0 - SAN resolved against
legal_actions(); UCI and SAN mix freely —v0.3.0 - PGN import — the Opera Game and the Immortal Game fold to mate —
v0.3.0 - Score emission, letters and figurines;
import(game.score())round-trips —v0.4.0 -
classicsmodule — famous games and openings as ready-made fixtures, plusPiece::white/Piece::black—v0.5.0 - Draw rules — insufficient material on the board; repetition and move-counts derived from the history; threefold/fifty claimed (
claim_draw), fivefold/seventy-five automatic —v0.6.0 - FEN import/export — counters derived from the log, never stored —
v0.7.0 - Full PGN export with tag pairs — the Result negotiated between board and tag, export∘import round-trips —
v0.7.0 - Variations — the
studymodule: graft lines, prefix sharing as construction, read back as lines —v0.8.0 - Self-play — the
playmodule: headlessbetween, seededRandomplayer, games are reproducible values —v0.9.0 - Multi-game PGN import —
pgn::games/pgn::import_all, the corpus loader for dictionary, annotator, and repertoire —v0.11.0 - The annotator —
review::annotate: the shift series locates a game's turning point ("move 3, White's g4??") — the coach's first rung —v0.12.0 - PGN variation
(...)import/export - A thinking player —
Minimaxnegamax over amaterialevaluator; depth substitutes for accuracy —v0.10.0 - MCTS and a stronger evaluator (mobility, structure); the engine fast path it will need
- Engine fast path: ungated movegen for search workloads
MIT OR Apache-2.0