chss.chat

chss.chat/research

Compression research · draft

How small can a chess share link get?

chss.chat shares a chess board as a link, with no account or database on the far side. You make a move, paste the URL into WhatsApp, and your friend sees the same game from the other side. That leaves one question: how short can the link become while still carrying the game with it?

Isn't a chess position just 64 squares?

That is the obvious starting point: 64 squares, each empty or occupied by one of six piece types in one of two colours. The picture looks complete.

Empty squares and every piece type, once per colour.

Except the picture is not quite enough to resume the same game. Some rules depend on what happened before this moment, and that information is not painted on the squares.

The board has invisible state

Put the pieces in the same places and two boards can still behave differently. Castling and en passant are the clearest examples: the boards replay what happened just before, or what becomes legal next. Side to move and the two move counters are harder to show visually, so those are named in text below.

  • Castling rights

    White king steps to g1

    King and rook still on their home squares

    Both histories reach the same arrangement, with the king and rook on their original squares. The difference is the invisible journey that came before: one history moved the king and permanently removed castling rights; the other never did.
  • En passant

    White pawn on d5; black pawn on e6

    White pawn on d5; black pawn on e7

    Both boards end with a white pawn on d5 and a black pawn on e5. On the left, Black just played the normal one-square move e6–e5. On the right, Black jumped from e7 to e5, so White may capture on e6 for this move only.
  • Side to move

    Whose turn it is. The same arrangement can belong to either player, so a snapshot has to say who moves next.

  • Halfmove clock

    Counts each player's turn since the last pawn move or capture. A player can claim a draw when the count reaches 100 under the fifty-move rule, and the game is automatically drawn at 150 under the seventy-five-move rule unless the final move is checkmate. The pieces alone do not reveal this count.

  • Fullmove number

    Numbers the turns in a written game. It starts at 1 and increases after each Black move, matching the move numbers used in scoresheets and chess notation. The rules do not use this number to decide whether a move is legal.

Should we store the board, or the moves?

Watch how the two common representations evolve during a standard game, such as Morphy's Opera Game.

Board → text snapshot / move list

32 pieces

Board text (FEN, Forsyth–Edwards Notation)56 chars

rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1

Move list (UCI, Universal Chess Interface protocol)

(start · empty path)

start

The first string is FEN, the standard readable snapshot used by chess libraries. It records the pieces, whose turn it is, castling and en passant rights, and two move counters. Those extra fields are how the text preserves a game that the 64 squares cannot describe alone. Once every slash, space, and digit has to live in a URL, though, a FEN share averages about 104 characters in the held-out benchmark.

FEN includes those fields for different reasons. Side to move, castling rights, and en passant can change which move is legal next. The halfmove clock carries the fifty-move rule, while the fullmove number preserves move numbering. Together they make one snapshot sufficient to restore the standard state used by chess software. Later, we will ask whether a more specialised encoding can derive any of that state instead of spelling it all out.

The second string is the UCI move path: coordinate moves such as e2e4e7e5…. Replaying it from the starting board recovers the same state without writing each field separately. It begins wonderfully short, then grows with every move and preserves more history than a board preview needs.

That early advantage is why paths remain in the comparison. For an opening or a short game, the move list can be smaller than a packed board. Later, its unbounded growth becomes the problem. This gives us two useful ideas to compress: the state we have now, or the route that brought us here.

Packing the grid into bits

The most literal binary approach gives every square a four-bit cell for empty or a coloured piece type. That fixed grid costs 256 bits. The tested codec adds the same 27-bit meta block used by occupancy: side to move, castling, en passant, halfmove clock, and fullmove number. Its logical payload is therefore always 283 bits before the URL alphabet. It never adapts to how empty the board becomes.

Most of the board is air, though, and that is where Occupancy + Pieces wins. A 64-bit mask marks which squares are filled; each occupied square then gets a four-bit piece. Empty costs one bit in the mask instead of four in the grid. The same FEN-complete meta rides at the end. Size now tracks piece count rather than ply count, so middlegames and endgames stay near the same URL length while path codecs keep growing.

Generic compression does not save the day

gzip works best when it has enough material to find repeated patterns and enough payload to repay its own header. A chess share string gives it very little of either.

Move lists do contain structure, but each individual share is too short for generic gzip to exploit efficiently. The header alone can outweigh the opening moves, and turning the compressed bytes into a URL-safe alphabet expands them again. Two coordinate moves become a longer link than the raw move string. gzip(FEN) produces the longest mean URL in this benchmark. It stays as a control so "just gzip it" has a measured answer: on these strings, gzip makes the pasteable URL longer.

Fewer bits can still make a longer link

A codec has at least three lengths. Logical bits are the information written before padding. Payload characters are what survive a URL-safe alphabet, or raw ASCII for a plain move list. Full URL length includes the origin, route, and payload: the thing someone actually pastes into WhatsApp.

Those three do not move in lockstep. A bitstream is first rounded to complete bytes. Unpadded Base64URL usually needs about four characters for every three bytes, with visible jumps at byte boundaries. Saving one or two logical bits may therefore save no characters at all.

The alphabet matters too. Ordinary Base64 uses + and /, which are awkward in URL paths. Characters outside a conservative URL-safe alphabet may be percent-encoded, normalised, or treated differently across URL implementations. Packed rows use Base64URL (-/_) so the payload stays path-safe.

We score a complete share URL so the comparison stays concrete. The fixed route is scaffolding, not the research question. The same trade-offs apply to path segments, query params, QR codes, and other apps.

If the score is characters rather than bits, a dense Unicode symbol can look like a cheat. The demo below shows why the displayed glyph, its code point, its encoded bytes, and what survives in a URL path can all disagree.

Code points · U+4E2D
24 bits3 UTF-8 bytes0xE4 · 0xB8 · 0xAD
Serialised URL characters for this input in a path context: %E4%B8%AD. Browsers may still display the glyph; the serialised form is what travels in the URL.

One displayed glyph can occupy several bytes and many serialised URL characters. The alphabet is part of the codec, and every symbol comes with rules.

What can the decoder infer?

Occupancy still writes a full FEN-complete meta block. Before seeing how that compares on the scoreboard, ask how much of that meta the decoder could recover from the pieces alone. A shorter state encoding can exploit what the current arrangement makes impossible. It cannot treat hidden state as if the pieces uniquely determine it. The paired boards near the beginning show exactly where that assumption fails.

  • Castling rights are partly constrained. If a king or rook is absent from its home square, that castling right is certainly gone. If both have returned home, the board still cannot reveal whether they moved earlier. The encoder can omit impossible rights, but must distinguish the remaining cases.
  • En passant is usually impossible, but not always inferable. Pawn geometry and side to move sharply limit when a target square could exist. When the geometry permits one, identical pieces can still result from a one-square or two-square pawn move. The relevant detail of the previous move remains genuinely hidden.
  • Side to move is generally independent. Some arrangements constrain which side could legally move, but many are valid with either player to move. A general snapshot therefore needs to preserve the distinction.
  • Neither move counter has an exact answer on the board. The same pieces can appear after different numbers of quiet plies and at different fullmove numbers. Common values can receive shorter codes, but recovering the original values requires carrying equivalent information somewhere in the state payload.

The useful saving is conditional: avoid bits when the board proves a value, and encode the ambiguity when it does not. Simply deleting a field produces a different state rather than a better compression of the same one. The first scoreboard therefore compares codecs that preserve the chosen target: a restorable FEN snapshot, including legal-move state and both counters. Occupancy carries its full meta block in that comparison.

At ply 2, who should win?

Before the numbers: at two plies, should replaying e2e4e7e5 beat carrying an entire board? Should occupancy already beat native FEN once empty squares cost one bit instead of four? And can gzip repay its header on a string this short?

The benchmark samples positions from real Lichess games and measures the complete URL, not just the payload. Make a prediction, then read the means.

Checkpoint means from a random Lichess hash sample (270,021 games, 1,169,301 observations at plies 2, 8, 16, 32, 64). Not full-game averages—chss.chat is DB-less, and a complete Lichess month is too large to score; a smaller draw reran to check stability before scaling N. Full URL includes https://chss.chat/p/.
MethodBitsCharsURL
Raw / native
Native FEN (Base64URL)

Keeps the whole printable board text so the decoder can reload any mid-game position.

Bits
Chars
URL
Native UCI (ASCII)

Lists every ply from the start as from-to squares, readable as typed.

Bits
Chars
URL
Packed binary → Base64URL
Packed UCI path

Twelve bits per ply from the start, then padded into URL-safe characters.

Bits
Chars
URL
Occupancy + pieces

Marks filled squares once, then names what sits on each, plus the clocks.

Bits
Chars
URL
Naïve 4-bit grid

Assigns a fixed nibble to every square, empty or not, so length never changes.

Bits
Chars
URL
gzip → Base64URL
gzip(UCI) → Base64URL

Runs a general compressor on the move string, then expands bytes for the URL.

Bits
Chars
URL
gzip(FEN) → Base64URL

Runs a general compressor on the board string, then expands bytes for the URL.

Bits
Chars
URL

Overlay reads mean ± σ [min–max]. Tall band = observed min–max; mid band = ±σ; coloured bar = mean.

Occupancy lands around 57 URL characters as a standalone mean, against roughly 104 for native FEN. The fixed square-by-square encoding sits between those ideas and never adapts: every position costs the same. Packed paths win early and lose later. gzip loses throughout. Among the standalone codecs, occupancy has the shortest overall mean because it stores the current state without growing with the move count.

Inference can trim some meta later. The next leap is different: move shared opening knowledge into the decoder so familiar prefixes need not be spelled out at all.

What if the decoder already knows common openings?

Human openings repeat. Instead of spelling out a familiar first dozen moves, the URL can point to an opening the decoder already knows, then append only the unfamiliar suffix. The information still restores a full path, and therefore a full FEN state; the savings come from moving shared knowledge into the software.

Think of the codebook as a row of small lookup tables, one for each opening depth. Here, depth counts plies, or individual turns by one player. Depth 2 means White and Black have each moved once; depth 12 means six full moves have been played. A depth-2 table might contain e2e4e7e5, while a depth-8 table can name a much longer branch of the same opening.

The letter K sets how many of the most frequent prefixes each table may keep. The benchmark uses up to K = 1024 entries at each of depths 2, 4, 6, 8, 10, 12. Since 2¹⁰ = 1024, a 10-bit index can select any entry in one table. The depth ID selects the table. Together, those two numbers tell the decoder which known sequence to replay.

The encoder uses the longest prefix it finds, then packs any moves that came after it as a suffix. A dictionary miss stores the complete packed path instead. One discriminator bit tells the decoder which form it received. No network fetch is required, but the codebook must ship with the decoder and remain frozen or versioned, or old links break.

Familiar lines such as e2e4e7e5, the Sicilian e2e4c7c5, and longer Open Sicilian stretches land in the book. Expand the lookup method card below for three concrete codebook rows.

How far could a dictionary go?

The opening book suggests an extreme version of the same idea. If the decoder contained every possible position, the URL could be little more than an index. Common positions could receive the shortest indices, while rare positions would receive longer ones. That is the theoretical direction in which a frequency-aware codec points.

The decoder would be doing most of the work. Tromp and Österlund estimate about 4.8 × 1044 legal chess positions. Even if real games visit only a tiny fraction of them, a complete catalogue is far beyond anything a web client could sensibly ship.

Our corpus shows where the smaller version stops paying. At depth 2, a 1,024-entry book covers essentially every observed opening prefix. By depth 8, more than 600,000 prefixes appear and the same book covers about a quarter of them by frequency. At depth 12, over 1.5 million appear and coverage falls below 5%. The first few moves repeat; middlegames quickly fan out.

A full position database is therefore a useful limit, not a useful product. The practical design keeps the small, high-value opening book and falls back to packed moves or occupancy when a game leaves it. That combination is the hybrid in the next section.

Where does history stop winning?

Paths start cheap and grow with every move. Board-state encodings stay relatively stable and may shrink after captures. Dictionaries help when openings are predictable. A hybrid that tries packed path, occupancy, and lookup, then keeps the smallest, rides the cheap side of that curve.

Before the loop and the table: at ply 2, should lookup beat occupancy by a wide margin? By ply 32, should that answer reverse? Where do you expect the hybrid to sit relative to both?

Morphy's Opera Game from the starting position through mate, with URL length for each ply. Held-out sample: 270,021 games · 1,169,301 positions at plies 2, 8, 16, 32, 64 · codebook from hash-train.

Board → URL length

start32 pieces

Packed UCI path0 chars

Occupancy + pieces38 chars

AAD_____AABCNWMkEREREZmZmZnKveus-AAAIA

Lookup K=1024 + suffix2 chars

AA

Hybrid best of 3 path2 chars

Measured only at sampled checkpoints, the same pattern appears in the table. Early columns favour paths and lookup. Later columns favour occupancy. Hybrid hugs the cheaper explanation. Greener cells are shorter within each column.

Mean URL length at plies 2, 8, 16, 32, 64. Four codecs that show the crossover once dictionaries and hybrid selection are allowed.
MethodPly 2Ply 8Ply 16Ply 32Ply 64
26385486150
6059585549
25314678142
2531465550

At ply 2, hybrid and lookup sit near 25 characters while occupancy still carries a full board near 60. By ply 32, occupancy and hybrid converge near 55, and packed paths have ballooned past 80. Opening history is cheap because it is short or familiar. Later, occupancy wins because the board no longer cares how many moves it took to get there.

The hybrid keeps the cheapest explanation

Across sampled checkpoint positions, hybrid lands around 40 URL characters versus roughly 104 for native FEN. Equal-weighting games instead of checkpoint observations changes the hybrid mean only slightly, from about 40 to 39.5 characters.

That is not proof of the shortest chess encoding possible. It is the best result among the practical methods tested here. The durable lesson is not one winning representation. The winning system chooses the cheapest truthful description for each case: a short path while history is cheap, a known opening when the codebook hits, and a board snapshot once the journey costs more than the destination.

What to try next

  • Smarter paths. Rank each move among legal moves rather than storing source and destination squares.
  • Compressed move sequences. Measure whether UCI or PGN becomes more competitive once wrapped in Zstandard or Brotli as well as gzip. Raw gzip already loses on short share strings; a fuller bake-off would still keep the comparison honest.
  • Smarter snapshots. Use variable-length piece coding or rank legal board configurations. Flat position ranking may improve bounded or worst-case size; it does not automatically minimise frequency-weighted average length on real shares.
  • Smarter alphabets. Write directly into URL-safe six-bit symbols instead of padding through bytes.
  • Prior art and variants. Compare specialised position encodings from the literature, plus Chess960 and crazyhouse. The numbers would move. The same URL-safety and character-count constraints would still apply.

The open question is not whether a shorter link exists in principle. It is which of these mechanisms still pays once every share must remain pasteable, self-contained, and truthful.

A compact map of the bets

Each row is a bet about what to store. Expand a method for the mechanism and a real example link. Examples use 1. e4 e5 (e2e4e7e5) unless noted.

MethodKindMean full URLDepends onKeeps
State104None beyond chess.jsComplete FEN state
Path100Replay from startFull path from start
Variant98None beyond chess.jsPlayable fields only (no clocks)
Path61Replay from startFull path from start
State57None beyond chess.jsComplete FEN state
State70None beyond chess.jsComplete FEN state
Path120gzip + replayFull path from start
State124gzip + chess.jsComplete FEN state
Frequency55Frozen codebookFull path from start
Hybrid40Codebook if lookup winsPath or complete FEN state
Native FEN (Base64URL)104 mean URL chars

Stores the full FEN string, then Base64URL-encodes it for a path-safe payload. The decoder Base64URL-decodes and loads the position with chess.js.

Use when you already have a mid-game FEN and no move path, or when interoperability with standard FEN matters. Every slash, space, field, and clock digit still has to pass through the URL alphabet.

Board for 1. e4 e5
1. e4 e5
https://chss.chat/p/f-cm5icWtibnIvcHBwcDFwcHAvOC80cDMvNFAzLzgvUFBQUDFQUFAvUk5CUUtCTlIgdyBLUWtxIC0gMCAy

Full FEN → Base64URL. About 102 characters for the share URL on this opening.

Native UCI (ASCII)100 mean URL chars

Appends raw UCI move text with no further encoding. e2e4e7e5 is already URL-safe ASCII, so the payload is the move string itself. The decoder replays from the start position.

Included for the early-game edge only: openings and short games can be surprisingly compact. Grows by four characters per ply (five on promotions), stores full history, and is not the long-term share format.

Board for 1. e4 e5
1. e4 e5
https://chss.chat/p/u-e2e4e7e5

30 characters for the full URL. By ply 16, the same format averages 86 characters.

Board for Italian, 6 plies
Italian, 6 plies
https://chss.chat/p/u-e2e4e7e5g1f3b8c6f1c4g8f6

Still readable. Length tracks the path, not board complexity.

Trimmed FEN (playable-only variant)98 mean URL chars

Base64URL of FEN with the halfmove clock and fullmove number dropped. The result can restore a playable board, but not the original counters on its own.

Useful as a lower bound on those two fields. Without a recovery rule, it describes less state than full FEN. It is also still text-shaped, so it never approaches the packed rows.

Board for 1. e4 e5
1. e4 e5
https://chss.chat/p/t-cm5icWtibnIvcHBwcDFwcHAvOC80cDMvNFAzLzgvUFBQUDFQUFAvUk5CUUtCTlIgdyBLUWtxIC0

Restores the board and immediate move rights, but not the original counters.

Packed UCI path61 mean URL chars

A path again, but each move is 12 bits (from-square + to-square) plus 2 bits when a promotion appears. Because the decoder replays the board, it knows when a move must include a promotion choice. The bit stream is then Base64URL-encoded.

Wins while games are short. By ply 16, the packed path is already roughly level with occupancy on average. Hybrid exists largely to catch that crossover; packed UCI is not a serious late-game candidate on its own.

Board for 1. e4 e5
1. e4 e5
https://chss.chat/p/p-Mc0k

24 bits → four Base64URL characters. Full URL length 26.

Board for Italian, 6 plies
Italian, 6 plies
https://chss.chat/p/p-Mc0kGV5qFa-t

72 bits → 12 characters of payload.

Occupancy + pieces57 mean URL chars

A 64-bit occupancy mask (which squares are filled), then a 4-bit piece nibble per occupied square, then FEN-complete meta: side to move, castling, en passant, halfmove clock, and fullmove number. No move list. Size tracks piece count, not ply count.

Among the standalone codecs, occupancy has the shortest overall mean on this held-out validation scoreboard. Middlegames and endgames stay near the same URL length while path codecs keep growing. Weak only in the opening, where a short path or dictionary entry is smaller.

Board for 1. e4 e5
1. e4 e5
https://chss.chat/p/o-EADv___vABBCNWMkEREREZmZmZnKveus-AAAQA

219 bits with 32 pieces plus clocks → 60 URL characters. By ply 64, occupancy shortens as captures remove pieces.

Naïve 4-bit grid70 mean URL chars

Every square gets a fixed 4-bit cell (empty or coloured piece), plus the same 27-bit FEN-complete meta used by the benchmark. The total is always 283 logical bits before Base64URL.

A useful baseline for “store the grid literally.” Occupancy beats it because empty squares cost one bit in the mask instead of four in the grid.

Board for 1. e4 e5
1. e4 e5
https://chss.chat/p/n-QjVjJBERAREAAAAAAAAQAAAAkAAAAAAAmZkJmcq966z4AABA

Fixed 70-character URL on every position. Simple, never adaptive.

gzip(UCI) → Base64URL120 mean URL chars

gzip the raw UCI ASCII string at maximum compression, then Base64URL the bytes. Same idea people reach for when a payload “should compress.”

Almost always loses on share URLs. The gzip header alone is larger than a short opening path, and Base64URL expands the compressed bytes again. Even on long paths, generic gzip remains far behind the chess-aware packed representation in this benchmark.

Board for 1. e4 e5
1. e4 e5
https://chss.chat/p/g-H4sIAAAAAAAC_0s1SjVJNU81BQCH6qitCAAAAA

Two coordinate moves become a 60-character URL. The raw move string was 30.

gzip(FEN) → Base64URL124 mean URL chars

gzip the full FEN text, then Base64URL. Same compressor tax as gzip(UCI), applied to a string that is already short and low-redundancy.

gzip(FEN) produces the longest mean URL in this benchmark. Kept as a control so “just gzip it” has a measured answer.

Lookup K=1024 + suffix55 mean URL chars

Human openings repeat. A hit replaces familiar opening plies with a depth id and a 10-bit index, then appends any packed suffix. A miss stores the complete packed path. Every lookup payload starts with one discriminator bit (1 = hit, 0 = miss), counted in both logical length and the Base64URL payload. The benchmark book keeps a separate list of up to K = 1024 prefixes at each of depths 2, 4, 6, 8, 10, and 12 on the hash-train split; the index is fixed at 10 bits because each depth has at most 1,024 entries. Decode needs no network or remote fetch, but the decoder must ship the same frozen codebook; version it or old links break.

Shines on openings people actually play. On the held-out validation split it lands near occupancy in the overall mean, but wins hard at plies 2 and 8 in the sampled checkpoints, where a short path is already small and occupancy still carries a full board.

Codebook sample: a few frequent prefixes

Index 0 is the most common prefix at that depth. The decoder replays the stored UCI. Each depth has its own list of up to K = 1024 entries; these three rows are enough to see that openings repeat.

DepthIdxOpeningUCI prefixFEN
20Open gamee2e4e7e5rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq -
22Siciliane2e4c7c5rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq -
80Open Siciliane2e4c7c5g1f3d7d6d2d4c5d4f3d4g8f6rnbqkb1r/pp2pppp/3p1n2/8/3NP3/8/PPP2PPP/RNBQKBNR w KQkq -
Board for 1. e4 e5, book hit
1. e4 e5, book hit
https://chss.chat/p/d-gAA

Discriminator bit = hit, then depth id 0 and index 0 for e2e4e7e5. No packed suffix. Full URL length 25.

Hybrid (min of 3)40 mean URL chars

For each position, encode packed UCI, occupancy, and lookup, then keep the smallest of those three. A 2-bit mode tag rides in front of the winning payload. The decoder reads the tag and dispatches. This scoreboard hybrid includes lookup; a production-style hybrid without a codebook is only min(packed, occupancy).

Wins across these tested positions and methods. Early games pick path or dictionary; later games pick occupancy. Mean URL falls to about 40 characters with a maximum of 60 characters in this sampled benchmark (an observed max, not a formal upper bound). The largest observed samples select a representation close to occupancy plus the mode tag.

Board for 1. e4 e5, picks lookup
1. e4 e5, picks lookup
https://chss.chat/p/h-oAA

Two-bit mode selects lookup. Shorter than packed path alone; much shorter than forcing occupancy.

Board for Sicilian ~19 plies, picks occupancy
Sicilian ~19 plies, picks occupancy
https://chss.chat/p/h-RA2z6yZ-jkQZDQRFREiMRmZupq5mc3sYAIBQ

Mode selects occupancy. URL stays near 58 instead of a growing move path.

How chss.chat multiplexes formats

The compression question is which representation is smallest. The product question is how one app ships several of them behind one route. chss.chat answers that with a short letter prefix before the payload: f- for full FEN, u- for raw UCI, t- for trimmed FEN, p- for packed moves, o- for occupancy, n- for the naive 4-bit grid, and so on. The decoder reads the prefix and dispatches.

Production today uses f- / u- and a no-codebook h- that keeps min(packed, occupancy). The dictionary row and the scoreboard hybrid that also tries lookup are research codecs. Prefixes are dispatch, not the representation. Changing them is not the research question; finding the smallest trustworthy payload is.

Reproduce the benchmark

The scoreboard is not a black box. Anyone can rebuild it from the public Lichess dump and the scripts in this repository. The product metric is pasteable URL length; the pipeline exists so that claim can be checked, not trusted.

Full commands, disk estimates, and sampling details live in benchmark/README.md on github.com/ghcpuman902/chss. The short path is below.

1. Clone and install

You need Python 3.11+, zstd /zstdcat, roughly 30 GB free disk, and about 5 GB+ free RAM while the month is streaming.

git clone https://github.com/ghcpuman902/chss.git
cd chss
python3 -m venv .venv-benchmark
.venv-benchmark/bin/pip install -r benchmark/requirements.txt

2. Download a Lichess month

Standard rated games are CC0 on database.lichess.org. The published table used June 2026 (~26 GB compressed, 86.5M games). Do not fully decompress it. The scripts stream with zstdcat.

mkdir -p data/standard
curl -L --continue-at - \
  -o data/standard/lichess_db_standard_rated_2026-06.pgn.zst \
  https://database.lichess.org/standard/lichess_db_standard_rated_2026-06.pgn.zst

3. Stream, convert, and split

One helper runs the three passes: full-month frequency aggregate, a 3% hash sample of games turned into compact UCI JSONL, then a deterministic train / validation / test split. On our machine (Apple M3 Pro, 18 GB), that corpus pipeline took about 1 h 45 m wall time, roughly an hour for the aggregate and about 41 minutes for the hash extract.

bash benchmark/run_corpus.sh

Outputs land under data/standard/corpus/hash/ as zstd-compressed train / validation / test JSONL. Once those exist you can delete the raw .pgn.zst to free ~26 GB.

4. Rebuild the scoreboard

Train the K = 1024 opening codebook on the train split, evaluate at plies 2 / 8 / 16 / 32 / 64 on the held-out validation split, and write the slim JSON the page reads.

bash benchmark/run_scoreboard.sh

# same as:
.venv-benchmark/bin/python benchmark/url_length_benchmark.py \
  --train data/standard/corpus/hash/2026-06.train.compact.jsonl.zst \
  --eval data/standard/corpus/hash/2026-06.val.compact.jsonl.zst \
  --out benchmark/results/url_length_hash_val.json \
  --slim-out lib/compression-url-scoreboard.json

You should land near the same ranking: hybrid shortest among the tested methods, occupancy next among standalone state codecs, native FEN much longer. Approximate checkpoint means on this page are hybrid 40, occupancy 57, and native FEN 104. Exact floats can shift a little with toolchain versions; the order should not.

Design notes and earlier bake-off logs: doc/chess-url-compression.md.