YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
GPCT β Generative Pretrained Chess Transformer
A static-site harness for testing a chess AI that plays by predicting the next token of a chess game. The AI isn't here yet β a mock model stands in behind the exact interface the real one will use. No build step, no dependencies, hostable on GitHub Pages as-is.
Run it
node tools/serve.mjs # -> http://localhost:4173
(any static server works; ES modules just can't load from file://)
GitHub Pages: push, then Settings β Pages β Deploy from a branch β
main / root. The site is fully static.
How a computer move happens
- Legal moves come from chess.js and are tokenized into the scheme below.
- The model is asked for a next-token distribution over the whole vocabulary.
- Mask: every token that cannot continue a legal move sequence is set to probability zero. (On the first token of a move, the three nerf tokens are also allowed.)
- Renormalize, then sample.
- If a nerf token was drawn, the whole move is re-decoded with
qualityset β the model is now conditioned on "this move is an inaccuracy / mistake / blunder". Nerf tokens are masked out on the retry. - Steps 2β4 repeat per token until a core move token completes the
sequence; the matching SAN is played. The move gets a
?!/?/??glyph in the move list if it was nerfed.
The Model output panel in the UI shows the sampled chain and the top of the masked distribution at every step of the last computer move.
Tokenization
A move is a sequence of modifier tokens followed by one core move token β the core token is what ends a move:
[#|+]? [x]? [=Q|=R|=B|=N]? [core]
fxe8=Q+ -> [+] [x] [=Q] [fe8]
Nxf3 -> [x] [Nf3]
Raxe1# -> [#] [x] [Rae1]
e4 -> [e4]
O-O -> [O-O]
Cores keep SAN's disambiguation so nothing is lossy: fe8 is a pawn capture
core (source file + destination), piece cores may carry file / rank / square
disambiguators (Nbd7, R1e2, Qh4e1). sanToTokens() / tokensToSan()
in js/vocab.js convert both ways.
The vocabulary is 5,273 tokens:
| group | count |
|---|---|
| core moves (pawn 176, N/B/R/Q/K 5,064, castling 2, generated geometrically) | 5,242 |
modifiers + # x =Q =R =B =N |
7 |
nerf <inaccuracy> <mistake> <blunder> |
3 |
structural <bos> <eos> + 13 <elo-*> buckets + <elo-any> (vocab v2) |
16 |
game-end <white/black-resign> <white/black-flag> <draw> (vocab v3 β the engine resigns / offers you a draw when it samples its own action; flags are training-signal only) |
5 |
Core generation is a deterministic, slightly loose superset of real SAN
(every core is geometrically possible on an empty board; impossible ones are
just permanently masked). tools/vocab-check.mjs plays random games and
asserts every legal move tokenizes into the vocabulary and round-trips:
node tools/vocab-check.mjs 500
Plugging in the real AI
A model is any object with an async predict(ctx) returning either a
Float32Array / number[] of length VOCAB_SIZE (index i = probability
of TOKENS[i]) or a plain {token: probability} map. It is called once
per token:
{
fen, // current position
moves, // SAN history: ['e4', 'c5', ...]
historyTokens, // same history as a flat token stream, nerf tokens included
moveTokens, // tokens emitted so far for the move being decoded
turn, // 'w' | 'b' β the side the model plays
opponent, // { rating: 1500 | null, site: 'lichess' | null }
legalMoves, // SAN list, provided for convenience β free to ignore
quality, // null, or 'inaccuracy'|'mistake'|'blunder' after a nerf
vocab, // the token list
}
Outputs don't need to be pre-masked or normalized β the engine masks illegal tokens to zero and renormalizes whatever you return.
Swap the model by replacing js/mock-model.js (imported in js/app.js), or at runtime β useful while weights load asynchronously:
window.chessGpt.setModel({ name: 'the real one', async predict(ctx) { ... } });
Other seams: window.chessGpt.loadFen(fen) jumps to a position,
window.chessGpt.config exposes ENGINE_CONFIG (sampling temperature,
disable nerf tokens, think-delay).
Files
index.html page shell
style.css minimalist theme
js/vocab.js token vocabulary + sanToTokens / tokensToSan <- swap for your tokenizer
js/engine.js mask -> renormalize -> sample decode loop <- the AI's socket
js/mock-model.js placeholder model <- replace with the real AI
js/app.js game flow, UI wiring
js/board.js SVG board rendering + input
js/pieces.js geometric piece shapes
lib/chess.js vendored chess.js 1.4.0 (rules, legality, SAN)
tools/serve.mjs dev server: node tools/serve.mjs
tools/vocab-check.mjs vocabulary/round-trip validation
Training the real model
TRAINING.md is the plan for building the training set from evaluated Lichess games (eval-derived nerf labels, this vocabulary, nanoGPT packing).
Notes
- The mock scores moves with a shallow greedy heuristic, answers each
predict() call with exact next-token marginals of that distribution, and
emits nerf tokens more often the lower the opponent's rating (
900 lichessblunders a lot; leave rating empty for a middling default). The rating site is plumbed through but unused by the mock β a real model might embed it, since ratings aren't comparable across sites. - Sampling is plain categorical over the masked distribution
(
ENGINE_CONFIG.temperature, default 1.0). - If the model puts zero mass on every allowed token, the engine falls back to uniform over legal continuations and logs a console warning.