The world's smallest chess arbiter to enforce every official rule.
The rule engine is 1,527 bytes of plain source. In rule
correctness and FIDE compliance it is among the most complete engines anywhere; in
function per byte it leads by a wide margin — and that is the claim this project
rests on. It is written in JavaScript and runs in the browser: no libraries, no install, no server. The showcase build for the claim is a single
2.4 KB HTML file: every rule, a clickable board and a live clock, running on any device with a
browser — the same file you play below and can download.
The entire starting position as the engine's array holds it: 64 hexadecimal digits (base 16, written with 0–9 and a–f), a1 through h8 in order, with an 8-cell gap between ranks. Each digit is one square, and each colour in the strip marks one piece type; a white piece's code is the black one's plus 8. That is the engine's
board representation.
Every square shows four things: its name, the piece's
hexadecimal code, its glyph (the piece's Unicode symbol — ♞, ♛),
and the index the engine uses.
The index is written in hexadecimal: the high digit is the rank,
the low digit the file — a1 00, h8 77. Cells whose low digit is
8–f lie off the board; the faint column on the right shows each rank's eight such cells as
one. What this layout buys the engine is covered on the
“The engine itself” tab.
“Perfection is reached not when there is nothing left to
add, but when there is nothing left to remove.”Antoine de Saint-Exupéry
Golfstack
The code itself, its worth, and the craft of Golfstack
A complete, tournament-grade chess arbiter fits into a single JavaScript file of
1,527 bytes. As a single playable HTML file it comes to 1.7–2.7 KB
(KB is a unit of file size; 1 KB = 1024 bytes),
depending on what the interface offers and whether the HTML follows old or modern
conventions. FideLite is an engineering exercise in packing the most
function into the least space, written the code-golf way — carving away every byte of
filler without changing what the program does. Golfstack is what I call that way of
working.
In the main builds, two people play each other on the same device. The engine never
suggests a move; it arbitrates the game, and it needs no external library to do so.
There are no shortcuts on the rules side. You can see how each article is handled, one by
one, on the “Rules and their application” tab. The claim procedure for the
50-move rule and threefold repetition, and the offer–acceptance–lapse cycle of a draw by
agreement, work as the handbook describes: a claim can be made on the move about to be
played, an offer lapses once the opponent moves without accepting it, and checkmate takes
precedence over every automatic draw. When a player resigns or runs out of time in a
position where the opponent cannot mate, the result follows the test that 5.1.2 and 6.9
set out. Most major platforms take shortcuts on these articles.
Technical features
An interface that feels like a modern website. In the default builds the selected
square and its legal destinations are highlighted, a promotion picker pops up, and
indicators show check and a pending draw offer; after each move the board turns to face the
player to move. The glyphs, the clock symbol and the indicators all come from Unicode — no image
files, no web fonts, no requests to an external server. The more minimal builds draw the
board in plain text, and some draw no board at all.
Fischer time control and a readable clock. In the builds with a clock the default time
control is 10+5: ten minutes per player, plus a five-second increment added to the
player's time on every move. Both clocks are shown as MINUTES : SECONDS next to their emoji, and you can see
the increment being added on every move. The moment a flag falls, the result is declared
automatically.
Move generation and robustness. The chess logic holds no surprises: an illegal
move is never accepted, a legal one is never rejected, and no result code is ever triggered
in the wrong position. This rests on measurement — the engine passes
the standard chess-programming test suites with zero errors at
reasonable depths.
Rule accuracy and how games end. When a game ends, the engine does not just say “draw”; it reports which of the fifteen results actually occurred, by name. The codes
are only two characters long yet mnemonic — you can tell at a glance what each letter stands
for — and all fifteen map one-to-one onto articles in the FIDE handbook. Stalemate,
threefold repetition, dead position: nothing is left ambiguous.
The hard half of the draw. Some articles in the federations' rulebooks are skipped
by most implementations, or reduced to their simplest form; this engine tries to apply them
as written. FIDE's well-known Articles 6.9 and 5.2.2 in particular are not
skipped but implemented as far as the algorithm could take them. The payoff is concrete: most
unfair results are prevented. Details here.
A modular structure. The rule layer sits on one side, the interface on the other.
Remove the draw offer, the clock or the automatic draw limits and everything else keeps
working; the starting position (from a FEN, if you like) and the time control are both
configurable. Code-golf chess projects rarely keep this separation: in most of them you can
no longer tell where the rules end and the gameplay begins.
Readability and mnemonics. The source was written to run, not to be read:
single-letter names, one line, inlined functions. Even so, the naming is not arbitrary —
wherever the byte budget allowed, each name echoes the English word it stands for. What the
code actually does is walked through line by line in a separate section.
Speed and depth. Most of an arbiter's work is done within a single ply, but on the
hard half of the draw the engine really does look ahead — and it does so during the game, at
the moment a flag falls or a player resigns, not just in testing. So speed is not cosmetic. It also matters in
testing, where perft (the standard correctness test that counts every
move sequence of a given length from a position) counts millions of nodes. The engine's
fast variant covers both: the same rules, 117 bytes longer, logically
identical. The benchmarks are under a separate heading. And it matters for the bot: the search
built on top of the engine calls it again at every node, so the engine's speed sets how deep the bot can look in the same time.
The bot is a separate layer. The claim is about function per byte: a bot is not required, and adding one does not break rule equivalence. Builds with a bot fall outside the claim; the reasons are below, and the measurements are on the Builds tab.
The real claim is not any one of these items but the ratio itself: function per
byte. The whole rule set sits in a single file — hard as diamond, dense as a black
hole. It is also a reflection of my ego, as a craftsman and as an artist. ;)
The engine was tested thousands of times during development, edge cases included, and
every known bug was fixed. Even so, I am always open to hardening it further, within my own
principles: if you spot a way to shave off one more byte — or, more worryingly, a bug, a
crash or a wrong ruling — write to
cuneytinan37@gmail.com.
Live exhibit
Pick a build and play
Not one game but an exhibition. The repository holds 36 files you can play straight in the browser, and the
variety runs along three axes: in the rules, from a legality-only build to a full FIDE
arbiter; in the interface, from a clickable board to UCI notation and blindfold builds that
draw no board at all; and among the special builds, all the way to bots, more polished
interfaces and builds that mirror the rules of Lichess and Chess.com. The variety takes its
cue from the Ship of Theseus: it is there so that the line between full chess and something
less can be argued over and thought about. That is also why I call the generator that adds
and removes rules on the fly Chesseus!
Each build runs in its own frame, and the clocks only start ticking once you start the
game. Switching builds ends the current game.
Pick a rule level first, then a build. In each level the first build, default,
is that level's main file. L3 is the full arbiter: the rule layer is identical
in all of its builds; what changes is how you enter a move, how the board is drawn and which
indicators are shown.
L1 and L2 are the lower rule levels; both are outside the claim. special is a layer of its own: builds that fit none of these
levels, most of them derived from them, some at odds with the project's own principles. The
details are in the Special builds section at the end of the
Builds tab. nonbrowser in L1,
L2 and L3 is a terminal build: it does not run in a
browser, so it cannot be played here, but you can view its code, or download it and run it with
node.
If the board feels small in the builds that draw one, use the browser's zoom —
Ctrl+, or ⌘+ on macOS; Ctrl0 (⌘0 on macOS) resets it. In the builds that open a
prompt() dialog, nothing will appear if your browser blocks dialogs; download
the file and open it locally, or allow dialogs.
Selected build L3. Start the game or show its code.
Carving the marble — a chess universe fitted into bytes
Code golf is the art of writing a program in as few bytes as possible without
giving up any of its functionality, its rules or its logical correctness. There is one
constraint: the behaviour stays the same, the size shrinks.
From beginning to end this project chased a single question:
How few bytes can a complete, 100% FIDE-compliant
game of chess fit into, skipping no rule from the first move to the last?
The answer is below. But to ask the question at all, I first had to decide what counts
as “complete”.
What can you remove and still have chess? (the Chesseus problem)
Writing the engine, I found myself in the middle of the Ship of Theseus paradox: what
can you strip out of a game of chess and still be left with chess?
An unavoidable question of identity surfaces here. I did not take medieval chess as my
starting point — in that game the bishop leaps exactly two squares diagonally, the
queen's ancestor moves one square diagonally, and neither castling nor the pawn's double
step is yet established. The two share a name, but they are fundamentally different games;
taking the older one as the baseline would have meant answering the question for another
game.
What I took as the starting point was this: today's piece movement (without castling), check, checkmate and
stalemate, the pawn's single-square advance, and automatic promotion to a queen. That is the
core left once everything removable has been removed. Take away one more article and the
game stops being chess.
With the core in hand, there was only one direction left: adding.
From core to full arbitration
The engine was not built in one go; it grew in overlapping rounds.
Underpromotion came first: the pawn was no longer condemned to become a queen, and the
knight, bishop and rook became options too. Then the pawn's double step, and with it en
passant, which has to come along — the two are inseparable, because the second closes the
loophole the first opens. Then castling. At that point, everything on the board was chess as
we play it today.
After that I moved off the board. In theory the move counter keeps a game from running
forever, in practice the clock does; both entered the engine at this stage.
Normally I would have stopped there. But having come this far I wanted to finish the rest
of the rulebook: repetition, insufficient material, draw offers and draw claims, flag fall,
resignation. That also brought in the rulings that arise not from moves on the board but from
the players and the clock.
The path was far from a straight line. Again and again I had to go back after noticing
that a rule I had already added did not match FIDE exactly, or behaved wrongly in a
particular position. Sometimes a new discovery forced me to scrap the whole solution I had written up
to that point. Every reversal meant a structural rewrite followed by another round of byte
optimisation; the code grew, then shrank again.
It was a long and instructive process, on both the coding and the chess-logic side. The
turning points I ran into along the way — which rule broke what, which discovery forced a
rewrite of what — are set out on the Technical detail tab.
Where the carving stops
Asked how he made his sculptures, Michelangelo is famously said to have answered:
The sculpture is already complete inside the block of marble.
I merely carve away the parts that are not needed.Michelangelo
That is what I did in Golfstack. The rules were a given from the outset; the work was carving away everything superfluous in the code that expressed them.
The name for where that carving ends is Kolmogorov complexity: the length of the shortest
program that can produce a given object. Code golf is precisely a walk toward that
bound — and the bound is provably uncomputable, so no one can tell you in advance where to
stop. You find your way by measuring. The added constraint here is that the carving has to
be lossless: drop a single article from the logic the rules require, and what remains is not
a smaller chess but a lesser one.
The answer to the question is therefore a single number: 1,527 bytes.engine.js arbitrates an official game between two human players from the first
move to the last; bundled with a front end into a single playable file, it comes to between
1,715 and 2,744 bytes, depending on the interface and on whether the HTML
follows old or modern conventions.
Why the browser, and why JavaScript
One of the first questions that comes up is this: why the browser, why JavaScript?
The first half of the answer is about reach. Measured by how many devices, operating
systems and environments it opens on, the browser wins by a wide margin. No compiler, no
runtime to install, no package manager; double-clicking the downloaded file is enough. On top
of that, HTML gives you the visual layer almost for free: the board, the clocks and the
indicators need no separate UI framework, and the file opens even if
<html>, <head> and <body> are
omitted entirely. If the engine had any further to go to reach a player, the size claim
would mean nothing in practice.
The second half is about the language itself. JavaScript suits Golfstack far better than
you might expect: it needs no variable-declaration keyword, imposes no boilerplate of entry
point and imports, and implicit type coercion means the board's numeric encoding never forces
an explicit cast. Arrow functions, the ternary operator and the comma operator together let
an entire function collapse into a single expression with no braces and no
return. JavaScript also lends itself naturally to self-extracting packers; the
engine's packed build relies directly on that.
The obvious objection: the browser weighs hundreds of megabytes and supplies both the
JavaScript engine and the runtime. Isn't 1,527 bytes just a sleight of hand, then?
There is no such thing as a zero baseline. Written in C it would need libc and an
operating system; written in assembly, an instruction set and a machine. A measure that also
counts the runtime has nowhere to stop; measured that way, no size claim could be made in
any language. Code golf therefore always draws the line in the same place: the code you
write and the libraries you pull in count; the environment and the baseline do not.
Neither the browser, the operating system nor the processor counts toward the total.
Cross-language comparisons use this measure, and record attempts are open to any
language.
Those who take the end user's view and want a stricter measure have a point too: same
environment, same language, zero libraries. Both are legitimate, and a single file passes
both — no library downloaded, bundled in or added.
The “smallest in the world” claim rests on both measures. I have searched for quite a
while and have not found anything smaller that enforces every official rule, in JavaScript
or any other language. The claim is falsifiable, and it should be: it falls the moment
someone ships something smaller. Until then it stands.
The rule layer is one thing, the bot another
The second question is harder: there are builds with a bot right here on this page, so why
does the claim leave them out entirely?
The usual route is well known: draw a board, set up the pieces, and let the player take
on a bot that plays a little better than random; most attempts so far have followed that
path. The code-golf world has chess bots squeezed into a few hundred bytes, but to make room
they give up almost every rule that forms the backbone of the game — castling, en passant,
the 50-move rule, threefold repetition. Bot logic bloats the code considerably; a two-player
build is far cheaper on the byte budget. And adding a bot contributes nothing to FIDE
compliance.
The real issue is measurability. What counts as a bot is vague: is a sub-100-byte program that plays random legal moves a bot? Is one that counts material a
bot, or is the bar higher? Who decides? Rules are not like that; they are exact. Whether a
piece of code implements an article can be verified by inspection, with no room for
argument. Even where coverage falls short, the measure stays exact: every draw the engine
declares is a real one (soundness), while some real draws are missed (completeness) — and the misses
come to roughly one game in 2.2 million in real-world play; a missed draw can change the result only at a flag fall or a resignation. For a few articles, pushing
coverage to 100% is hard, but even that is a precise target whose gain per byte can be
calculated; a bot has no such definition. I did try writing a bot along the way; then I put all my energy into the
side that can be measured.
The two are kept apart in the code as well, and that is by design. The engine core knows
nothing about the driver, so layering a search on top of it, or intertwining the two, does
not break rule equivalence. That is exactly how the bot builds on this engine came about: the same
rules, minus the clock and the player-to-player interactions, with a search on top.
I do not consider that side finished: before a bot can be included in the claim, it has to
clear the same bar the arbiter did — it has to be a masterpiece in its ratio of quality to bytes. That is a longer job, but since it is a hobby I
enjoy a great deal, I will make a serious attempt at a record on that side too.
Here is what the measurable side has bought: on the hard half of the draw, this engine is
far ahead of the major platforms. I will go on record here — even the strictest of
them stop at the material test (is there enough left on the board to mate with?)
rather than follow the rulebook to the letter. Looking at the position itself — whether a
mate can still be constructed once the flag has fallen, and detecting
blocked positions — is rare. And this has been measured: Miguel
Ambrona's CHA-Solver
found 204,060 wrongly decided games in the Lichess open database, and this
engine rules more than 98% of them correctly. Some platforms make deliberate choices,
such as Chess.com's blend of FIDE and USCF; the choice here is a
different one: one rulebook, one standard.
Beyond the bytes
Code-golf chess projects usually cut corners on the 50-move rule, threefold repetition
and insufficient material — often on castling and en passant too — and focus on the bare
core alone. I went where hardly anyone goes, and I think that is exactly where the project's
originality comes from. As far as I know, no arbiter engine anywhere fits the whole FIDE
rulebook into fewer bytes than FideLite — and it carries a blocked-position detector I have
not come across on any major platform.
It is not a commercial product and does not pretend to be. But layering every rule of
chess this way and fitting them into as few bytes as possible is pure engineering — pushing
the limits on the code side.
I had substantial help getting here from AI models such as Claude and Gemini. Those tools
dramatically compressed work that would normally take months: debugging, code optimisation,
untangling browser quirks. The decisions stayed mine; what I gained was speed.
In a letter to his brother, Chekhov called brevity the sister of talent. In this project I
tried to reduce the vast world of chess to its shortest form without losing any of its
function.
FideLite — what the name and the logo mean
The name has two parts: FIDE + Lite. Together they echo the French
fidélité — fidelity, adherence. That is precisely the point: the file
shrinks, its adherence to the rulebook does not. “Lite” stands for lightness, “Fide” for
the federation; together, an arbiter that drops no article for the sake of being
light.
The logo is built around a single square. The square at the centre is the
origin; the remaining twenty-four squares are where pieces moving from that
square can go.
Origin — the single square at the centre
Knight — eight neutral squares, the L-shaped jumps
Rook — eight turquoise squares, four straight rays
Bishop — eight lilac squares, four diagonal rays
Queen — every rook and bishop square
King — the maroonyellow frame, a one-step range
Pawn — the square its single-step advance lands on: the FIDElite wordmark
Rules
How the rules read
This tab is about the book itself: what each article says, how I read it, and where that
reading is contested. There is no code here; for how the engine does any of this, see the “The engine itself” tab.
What sits under each article instead is a test: a position, the correct ruling,
and the wrong answer a system gives when it applies the article superficially. Reading a
rule is one thing; applying it correctly in every position is another, and only a position
shows the gap.
The rule sections (1–7) use the same parts in the same order, though not every part appears in every section: what the book says, how an algorithm
reads it, where relevant what FideLite does, and what it costs to apply. Section
8 compares FIDE with USCF and the platforms; section 9 describes the hybrid I propose; section
10 looks at the rules that change not the game but its frame.
Article numbers follow the FIDE Laws of Chess that took effect on 1 January 2023 — FIDE Handbook ↗️.
1. How the pieces move, and what makes a move legal
What the book says
Pawn — 3.7. Moves straight forward one square, onto an empty square; on its first move it may advance two squares if both are empty. It moves diagonally forward one square only when capturing, and cannot capture straight ahead. En passant and promotion are among the special moves.
Bishop — 3.2. Moves any number of squares along a diagonal, as long as the path is clear.
Rook — 3.3. Moves any number of squares along a file or a rank, as long as the path is clear.
Queen — 3.4. Bishop and rook combined: any number of squares in eight directions, as long as the path is clear.
Knight — 3.6. Draws an L: two squares in one direction, one at right angles. The only piece that can jump over other pieces.
King — 3.8. Short-ranged: one square in any direction. Castling is among the special moves.
For a movement to count as a legal move, four conditions must all hold:
The piece's movement. The move must follow that piece's pattern above.
A clear path. For every piece except the knight, no piece may stand on the squares in between. The knight is free of this condition, and that is not an exception to the rule but part of how the knight is defined.
The destination square. It must be empty or hold an opponent’s piece; you cannot move onto a square occupied by one of your own pieces.
King safety. No move may leave your own king attacked. The first three conditions are about the shape of a move; this fourth one is about its consequence.
How an algorithm reads it
Together these four define a legal move, and the rest of the book leans on that one
definition from beginning to end. Checkmate, stalemate, dead position, flag fall — every one
of them speaks of a “legal move” or a “series of legal moves”. Shift the definition and every article that rests on it shifts with it.
What it costs to apply. The fourth condition costs more than the first three: whether a move leaves your own king in check cannot be known without making it. It is also the check an arbiter algorithm runs most often.
2. The special moves
Castling — 3.8.2
What the book says
The book puts two different things into two different sub-articles here, and the distinction
matters more than it first appears. 3.8.2.1, the right is lost: if the king has moved,
or that rook has moved, the right is gone and never returns. 3.8.2.2, castling is
prevented temporarily: if the square the king stands on, crosses, or lands on is attacked,
or if a piece stands between king and rook, castling cannot be played right now — but the
right itself stays.
Three squares, not two. The square the king stands on, the one it crosses, and the one it lands on — none of the three may be attacked. The landing square is the one most summaries drop; all three sit in the same sub-article.
The rook is exempt. Those three squares are the king’s. The square the rook crosses may well be attacked: castling queenside with b1 under attack is perfectly legal. The rook’s path only has to be empty.
If the king moves, both rights go. A king move forfeits the right on both sides; a rook move forfeits only the right on that rook's side.
♜♚♜♚♚
Queenside castling with b1 attacked. The move is legal — the three-square condition covers the king’s squares (e1, d1, c1), not the square the rook crosses. The king goes from e1 through d1 to c1: the green frames mark where it starts and lands, the arrow the square it crosses, the faded king its arrival. The reddish squares are those the black rook attacks.
What it costs to apply. Castling is legal only at the intersection of four conditions: the right still stands, the squares between king and rook are empty, the king is not in check right now, and the squares it crosses and lands on are not attacked. Those attack queries are what make this article costlier than its neighbours.
En passant — 3.7.3.1
What the book says
If a pawn advances two squares on its first move and lands beside an opposing pawn, the
opposing pawn may capture it as though it had moved only one square: it moves diagonally
onto the square that was skipped, and the pawn that advanced two squares is removed from the board. The right lasts only for the
very next move. If it is not used on that move, it is gone for good — it does not come back a move later.
How an algorithm reads it
En passant is not a separate rule but a patch on another one. The pawn’s two-square
first move was introduced to speed up the opening; but it also let a pawn slip past a square
an opposing pawn was covering. En passant closes exactly that hole: a pawn that has just moved
two squares may be captured as though it had moved one.
What it costs to apply. The capture itself is cheap: it is expressed through the skipped square, where the capturing pawn lands, so it needs no rule of its own. What is costly is deciding whether the right arises at all — we come back to that in section 4.
Promotion — 3.7.3.3
What the book says
It is compulsory. A pawn reaching the last rank cannot stay a pawn. Promotion is not an option but the condition for completing the move.
It is immediate. Not a separate move but part of the same one. The new piece is in play at once and can give check on that very move.
It is not limited to captured pieces. A second queen may appear while the first is still on the board; the book sets no limit.
Underpromotion is not merely an aesthetic option. There are positions where promoting to a queen stalemates the opponent and promoting to a rook wins — that is the whole point of the Saavedra study. A knight promotion can win too, by giving check or forking.
How an algorithm reads it
The consequence: the queen is not always the best choice. A game that promotes
automatically to a queen is a simpler game, and I think more is lost than people assume.
Underpromotion creates a game in which no piece on the board is unconditionally superior;
a good part of the game’s depth comes from exactly that.
What it costs to apply. Because promotion is a choice, the interface has to ask a question and the algorithm has to account for all four answers. Promote automatically to a queen and this article costs almost nothing.
What the special moves add
Castling and the double step create nothing new on the board: the squares king and rook
reach by castling, and the square a pawn reaches in two steps, can all be reached by ordinary
moves. What they add is tempo: they speed the game up, and as a side effect lend it
some beauty in particular cases. Underpromotion is different. It multiplies the branches at
every promotion by four, and as promotions accumulate the space of the game grows
exponentially. That is why, to my mind, it is the most important addition to the rules after
the piece moves and legality themselves. I say addition because for a long time
promotion went one way only: in medieval chess a pawn could become nothing but the fers, the
queen's ancestor.
3. Check, checkmate and stalemate
What the book says
Check — 3.9. A king is in check when one or more of the opponent’s pieces attack it. The attacking piece does not have to be able to actually move to the king’s square: a pinned piece, which cannot move because that would expose its own king, still gives check. A pin stops the piece’s move, not its attack.
The king never leaves the board. The game ends in mate, not in the king being captured. This is not a stylistic choice; it is where the whole definition of legality rests. A move that leaves your own king attacked is not a bad move; it is a move that does not exist.
Checkmate — 5.1.1. The king is attacked and no legal move removes the attack. The mating side wins, and the game ends at that moment.
Stalemate — 5.2.1. The king of the side to move is not attacked, but that side has no legal move. The game is drawn, again at that moment.
A single thing separates the two. What they share is that the side to move has no legal move. The only thing that decides the result is whether its king is attacked.
What it costs to apply. Settled on the spot. Does the side to move have a legal move, and is its king attacked — both are answered from the position in front of you; no search beyond that side's own moves is needed.
Not every double check can happen
A piece can give check in three ways:
By a leap. The knight jumps two squares along one axis and one along the other: (±1, ±2) and (±2, ±1). Pieces in between do not stop it, because the knight jumps over them; a knight's check cannot be met by putting a piece in the way.
Along a diagonal ray. The piece moves equally along both axes, in the directions (±1, ±1). The bishop and the queen give check along this ray; the pawn's capture is its one-step form.
Along a straight ray. The piece moves along one axis only, in the directions (±1, 0) and (0, ±1). The rook and the queen give check along this ray.
A double check is given by a single move: a piece steps off a line, opens the ray behind it — a
discovered check — and gives check itself. Promotion and en passant are special cases of this pattern. En passant has
one more privilege: since it empties two squares at once, it can open two rays together; then
both checks come from the opened rays and the pawn itself gives none.
Castling can give check too, but only with the rook. The squares it empties lie on the first
rank, the edge of the board, and any ray it could open along that rank is blocked by the castling
rook itself. So castling cannot open a ray towards the king and cannot give double check.
Only a ray can be opened, so in every double check at least one of the checks comes from a
ray. Neither a knight's leap nor a pawn's one-step check can be opened: two knights, two pawns, or a knight
and a pawn cannot give check at the same time.
The piece standing in front of the opened ray cannot belong to that ray's family; if it did,
it would already have been giving check along that ray before the move. Only a rook, a knight, a
pawn or a king can stand in front of a diagonal ray; only a bishop, a knight, a pawn or a king in
front of a straight one.
Three results follow:
Different families. If the second check comes from a family other than the opened ray's, the double check is ordinary: a knight with a ray, a diagonal ray with a straight ray, a pawn with a straight ray.
Diagonal with diagonal. Two pieces of the diagonal family can never give check at the same time. Of the pieces that can stand in front of a diagonal ray, only the pawn could give a diagonal check, and both routes are closed: its advance is a single straight step, which changes the colour of its square, so it cannot land on the king's diagonal; if it captures diagonally, it either stays on the same diagonal or crosses onto the intersecting one. The two squares emptied by en passant are of different colours as well.
Straight with straight. Two pieces of the straight family can give check at the same time in only one way: a pawn captures diagonally and promotes to a rook or a queen. That diagonal step takes it off the line it was blocking and onto a line through the king: off the file and onto the king's rank, as in the diagram, or off the rank and onto the king's file.
♚♜♜♚
The pawn on e7 took the knight on f8 and promoted to a rook. The rook on e4 checks along the file, the new rook on f8 along the rank. Had the pawn promoted to a queen, the double check would be the same, with the queen giving the second check along its straight ray. The green frames mark the pawn's squares of departure and arrival.
This is the only case in which two rays of the same family can give check together: straight
rays have a single exception, diagonal rays none.
4. Counters and repetition — 9.2 · 9.3 · 9.6
What the book says
A move by one player is a ply; a move by each side is a full move.
The 50- and 75-move counter. One counter serves both limits: it advances as long as no pawn moves and nothing is captured; either event resets it.
Repetition of position — 9.2.3. Two positions count as the same when the same player has the move, pieces of the same kind and colour stand on the same squares, and the possible moves of both players are the same.
The occurrences need not be consecutive. The same position may arise at three separate points in a game; what counts is how many times, not how far apart.
The ones that need a claim — 9.2, 9.3. The game is drawn on a correct claim of threefold repetition or of 50 moves by each player (100 plies).
The automatic ones — 9.6. Fivefold repetition and 75 moves by each player (150 plies) end the game in a draw without waiting for a claim.
Mate takes precedence — 9.6.2. If a move delivers mate, the mate stands even when the 50- or 75-move limit is reached on that same move: a 50-move claim attached to the move is ignored, and the automatic 75-move draw does not arise. The coincidence does turn up in long endgames. With repetition it cannot happen: a checkmated position cannot have appeared before — had it, the game would have ended there. The book says so in two places: for 75 moves it is written out in 9.6.2 itself, and for a 50-move claim 5.1.1 is enough — mate ends the game on the spot.
What “possible moves” means
The whole weight of repetition sits in that phrase. The book spells out two cases explicitly,
and measures them differently:
9.2.3.1 — en passant: measured by legality (availability). If a pawn could have been captured
en passant at the start of the sequence and can no longer be, the positions are not the same.
What is measured is whether the right is usable.
9.2.3.2 — castling: measured by right. Castling rights are lost only when the king
or the rook moves. That castling happens to be unplayable right now — a piece in the
way, a square attacked — does not change the position.
How an algorithm reads it
♜♚♟♟♚
Black has just played g7–g5 and the en passant square is g6. But the white
pawn on f5 is pinned against its king by the rook on f8: play fxg6 and the f-file opens,
leaving the white king under attack. Since the capture is not legal, no en passant
availability arises either.
The condition in 9.2.3.1 requires the capture to be genuinely legal. In the position alongside, Black has just advanced a pawn two squares, but the capturing pawn is pinned
against its own king: the capture cannot be played, so the availability never arises at
all.
The distinction is critical, because refusing the capture only at the moment it is played
is not enough. As far as en passant itself goes the two give the same outcome — the move is
unplayable either way. The difference shows up in the counting: availability is part of what
defines the position. Treat it as present and two positions FIDE counts as identical are
counted apart, so the third repetition arrives late or never arrives.
The price is concrete: a game that should have been drawn can end in a loss. And because the
capture was unplayable anyway, the error is invisible on the board — it lives only in the
repetition count.
Three measures for two rights
For en passant and for castling, the repetition count can use three different measures — the trace of the right, pseudo-legal (geometrically possible, with king safety not yet checked) and legal — and each one sorts the same boards into a different set of positions. The top row of the table
is the measure that tells positions apart the most, the bottom row the one that tells them
apart the least; the more positions are told apart, the later a repetition arrives.
Measure
En passant
Castling
Righttrace
A two-square advance is enough; the trace stays even with no pawn to capture
The permanent right: it stands as long as the king and that rook have not moved
Pseudo-legal
An opposing pawn stands alongside, ready to capture; whether it is pinned is not checked
The right exists and the squares between king and rook are empty; attacked squares are not checked
Legal
The capture can genuinely be played now
Castling can be played now: the right exists, the path is empty, the king is not in check, and the squares it crosses and lands on are not attacked
FIDE's choice
Legal9.2.3.1
Right9.2.3.2
FIDE measures the two rights on two different rows: it counts en passant as availability and castling as a right, and says so plainly in 9.2.3.1 and 9.2.3.2. At first glance it looks inconsistent — both seem tied to the question “can it be played right now” — and the point is not undisputed. The book's reasoning is the distinction between losing a right and temporary prevention that we met in section 2: en passant has no temporary prevention — the right either exists or it does not, and one move later it is permanently gone — while for castling the two are separate things, and the book counts only the permanent one. The widely accepted view points the same way: counting castling as a right is both cheaper and
more precise in telling positions apart. The distinction has already had a price in a real game: in Karpov–Miles, 1986, Karpov claimed a threefold repetition
on his 26th move; it was shown that in the position after his 22nd move Black still held
castling rights, and the claim was ruled invalid. Under the rules then in force, Karpov was penalised five minutes on his
clock; Miles thought for a while and then agreed to a draw anyway, because an incorrect claim
is also a draw offer. The
difference was invisible on the board.
♚♞♚♞♜
X and Y: the board is identical. In X the king has never moved; in Y it went to f1 and came back. In both, the knight on g1 blocks castling. Once Ng1–f3 clears the way, castling becomes playable in X but not in Y. The green frames mark the knight's start and destination, the faded knight its arrival.
Why the right, and not playability at the moment. The basic requirement of a
state machine (a system that passes from one state to the next at each step) is memorylessness: the next state should depend only on the current state
and the move made, with no need to look back at the past. Had castling been counted by
whether it can be played right now, that requirement would break. Take the board alongside
in two different games. In X the white king has never moved; in Y it went to f1 and came
back. In both, castling cannot be played right now, because the knight on g1 blocks the
way. The pieces stand on the same squares, the same side is to move, and the two positions
have exactly the same list of legal moves; by the legal measure X and Y count as the same
position. Now play the same move in both: Ng1–f3. In the position that follows X, White
can castle on its next move; in the one that follows Y, it cannot. If the same move applied
to the same position yields two different positions, the assumption that they were the
same was wrong. So the position holds a memory that the board does not show: the castling
right. FEN keeps it in its KQkq field.
The same test shows why the legal measure is enough for en passant. The right lives for
a single ply: if the capture is not legal now, the right is gone in the next position
whatever move is played. Two positions the legal measure counts as the same cannot have
different futures, so no hidden memory is needed. That is why the book measures the two
rights on two different rows: each right is counted by the coarsest measure that still keeps
apart positions whose futures differ.
Only repetition counting needs this memory. In an arbiter that does not
count repetitions, castling can take an easier road just as en passant does: no right is
kept, and all that is checked is whether the king's and the rook's squares have been touched.
For castling the gap in difficulty and cost between the two roads is small; the details are on
the Technical detail tab.
Implementations write the en passant square in different ways. In all of them the capture
itself passes the legality filter in the end, so the difference never shows on the board —
only in the repetition count. In its simplest form the square is written after every
two-square pawn advance — which is how classic FEN writes it too. As notation that is correct;
used as a repetition key (a position's identity for the repetition count: the board, the side to move, the castling rights and the en passant square) it is plainly wrong and at odds with the definition of a repeated
position. En passant is a special move, and writing the square after
every double step is not precise enough for rule correctness. The definition asks that the en
passant possibilities of two positions be the same, yet when a capture that cannot be played
is recorded, the possibility exists on paper but not on the board: one position is counted as
two, and the square no longer marks a real difference between positions. Some platforms, Chess.com among them, read the article differently
and count repetition by the pseudo-legal measure. The generally accepted FIDE-compliant
reading, which Lichess follows too, is to count the square only if the capture is legal.
In FideLite. The en passant square is written only if the capture is legal — an opposing pawn stands alongside, and the capture, which clears two squares of the same rank at once, does not expose the capturer's own king. Castling is kept as the permanent right: together with the board, the side to move and the en passant square, the castling rights are part of the repetition key. FideLite thus applies FIDE's two rows exactly.
The two limits that keep a game finite
The two automatic limits in this section — 75 moves and fivefold
repetition — can each keep a game from running forever on its own; each rests on a
different kind of finiteness.
For the counter the reason is this: a pawn move and a capture cannot be undone. A pawn does not go back, a
captured piece does not return. Each can happen only a finite number of times in a game — there
are only so many pawns and pieces. And those two are the only things that reset the counter. So
once both are exhausted the counter climbs without interruption and ends the game by itself
at 150 plies. The book ties the counter to these two alone; in section 9 I argue
that two more events of the same class, the loss of a castling right and the lapse of an en
passant right, should reset it as well.
Repetition rests instead on the position space being finite: where the pieces stand,
the side to move, the castling rights and the en passant square give only finitely many
combinations, so a game that never ends must sooner or later come back to some position a
fifth time.
So even without dead-position detection, one of the two limits is certain to end a dead
position; not even two sides trying to drag the game out as long as possible can stop it.
In theory the 75-move counter is by far the earlier of the two: in k7/8/8/1p2p2p/1P2P2P/8/8/K7 w - - 0 1 the kings are
shut into their own three ranks; the 576 placements make 1,152 positions once the side to move
is counted, and the longest sequence in which none of them comes a fifth time runs to 4,608
plies, while the counter fills at 150. In practice, though, positions where repetition cuts in
much earlier are common: being blocked makes mobility collapse and brings the same position back
quickly — in the bishop example of section 6 fivefold repetition arrives on
ply 16.
Dead-position detection, on the other hand, is a rule that ends the game
early, not one that makes sure it ends: it never comes into play in a position that is not
dead, so on its own it guarantees no finiteness, but it recognises a dead one on the first move.
Read backwards, the same reasoning says how few rules it takes to guarantee
finiteness. Legality, checkmate, stalemate and one of these two limits are enough: a game
that enforces them cannot run forever; it is simply not possible. One is enough; the two are not needed together, and the
dead position is not needed for finiteness at all. It is there to end the game
correctly, not to make sure it ends.
The distinction is visible directly in the levels: each level adds
new rulings that end the game. The barest level has neither the counter nor a repetition count, so finiteness is not
guaranteed there — with only the two kings left on the board, the game never ends. The guarantee
comes with the automatic limits: the 75-move rule and fivefold repetition are
each enough on their own. Dead position and repetition, for their part, are sensible,
practical rules that speed the game along: they often deliver the result without waiting for
150 plies. Each draws on a different kind of certainty: the dead position on
geometric certainty — the board itself says mate will not come; repetition on
certainty of intent — returning to the same position a third time shows that no one
means to make progress, and the player may claim; at the fifth, no one needs to be asked. In
casual games and official tournaments alike, the time these rules save the players is
invaluable. What the top level adds — clock, resignation, claims, offers — is a separate
dimension: the rules not of the board but of human interaction, and without them
there is no tournament chess.
An irreversible move prunes the past
A move that resets the ply counter (a pawn move or a capture) and the loss of a castling
right both prune the repetition table from that moment on: none of the positions before it
can ever arise again. A pawn does not go back and a captured piece does not return; and since
a castling right is part of what defines a position, positions that held the right cannot
come back once it is lost. Everything the table holds up to that point is now dead data for
certain.
Where this pruning really pays is in engines that search. A bot checks for repetition at
every node of its search, millions of times a second; scanning the history only back to the
last irreversible move is raw speed there. In an arbiter the check runs once per ply, and
since the positions sit in a hash table (a structure that reaches an entry directly by its key), a lookup takes constant time however large the
table grows.
How large it can grow has a hard ceiling too: because the 75-move rule applies automatically,
a game can last at most 17,697 plies — 8,848.5 moves ↗️. That is the concrete
number behind the finiteness argument above; the table can never hold more positions than
that.
In FideLite. The engine follows the book: the counter resets only on a pawn move
and a capture. The repetition table is never pruned either; it grows for the whole game. Even
though it keys each position by a string of about 190 characters, at the end of the longest
possible game it takes up only about 4.5 MB. A load no one will feel; writing the pruning
would have been bytes spent for nothing.
Counters instead of a scoresheet 8.1 · becomes counters digitally
Over the board, what stands behind these counters is the players' own record. FIDE requires
each player to record their own moves and their opponent's, move after move and legibly, in
algebraic notation (8.1.1): by hand on the paper scoresheet or on a FIDE-certified
electronic scoresheet. It is the only notation system FIDE recognises (Appendix C ↗️);
computer chess knows it as SAN (Standard Algebraic Notation), the form that writes
Nf3, exd5, O-O. Piece letters may follow the player's own
language — a Turkish scoresheet writes A, F, K, V, Ş — while SAN fixes the English ones. The
record is what a claim stands on. Whether fifty moves have passed, how many times a position
has occurred — the player can work both out only from the scoresheet; keeping any other notes
is forbidden (11.3.1). The rule is strict. Writing a move down before playing it is forbidden
too; one of the exceptions is the claim under 9.2 or 9.3 (8.1.2): the claim is made by writing
the move on the scoresheet, and the arbiter checks it against the scoresheet. There the record
is evidence, because over the board both an illegal move and an incorrect claim are possible,
and the penalty for each is decided from it (7.5, 9.5).
Digitally none of that load remains. An illegal move is impossible, so it carries no
penalty; an incorrect claim is checked on the spot and costs nobody anything. With
the record no longer needed as evidence, what is left are the two numbers the player had to
work out of it: the ply counter and the repetition count.
That arithmetic should be automated: both numbers should stay current on screen after every move. The counter should count plies, not full moves; I think that is the right choice. Because a
claim can also be made on the move about to be played, knowing which ply fills the limit is
critical: in full moves a “49” on screen could mean 98 plies or 99, and the player could not
tell whether their next move completes the fifty. Precision has to reach down to the single ply.
How many times the position has occurred should be reported continuously in the same way, so the player does not learn afterwards that a right has arisen but sees it coming. I think that
is more consistent than approaches that announce a right only once it exists, by lighting up
a button: the book recognises a claim on the move about to be played as well, while a button
sees only the move already made.
I think this is also the most sensible way to bring systems based on FIDE, or even on USCF,
into digital play. Where an illegal move is impossible and an incorrect claim costs nothing, counters
that give the numbers automatically are fully consistent with the adaptations above: once the
record carries no burden, neither should the arithmetic done from it.
Given the ban on notes, could this be called a breach of FIDE? If it were, the engine would
be in breach from the very start: it allows only legal moves, never applies the penalty of
7.5, and checks a claim on the spot instead of against a scoresheet. The articles on
recording, notes and penalties are rules of the table, not of the game; they exist to produce
evidence and keep play honest, and digitally the platform does that job itself. FIDE's own
online regulations ↗️ point the same way: the playing zone accepts only legal moves,
and every move and clock time is recorded automatically and shown to both players (3.1.2, 3.1.6).
The ban on notes holds there too (9.6), but a record the platform shows to both sides is not
a note, and the counters are a summary of that record.
It would be good for this to become standard across platforms. A player who holds a right
should not have to count back through the move list to be able to use it.
In FideLite. Both numbers stay current on the status line after every move; the indicators section explains how to read them.
FIDE's online regulations claims become automatic digitally
Alongside its over-the-board Laws, FIDE has a set of online regulations ↗️,
written for official online events and for hybrid events, where players sit together under an
arbiter's supervision but play on screens. They take over the rules of movement (Articles 1–3)
unchanged; the differences are few, and almost all of them sit on the interface side.
The playing zone accepts only legal moves, and so does FideLite. Automatic recording of
moves and clock times, premoves and automatic promotion to a queen are settings of the player
or the interface, not rules. The project's claim is about the engine's rule correctness, not
the comfort of the interface.
The real difference is in claims. The regulations declare threefold repetition and the
50-move rule drawn automatically, without waiting for anyone to claim (5.4). It is as if
both players had an automatic claim switched on against each other; the aim is
simplicity. At
heart this too is a driver choice: a driver that makes the claim on the player's behalf the
moment a threshold is reached gives the same behaviour without touching the engine. Some of
the builds already do this. The Chess.com equivalent, like the
platform itself, always ends the game on its own at threefold repetition and at fifty moves.
Chess LUX, built on the FideLite engine and flexible in every setting, offers
it as an option.
The only difference that reaches the engine itself is resignation. Under the regulations a player who
resigns always loses (5.2); over the board, since 2023, a resignation is a draw if the
opponent cannot mate by any series of legal moves (5.1.2).
In FideLite. The principle is to carry the main federation's rules into digital play
as faithfully as possible, which is why the over-the-board book is the one applied. The name
carries the principle too: fidélité, fidelity. Claims and automatic draws are kept apart
on purpose: the 50-move rule and threefold repetition by claim, the 75-move rule and fivefold
repetition automatically. One is the player's right, the other the rule's verdict, and the
book writes them in separate articles. FideLite holds that this distinction carries into
digital play without friction: the claim travels on the move's own
channel and the numbers stay on screen, so a player neither misses a right nor has to
count back through the move list to use it.
What it costs to apply. Repetition is the first article in the book that requires keeping information beyond the board. What defines a position is not only where the pieces stand: the side to move, the castling rights and the availability of an en passant capture are all part of the definition. Get one of them wrong and the repetition count fails silently.
5. Dead position — 5.2.2
What the book says
The article says this: when a position arises in which neither player can checkmate
the opponent’s king by any series of legal moves, the game is drawn. Such a position is
called dead.
Three points are worth making at the outset, because the whole article turns on them.
It is two-sided. Both sides must be unable to mate; if either side still can, the position is not dead. Articles 6.9 and 5.1.2 in the next section ask about one side only — confusing the two-sided question with the one-sided one is the easiest mistake to make on this tab.
It ends the game at once. The game is over the moment the position becomes dead. No claim is needed, nor any ruling from the arbiter. Moves played afterwards do not count — the book takes this seriously enough to make the result retroactive.
It has three branches. The impossibility of mate can come from material: nothing left on the board can mate. It can come from structure: the material is more than enough, but the position is locked. Or it can come from forced arrival: the board is not yet dead by material or by structure, but every legal continuation carries it to an end without mate. One article, three different questions.
How an algorithm reads it
Each branch can be brought about by different moves, and that is what tells an algorithm when to look at which branch:
Branch
Moves that can bring it about
Why
Material
A capture. The one exception is when the last pawn on the board promotes and leaves the material insufficient: a lone knight, or bishops all of one colour.
Material shrinks only through captures. A promotion turns the pawn into another piece, and the piece chosen may be too weak to mate.
Structure
A capture or a pawn move. Exceptions: the move gives mate, or the pawn move creates an en passant right.
A quiet move can be played back, so if a lock appears after it, the lock was already there in the previous position. Mate ends the game anyway. An en passant right is itself a lock breaker; the lock arises only once the right lapses.
Forced arrival
Any move, quiet ones included.
A quiet move advances the ply counter; there may no longer be enough moves left for a mate.
The first branch: from material
The table below looks at both sides’ material at once and asks: is mate possible on this board at all? It should not be confused with the table in the next section, which looks at one side only and belongs to a different article; the link between the two is drawn at the end of that section.
Material
Ruling
Bare king
Insufficient
A lone bishop
Insufficient
Bishops all on squares of one colour
Insufficient
A lone knight
Insufficient
Bishops on squares of both colours
Sufficient
Bishop + knight
Sufficient
Two knights
Sufficient
Any rook, queen or pawn
Sufficient
Knights add up, bishops merge
For a dead position the pieces are counted without regard to side, across the whole board,
and in that count knights and bishops are not weighed on the same scale. With knights the
number decides: with a single knight on the board the position is dead; a second knight, whichever side it joins, brings it back to life. Bishops are different. If every bishop on the
board stands on one colour, the position is dead however many there are and whoever holds them.
Each new bishop of that colour leaves the ruling unchanged; the position stops being dead only
when a bishop of the other colour, or any piece other than a bishop, arrives.
♝♚♝♝♝♝♝♝♝♝♝♝♝♝♝♝♝♚
Sixteen bishops, all on dark squares. The marked e8, g8 and f7 are the squares orthogonally adjacent to the black king: being light squares, they can never be reached by any of the bishops on the board. The white king stands on a light square; as long as it stays there, it cannot even be checked.
The board alongside holds sixteen bishops: nine White's, seven Black's, all on dark squares.
White's nine is the highest number reachable: the original dark-squared bishop and eight
promotions.
The correct ruling: a dead position, the game is drawn. A system that counts bishops
sees sixteen of them, judges the material more than sufficient and plays on. Yet if fifteen of
these were replaced by a single bishop of the other colour, leaving only two bishops on the
board, the position would not be dead.
This is no coincidence but a necessity that follows from the geometry of chess.
A bishop never changes colour. A diagonal step changes both the file and the rank by
one, so the colour of the square never changes. A bishop that comes from promotion stays on the colour of its promotion square. Everything a dark-squared bishop does happens on dark squares: the
squares it attacks, the squares it stands on and the squares where it blocks other pieces are all
dark. For it, the light squares might as well not exist.
The escape square always stays open. With nothing on the board but the kings and
dark-squared bishops, a king in check always stands on a dark square, and its orthogonal neighbours are light. No bishop of either side can attack those squares or stand on
them; only the opposing king can cover them. It cannot manage this even in the most favourable case, with the king to be mated in the corner: a king in the corner has two light neighbours, and to cover both at once the
opposing king would have to step onto a square next to the king being mated. On the edge there
are three such neighbours, in the middle four. So neither side can mate. Neither the number of
bishops nor the side they belong to enters any step of this proof.
The condition is permanent. With no pawns on the board no new kind of piece can appear,
and captures only remove bishops. A bishop of the other colour can never enter the board. Once
the condition has arisen it never breaks, and 5.2.2's question of “any series of legal moves” is
answered at a glance, without looking ahead.
The knight has no such invariant: it changes colour with every step, and two knights, whether
on the same side or on opposite sides, make a mating position constructible. With knights it is the number that decides; with bishops, the colour.
In FideLite. The material check does not count bishops one by one; it only looks at
which colours have a bishop.
Scope and correctness
The correctness of a dead-position algorithm splits into two separate questions, known in
logic as soundness and completeness:
Soundness: when it says “dead”, the position is dead. In every position where it declares a draw, neither side can really mate.
Completeness: it recognises every dead position. No dead position stays in play with a “not dead” answer.
The two do not weigh the same: soundness is not negotiable, while completeness is a target
that gets measured. An error can go in one direction only: a dead position may be
missed, but a position that is not dead is never called dead.
Why this direction. The two errors are not equal. On a miss the game carries
on, the moves repeat and one of the two counters brings the draw in the
end; the result only arrives late. A miss turns into a wrong result in one case only, the game
being cut short before the counters fill: a flag falls or a player resigns, and the side
entitled to a draw loses. A serious error,
but confined to one game. A false positive ends the game; no rule takes back a draw
once declared. So an impossibility that cannot be proved does not count as a draw: when in doubt, the answer is “not dead”.
Soundness can be proved. Each of the three branches rests on a proof when it says
“dead”. In the material branch the proof is the table itself. In a lock, each condition of the
pattern rules out one kind of move that could break it; when all the conditions hold at once,
no such move is left. In forced arrival the proof is the exhausted tree: every line has run
into an end before any mate.
Completeness can be proved only for insufficient material. The material table is
closed: it catches every position whose material is insufficient, and all of those are dead.
But sufficient material does not show that a position is alive; with sufficient material a
position may be dead or it may not. That is decided not by the number of pieces but by their structure: a lock, or a forced line.
So the real question of completeness for a dead position lies in the structure. For the
lock, a proof of completeness would require a brute-force scan of every legal position with
two to thirty-two pieces; no such scan has been done yet. In forced arrival, completeness
depends on the budget; a limited budget leaves some lines unfinished. An algorithm that claimed to catch every dead position would therefore be claiming too much. Even so, almost every
dead position that arises in practice can be caught, and the miss rate can be measured.
Scope: reachable boards. The algorithms for structure and forced arrival are written for boards that can be reached by legal moves in a game that starts with 32 men, from the standard setup or from one of the Chess960 setups. At least some of the boards that can never arise in a game can be recognised by arithmetic alone, and they are eliminated at the outset: every piece beyond the starting
complement comes from a promotion, and every promotion costs a pawn; the extra rooks,
knights, bishops and queens together cannot outnumber the missing pawns. Bishops are counted
by colour as well: a side can hold at most nine bishops of one colour, the original one and
eight promotions. Boards beyond that are outside the algorithms' scope; since
they never arise in a game, they never come before an arbiter either.
In FideLite. The miss rate is measured. Of the 204,060 games CHA-Solver
found wrongly decided on time in the Lichess open database, the engine gives the right ruling
in 200,384. The locked class is drawn by each engine's own lock scan: FideLite's
recognises 48,135 of these games as locked, and Chess LUX's, which also covers
bishops, 52,146. In that wider class FideLite gives the right ruling in
48,483 games (92.98%), Chess LUX in all of them. Of the 3,676 games the engine misses,
3,638 have a bishop on the board; the engine's lock scan covers only kings and pawns, so these
are left to the search. The misses come to roughly one game in 2.2 million across the whole
record of about 8.22 billion games. The measurement was made with the Arbiter.
The second branch: from structure — blocked positions
Mate can be impossible with material to spare. The pawns have locked each other, none can
advance, nothing can be captured; even with both sides cooperating no mating position can be
built. The article covers this too, because it speaks of “any series of legal moves” — it
does not count pieces.
A lock is an invariant. A blocked position is recognised not by trying continuations one by one but by a pattern read off the board. Once the pattern is in place, no legal move can break
it; and since none can, it is in place in the next position too, and in the one after that.
This induction, move by move, is what lets a single position settle the whole future of the
game.
What is sought is not a winner but a lock breaker. If, even with both sides cooperating, no path leads either to an irreversible move (a capture or a pawn move, promotion included) or to a mate, the position is dead. Only reversible moves remain, and they shuttle
without ever reaching mate. So the algorithm does not ask who wins; it looks for a move that
would break the lock, or for a mate.
On a board within the scope, a locking pattern meets five
conditions at once. Each follows naturally from the principle above: break one, and a breaker
is born.
Knights, rooks and queens must be buried. Every square they could move to must hold one of their own men. A knight that can move jumps over the wall, a rook or a queen slides down an open line; the algorithm counts every one that can move as a breaker. A queen can be buried too: a queen on a8, shut in by its own pawns and knight, meets the condition. A rook that still has a castling right counts as buried only if the geometry of castling is closed as well, Chess960 setups included.
A pawn must not be able to change file. A pawn is either frozen or can advance only along its own file, with nothing to capture. A diagonal capture changes the file, a promotion the kind of piece; neither can be undone. While an en passant right stands the position does not count as locked, because that capture is a change of file too.
A capture by the king must not open the lock. The king may roam freely; reaching the other side of the wall changes nothing by itself. But if it can capture something, the pattern must still hold after the capture.
The bishop must stay harmless. The bishop may roam, but it must be unable to capture anything and unable to reach a square on which a pawn could take it; if it could, it would let itself be taken, make the pawn change file and break the lock. That a bishop can give check, on the other hand, proves nothing by itself: in some structures the check is harmless, in others it opens the door to a one-sided mate.
What is buried must stay buried. Wherever the king, a bishop or a pawn goes, the knights, rooks and queens must stay buried. If a pawn's advance, or a king or bishop changing square, would free a buried piece, that move is a breaker too.
Three examples show how the conditions look on the board. The plainest consists of nothing
but kings and pawns:
♚♟♟♟♟♟♟♚
Six pawns locked head to head: none can advance, and there is nothing to capture on their diagonals. The kings can roam the open files in between — but two kings cannot mate each other, and there is nothing else on the board. Correct ruling: a dead position.
Put two bishops on a similar lock: the ruling does not change — the position is still
dead, because the bishops are confined to their own halves as well and nothing can be
captured.
♚♝♟♟♟♟♟♟♟♟♚♝
Why the bishops cannot cross is a matter of square colour: White’s pawns occupy every light square of the fourth rank, Black’s every dark square of the fifth. The light-squared white bishop can never pass the fourth rank, nor the dark-squared black bishop the fifth — and the remaining squares of those two ranks are covered by the opposing pawns.
♝♛♜♞♚♟♟♟♟♝♟♝♟♝♟♝♟♟♟♟♟♟♟♟♟♚
The fourth rank is filled end to end with black men; the free queen, rook and knight cannot cross that wall, and the white king is shut in on the first rank. Locked, but unreachable: Black has twelve pawns.
The price of the first condition. On the board alongside, Black's queen, rook and
knight are free, yet the position is locked, and the algorithm does not recognise it. But
building that wall takes twelve black pawns: the board lies outside the scope, and no game
can produce it.
Where these conditions come from. Some follow necessarily from the geometry of the
board: the promotion arithmetic, a pawn changing file. The rest are places where the
algorithm deliberately stays cautious, such as counting a knight, rook or queen that can move
as a breaker; the twelve-pawn board shows what that caution costs. Ignoring king safety, the
turn and pins does the same job: it gives the sides more freedom than they really have. More
freedom means more breakers, and more breakers mean “not locked”; if caution makes the
algorithm err, it errs in the safe direction. Almost every lock met in real games fits these patterns.
When the scan should run follows from the rule too. A lock can only be born through
an irreversible move: a capture or a pawn move. A quiet move — a king or a
sliding piece changing square — cannot build a lock, because it can be played back: if a lock appears after it, the lock was already there in the previous position. A capture and a pawn move both reset
the ply counter; that is the moment to scan.
The loss of a castling right does not create a lock. In the
repetition count it is an irreversible event, because it changes the position's identity;
but a lock looks not at the castling right but at the geometry of castling. If castling could
ever be played, the rook does not count as buried and there is no lock in the first place. If
it never can, losing the right changes nothing on the board.
The rule has two exceptions. The first is mate: the move that creates the lock may
give mate at the same time.
♚♟♟♟♟♟♟♚♟♟♟♟
Black to move plays f5. The pawn checks the king on e4 and is covered by the king on e6. e3 is held by d4, f3 by g4; d4 is covered by c5; d5 and e5 border the black king: mate. The same move locked the structure: now no pawn can either advance or capture anything.
Mate comes first. With the kings elsewhere, the same f5 would only build the lock:
a dead position, a draw.
8/2k5/5p2/p1p5/P1Pp1Pp1/3P2P1/8/4K3 b - - 0 1
On the board alongside, though, f5 both creates the lock and mates. A scan that reads the
pattern sees frozen pawns and no captures left, and answers “dead”. But the game ended with
mate the moment f5 was played; a mated position is not a dead position. So mate is checked
first, and the lock only if there is none. Correct ruling: Black wins.
The second is en passant. A double step may well have created the lock, but it may
also create an en passant right, and while that right stands the
position cannot be called dead. Run the scan at that instant and it must answer “not locked”; and since no further irreversible move will come, it never runs again and the
position is missed for good. The position to scan is the one after the right has lapsed, one
ply later. The rule fits in four lines:
After the move
Scan
Why
The move gave mate
Don't scan.
The game ended with mate; even if the pattern is in place, the result is mate.
The ply counter was reset and there is no en passant right
Scan. Then not again until the counter resets.
A lock can only be born through an irreversible move; the quiet moves in between can be played back, so they cannot build a new one.
The ply counter was reset and there is an en passant right
Don't scan.
The right is itself a lock breaker; as it stands, the position cannot be dead.
One ply has passed without a counter reset and the en passant right has lapsed
Scan. Then not again until the counter resets.
The breaker is gone; if the double step created a lock, it shows now. The move that let the right lapse is quiet, so the counter does not reset; without this line the scan would never run again.
In FideLite. The engine implements only the part of this algorithm made of
kings and pawns and does not rule before it saturates; most
locks met in practice fall within it. With any other piece on the board — a bishop, or a
knight, rook or queen, even a buried one — the scan does not run; such locks are closed by
the 6.9 search when the space is narrow and missed when it is wide. The
extension that also recognises bishops and buried pieces lives in the sibling project
Chess LUX. A narrow example:
♝♚♟♟♟♟♟♟♟♟♟♟♟♟♟♟♚♞
The knight on d1 is buried among its own pawns: its four squares — b2, c3, e3 and f2 — hold locked white pawns. The white king has a1 to c1, the black king h8 and g8; the black bishop is shut in behind e7 and g7.
The scan stays silent, but White has two moves and Black one, and the search exhausts
every continuation in seven tries: the ruling is right, it only comes through a
different door.
Sometimes the lock is not yet in place on the board. A queen that is bound to be captured,
a pawn with no other move left, or a counter about to fill brings it about a few moves later,
or ends the game without mate before that. Such positions are dead too, but recognising them means looking ahead; they belong to the third branch.
The third branch: forced arrival — what the board is forced to become
Mate can be impossible even when the material is sufficient and nothing is locked. The
article asks not about the pieces on the board but about any series of legal moves,
and in some positions every series flows to the same place: the moves are forced, or the
choices change nothing. Wherever the line goes, it arrives at an end without mate. The board
as it stands is misleading; the ruling follows from what it is forced to become.
What is sought is still a mate, only further ahead. The algorithm assumes the two
sides cooperate and looks at every series of legal moves in front of it. If even one series
reaches mate, the position is not dead. If every series runs into an end before any
mate, it is dead. There are five ends: the first two branches, stalemate, a ply counter that
fills, and the fivefold repetition. The forced line usually carries the board into one of the
first two branches; this branch is those two seen from a few moves away. If the move that
fills the counter gives mate, the result is mate.
The tree is searched on a budget. The counter and repetition make the tree finite in
theory, but can leave it too large to exhaust in practice. So the algorithm works with a
budget: if it has exhausted the tree within the budget, the ruling is “dead”; if not, “not
dead”, the answer in the safe direction. A narrow line of forced
moves, or of a few choices, runs out in a handful of tries; as the choices multiply the budget
no longer suffices, and misses begin.
Here too neither side may be able to mate; positions where the forced line is
mateless for one side only belong to 6.9 and to the next section. The five forms below are one example of each end.
♚♛♚
White is in check and its only move is Kxb2: once the capture is made nothing but the two kings is left.
Forced insufficient material. The only legal move captures the one piece that could mate: White has nothing but its king to begin with, and Black's queen is forced off the board. Neither side can mate: the position is dead the moment the queen gives check. Correct ruling: a draw. A static check, however, sees the queen and plays on. This is also the most common shape in the whole branch.
♚♟♟♟♟♟♟♚♟
Black’s king is entombed by its own pawns and White’s: the g7 pawn covers both f8 and h8. Only the a7 pawn has moves left, and when they run out it is stalemate.
Forced stalemate. The same shape, a different destination. The forced line ends not in mate but in stalemate, and the game ends there. White cannot mate either: it has no piece that could ever give check, and its a-pawn runs into the black one. Correct ruling: a draw. The material here may be more than sufficient — sufficiency is beside the point.
♛♚♟♟♟♟♟♟♟♟♚
Black is in check and its only move is Kxb7. With the queen gone what is left is a locked wall of pawns: no pawn can advance and neither king can cross.
A forced blocked position. At the end of the forced line lies the second branch: the material is sufficient, mate is impossible all the same. The queen on the board is misleading; its only job is to be taken. Correct ruling: a draw. This is where two branches meet — and a case that counting pieces will never find.
In all three the line is forced, only the destination changes; and in all three
someone looking at the board can reach the right answer. The remaining two arrive at the
book's automatic limits instead: one depends on the ply counter, the other on repetition.
That is, not on the board but on its history.
♚♞♚♞
A symmetrical board: two kings, two knights.
A forced 75-move draw. This one cannot be read off the diagram — what decides it is
not the pieces but where the counter stands. Which is why the FEN (the standard one-line notation that records the position together with the side to move, castling rights, en passant square and counters) belongs beside it:
4k1n1/8/8/8/8/8/8/4K1N1 w - - 148 250
The second-to-last field is the ply counter: 148. The counter fills at 150, so 2 plies remain. The material is sufficient: a king and knight can construct a helpmate. But not in two plies: the counter
fills first, the game ends in a draw of its own accord there, and the mate can never
happen. The pieces are symmetrical, so this holds for both sides. Correct ruling: a
draw.
♝♚♟♟♟♟♟♟♟♟♚♝
Each side has exactly one move: the king shuttling. The bishops are locked in their corners and the pawns blocked head to head. The position returns after two full moves.
A forced fivefold repetition. The forced line keeps returning to the same position, and neither side can mate. The game ends by itself at the fivefold repetition at the latest. Correct ruling: a draw. The repetition itself depends on the history of the game — how many times a position has been seen cannot be read off the arrangement on the board — but since this line cycles for ever, the destination is the same whatever the history.
Here something has to be said plainly: the algorithm that recognises a dead position is one of the hardest in chess programming — to my mind, the hardest. Checkmate,
stalemate, repetition, the counters — each has a closed definition: checkmate and stalemate
follow from move generation, which can be tested with perft (the standard correctness test
that counts every move sequence from a position); repetition and the counters can be
verified exactly against test positions and reference implementations. The dead position has a clear definition too, but there is no defined method that proves it in every position. Even someone claiming
to have solved the article completely could not prove it: the proof would mean testing every
legal position with two to thirty-two pieces, about 4.8×1044 of them, one by one;
there is no computer to finish that, nor a corpus or an authority to certify it.
So the strongest thing an implementation can say is “close to 100%”; the number of games
it misses is measured, not proved — the measurement is in the next
section. The one thing that can be certain is the direction:
every position called dead must really be dead, and one that is not recognised must be
played on.
In FideLite. The check after every move does not look ahead, and need not: the
line is forced, and the game reaches the same result a few plies later anyway. That makes
the 6.9 search a closing check: it runs once, only when the game is
cut short before that arrival — a flag falls or a player resigns — and walks the line in the
game's place.
It has two limits: 2,000 move tries in all and 9 plies for any single line.
Whichever runs out first, the answer is “mate is possible”; a limit never invents a draw. In
an ordinary position the ruling comes in well under a millisecond; the heaviest position from
real games takes 56 milliseconds, and even a search that spends the whole budget stays under a
second: friction paid at most once a game.
On narrow lines the budget is more than enough: the fivefold example closes in
four tries, the two-knight example with the counter at 148 in 72, within tens
of milliseconds. As the counter is wound back, the ruling flips between 147 and 146 —
not because of mate but because of the budget: at 147 the tree runs out in 688 tries, at 146
it does not fit into 2,000.
Larger limits would make the search a little more complete, but more depth slows every ordinary position, and a bigger budget every position that hits the ceiling, in the same proportion. CHA-Solver stops at
the same place: it says that with an unlimited budget it always reaches the right ruling, yet
recommends its 23× faster version, which is not complete in theory. I agree: a few extreme
exceptions are not worth slowing the ordinary positions.
The engine keeps to that direction: every position it calls dead is dead, and play continues in any position it does not recognise. Online chess platforms share the direction; the difference is in completeness: they have no check for this article at all, so they miss every blocked
position.
What it costs to apply. The most expensive article in the book, in code size and in speed alike. The material branch is a matter of counting pieces, and cheap. The structural branch needs a look at the whole board: showing that the lock is permanent, that no series of moves can open it. Forced arrival, unlike mate and stalemate, cannot be answered from the position as it stands; it needs a search that looks ahead.
6. Flag fall, resignation and the possibility of mate — 5.1.2 · 6.9
What the book says
These two articles follow the same pattern. The player who resigns or runs out of time loses —
unless the opponent is in no position to checkmate that player’s king by any series
of legal moves, in which case the game is drawn.
For flag fall the exception has been in the book a long time. For resignation it was
added in 2023: the earlier text said the player who resigns loses, full stop. In the same position, a player who resigned in 2022 lost; one who resigns from 2023 onward gets a draw.
Resignation, moreover, is not tied to the turn. Unlike an offer or a claim, a player may resign at any moment, even while the opponent's clock is running (5.1.2).
Nor should the opening clause of 6.9 be overlooked: the article does not apply
at all where one of 5.1.1, 5.1.2, 5.2.1, 5.2.2 or 5.2.3 applies. Checkmate, resignation, stalemate, dead position and draw by agreement all take precedence over flag fall. That is why mate beats the flag: mate ends the game on the spot, and a flag that falls
afterwards changes nothing.
It is one-sided. 5.2.2 asks about both players; these two ask only about the opponent of the player who resigned or flagged. That you can mate does not save you — the question is not what you can do but what your opponent can.
Not “can they force it”, but “can it be built”. The article is not looking for a forced mate (one that arrives whatever the opponent does); it asks whether a mate could arise if the losing side played the worst moves available. A helpmate, in other words. Since the losing side can help by shutting off its own king’s escape squares with its own pieces, the defender’s material counts too. A real case: Soćko–Foişor, 2008.
How an algorithm reads it
no leeway digitally The fine point is when mate and flag coincide. The book grounds it in its definition of a move: a move is completed when the player presses the clock, but a move that ends the game — mate, for instance — counts as completed the instant it is made, without the clock being pressed (6.2.1.1). So the question between mate and flag is whether the mate reached the board before the flag fell. Over the board, if the mating move lands just as
time runs out and it is unclear which came first, the benefit of the doubt usually goes to the side delivering mate. The reason for that leeway is human: placing the move on the
board and pressing the clock both take time, and seeing the flag is a matter of eyesight. The
uncertainty comes from those constraints, and the room for interpretation over the board
exists to settle it. Digitally none of those constraints exist. The line is sharp, the flag
can fall at any instant, and which event truly came first is decided by the algorithm: mate
triggers on the board and the flag on the clock, on two separate layers, and the one to be
taken as correct is the one that triggered first. Digital play is therefore where this
article is least open to interpretation.
Anyone porting an arbiter algorithm into an online application should also mind the turn: offers and claims should stay tied to it, resignation should not.
In FideLite. The clock is updated before the move is processed; if time has
already run out the ruling comes from the flag, otherwise from the board. Resignation cannot be fully freed from the turn: the builds are simulations played on one
screen, resignation travels on the move's channel, and whoever presses the resign button is taken to be the side to move. The aim is to exhibit rule correctness, so the concession was made here; the
details are in Pitfalls.
The cheap half: the pieces on the board
The first question is answered by counting: is mate possible at all with the pieces the opponent holds? The table below gives the answer case by case. It differs from the table in section
five in this: there both sides were counted together, here it is the mating side that is
counted — and the defender’s material enters only because it can help.
Mate with a lone knight can only be built if the defending side owns a piece that can block its own king's escape: a bishop, a knight, a pawn, even a rook. A defending queen cannot do the job,
because from every square where it could close the escape in the corner it simultaneously
attacks the mating knight. A defending pawn suffices in two ways. On the a- or
h-file it can close the cornered king's escape square itself, even before promotion. On any
other file it promotes: since the article allows any series of legal moves, the pawn can turn
into the piece that does the shutting in. What was said about the pawn holds for mate with a lone bishop too.
Mating side
Defender
Mate
Constructible position
K
K + any piece
No
not constructible
K + B
K
No
not constructible
K + B
K + B same colour
No
not constructible
K + B
K + R
No
not constructible
K + B
K + Q
No
not constructible
K + N
K
No
not constructible
K + N
K + Q
No
not constructible
K + N
K + R
Yes
♚♜♞♚
K + N
K + B
Yes
♚♝♚♞
K + N
K + N
Yes
♚♞♞♚
K + N
K + P on the a- or h-file
Yes
♟♞♚♚
K + N
K + P on any other file
Yes
After promotionnot while it is still a pawn; possible once the pawn promotes into a piece that does the shutting in
K + B
K + N
Yes
♚♞♝♚
K + B
K + B opposite colour
Yes
♚♝♚♝
K + B
K + P on the a- or h-file
Yes
♝♟♚♚
K + B
K + P on any other file
Yes
After promotionnot while it is still a pawn; possible once the pawn promotes into a piece that does the shutting in
K + N + N
K
Yes
♞♚♞♚
These are positions that can be constructed, not results that can be forced. None of
them is a winning method; they are only the answer to “does mate exist at all with this
material” — and that is exactly what 6.9 and 5.1.2 ask.
Unbuildable, you said?
According to the table, K+B cannot build a mate against K+Q or against K+R. The result of
section 5 holds here too: a second bishop of the same colour, whoever
holds it, does not change these rows; the defender's piece answers every check either by
stepping in between or by taking the checking bishop.
♛♚♜♝♚♝
The dark-squared bishops on b6 and g5 check the king on d8 at the same time. The marked c7 and e7 are where the heavy pieces would step in against a single check; against a double check only the king may move, so neither can be used.
And yet here is such a mate on the board. The black king stands on d8 with its own heavy
pieces beside it: the queen on c8, the rook on e8. The white king is on d6, and the bishops on b6
and g5 give check at the same time. With a single bishop it would not be mate: against the
bishop on b6 the queen on c8 would step in on c7, and against the one on g5 the rook on e8 would
step in on e7. Each heavy piece can answer one check; only a double check gets past both.
But two diagonal rays cannot give check at the same time: the front bishop would already
have been giving check before the move. The full reasoning is in section
3. This board can be drawn but not played.
♚♚
The two kings side by side; every square the black king could flee to is next to the white king.
Were it constructible, we would have to count this as checkmate too: the black king is attacked and has no square to go to. There is nothing on the board
but two bare kings, yet by the letter of the definition this is mate.
Counting it as mate breaks the game's premises, and then no limit is left: even a bare king
could mate, and every material table collapses. The two boards are equivalent: each is a
drawing that no series of legal moves can ever produce. Where we draw the line is up to us, but
it has to be drawn in the same place for both; whoever counts one as mate has to count the other
too. The rule speaks only of positions that can be played.
The costly half: what the board is forced to become
Counting pieces settles only half the question, and the cheap half at that. Your opponent may have a
queen on the board — a system that looks at the position statically says “sufficient” and
stops there. But the article does not ask about the pieces on the board; it asks about
any series of legal moves. And in some positions all those series come down to a single line: there is no other move.
The typical case: your opponent has given check with a queen, your only legal
move is to capture it — and your time runs out before you can. The queen is visible on the
board; yet no series of moves leads to your being mated, because the one line along which the
game can continue passes through that queen being taken. The correct ruling is a draw.
In practice this plain shape is what usually comes up, and the search closes such a line in a single try. A threefold repetition that was about to be claimed does not help
here: it needs a claim, so it does not end the line; only fivefold repetition and the
75-move rule end it on their own. If the forced line leads somewhere mateless for both
sides, the position is already dead (5.2.2). If it is mateless for one side only, the ruling becomes
asymmetric; those cases are below.
The difference fits in one sentence: counting pieces looks at the board at the moment
the flag fell; the book asks what that board is forced to become. Over the board the
problem barely exists; the arbiter is already looking ahead. The unfair rulings accumulate on
the servers.
In FideLite. The yardstick is Miguel Ambrona’s
CHA-Solver: the first to scan this class of positions systematically, an open-source solver published with a
peer-reviewed paper.
Across the Lichess open database, over the games up to 30 September 2026, it counted
204,060 wrongly decided games. The engine itself draws the line: FideLite's lock scan
recognises 48,135 of them as blocked positions, and the remaining 155,925 are
left to the search and the material table.
The engine rules 200,384 of them correctly: all 48,135 locks it recognises and
152,249 of the rest. The
3,676 it misses are a measured number, not a proved bound: in every one of them the
search runs into its limit, and 3,638 have a bishop on the board, which puts them outside the
lock scan. CHA-Solver leaves out the ply counter and repetition, so it counts positions where
the counter fills before a mate as winnable; this engine calls them drawn. A scan of the same
database — about 8.22 billion games — with this engine would likely turn up further wrongly
decided games that CHA-Solver does not count. You can repeat the measurement with the same data:
the Arbiter.
What it costs to apply. The reason given for not applying this article has always been the same: that it is intractable. The reason is less solid than it looks. Looking ahead is required, but forced lines are narrow by definition, and the question is asked at most once a game, as the game is cut short.
One-sided wins that need a search
Because 6.9 asks about one side only, the ruling can be asymmetric: on the same board mate may be possible for one side only, and whose flag fell decides the result.
5.2.2 does not work this way; there the question is asked for both
sides at once and there is one answer. Counting pieces cannot show this; what the board can
become has to be examined. Two examples, one from a forced line and one from a locked
structure.
♚♚♜♟♟♛
White is in check and its only move is Kxe7 — and by clearing the rook's path, that move delivers a back-rank mate. But there is no time left to play it.
A forced mate the other way. The only legal move of the player who flagged mates their opponent. But there was no time left to play it, and the game ended the moment the flag fell; it cannot be called a win. Nor does the opponent ever get a turn, so no series of moves lets them mate either. Correct ruling: a draw. 6.9 asks not “can I mate” but “can my opponent mate me”.
♝♟♚♝♟♟♟♟♟♟♟♟♟♚
The pawns are frozen: White’s on light squares, Black’s on dark, the bishops of opposite colours — the only possible capture is the black king taking the unprotected white bishop, and that makes the position dead. Neither king can cross either. But as it stands the position is not dead: White can still mate, Black cannot.
An asymmetric lock. Because the pawn structure is frozen, the number of reachable
states is finite and can be counted in full: 68,672 states while the white bishop
stays on the board. In none of them is White mated; in 58 Black is. For
instance:
♚♝♟♟♟♝♟♟♟♟♟♟♟♚
One of the 58 mates the same lock allows. The white bishop checks from c6, the a6 pawn covers b7, and the two squares Black might flee to are blocked by its own bishop and its own pawn. The white king is still on d1 — it takes no part in the mate.
The ruling is asymmetric again: if White’s flag falls it is a draw, if Black’s it is
a loss. Since the position is not dead the game does not stop, and it should not. A
structure seen once in a billion games in practice, but it shows on a single board why 6.9
is a separate article from 5.2.2.
In FideLite. Both rulings are right: if the side that flagged or resigned cannot
be mated it is a draw, if it can, a loss. To try it yourself, open
Chess LUX ↗️,
enter the FEN, press Play and resign with the ⚐ button without making a move; the side to
move is the one resigning.
k3K2R/pp2q3/8/8/8/8/8/8 w - - 0 1
5b2/p2k1B2/Pp1p1p2/1P1P1Pp1/6P1/8/8/3K4 w - - 0 1
5b2/p2k1B2/Pp1p1p2/1P1P1Pp1/6P1/8/8/3K4 b - - 0 1
In the first two White resigns and the game is drawn (½‑½); in the third Black resigns
and loses (1‑0).
The intersection: the dead-position material table
When it comes to building a mate, the table in the cheap half comes down to three rules:
A bare king cannot build one against anything.
A lone knight cannot build one if the other side has no piece but queens. There must be exactly one knight; there may be any number of queens.
Bishops all of one colour cannot build one if the other side has no piece but rooks, queens and bishops of that same colour. The number of bishops does not matter; as long as they all share one colour, some may even be the opponent's.
Take the intersection of these one-sided rules, the combinations where neither side can
build a mate against the other, and out comes the dead-position material
table of section five: a bare king against a bare king, a lone knight or bishops all of
one colour; bishops of one colour against bishops of that same colour. In every other
combination at least one side can build a mate. The material branch of 5.2.2 is 6.9's
question asked of both sides at once.
Two possibilities of mate
for implementers Mateability is not one question but two: can
White mate Black, and can Black mate White? For every position the two possibilities can be worked out separately. Like castling rights, they are two small pieces of information the position carries, and like them they can only be lost: if no series of moves from a position
leads to mate, none from any later position can either. A possibility once lost never comes
back for the rest of the game. The direction is the same here too:
a possibility counts as standing until it is proved lost.
When a flag falls (6.9) or a player resigns (5.1.2), only the opponent's possibility
decides the ruling: if it stands, a loss; if it is lost, a draw. Three consequences
follow:
If both possibilities stand, a flag fall or a resignation is a loss, declared at once.
If one side's is lost, that side can no longer win; a draw is the most it can get. That is exactly what 6.9 and 5.1.2 ask.
If both are lost, the position is dead, the familiar 5.2.2, and the game is drawn on the spot.
The two possibilities can be updated as soon as each move is played. Then nothing is left to do at a flag fall or a resignation: the ruling is ready and only has to be read. The cost is
lower than it sounds, because what has already been worked out lightens the load:
A possibility once lost is never asked about again.
Material and the lock can change only through a capture or a pawn move (the table at the start of section five), so they are not read again after a quiet move.
So not every move needs a full check for both sides. The work falls only on the moves that
can actually change a possibility, which is kind to both the budget and the processor.
Which road to take is a matter of preference and priorities: keep the possibilities up to
date throughout the game, or leave the look-ahead search to a flag fall or a resignation and
run it then, one-sided, once.
In FideLite. The engine takes the second road. This section is a suggestion for
anyone who reads the rules or CHA-Solver and writes an arbiter of their own.
Short circuits of a single search
Every dead position is, in fact, a draw that would arrive on its own. With no mate
possible, one of the two limits that keep the game from going on for
ever ends it sooner or later; in some positions a forced stalemate ends it earlier still.
5.2.2 simply declares that inevitable end without waiting for it.
A second conclusion follows: a search with enough budget would find them all by
itself. A search that walks every series of legal moves ahead of it to those ends would
recognise every dead position without needing a material table or a locking pattern, and
would answer 6.9's one-sided question the same way. Since the same two limits make the tree
finite, this is possible in theory. But exhausting that tree, even for a single position, can
take far longer than any player would wait; and 5.2.2 wants a ruling after every move, 6.9 and
5.1.2 the moment the game is cut short.
So the branches of 5.2.2 and the material table of 6.9 are not redundant; they are
short circuits of that search. Each closes part of the tree without walking it line by line:
The material table is an inference. Once it has been shown that certain pieces cannot build a mate, every series that could be played with them closes at once.
The lock is a deduction. That no move can break the pattern is certain knowledge, following from the rules of movement. If it cannot be broken, it is there in the next position, and in the one after; a single board speaks for the whole future.
Forced arrival is a domino. When the moves are forced the line does not branch: each move topples the next like a domino, and the line reaches an end without mate. The search walks only that line; the rest of the tree is never opened.
These short circuits are a property of the problem itself, not of any piece of software.
Whatever the language, whatever the engine, an arbiter that wants to apply the rule in
reasonable time has to resort to short circuits of this kind. Each answers the same question
— does any series of legal moves reach mate — by a cheaper road; none of them changes the
question.
7. Claims and agreement — 9.1 · 9.2 · 9.3 · 9.5
What the book says
Claiming the current position. If the threshold is already met on the board, the claim is made without playing a move.
Claiming with a move. The player states the move to be played and declares that the rule will be satisfied once it is played. Once stated, the move cannot be changed.
The price of an incorrect claim — 9.5.no digital counterpart If the claim turns out to be wrong, the opponent is given two extra minutes. It makes up for the friction of the board: a claim stops the clock, calls the arbiter to the table, and breaks the opponent's tempo and concentration. Digitally a claim is checked instantly and automatically; the clock does not stop, nobody waits, and the opponent loses nothing. With no loss to make good, the rule has no digital counterpart — it goes the same way as the penalty for an illegal move.
How an algorithm reads it
The claim window
The book ties a claim to a move: either you state the move you are about to play
and claim, or you claim when the position has arisen and it is your turn. Over the board that
window is as wide as your thinking time — comfortably wide.
Claiming on your own turn, for a threshold already met on the board, is no problem. The
friction begins when the right will arise from the move you are about to make. The book
solves that by letting you state the move in advance and claim. Digitally, if the move does not
carry that information within it and no automatic trigger is switched on, the player has to
play the move first — but the instant it is played the turn passes to the opponent, and the
right is now theirs. The opponent can reset the counter with a capture or a pawn move, or
change the position; with a premove (a move entered before one's turn and played
automatically the instant it arrives) this happens within milliseconds. The right is gone
before the player could claim it, and not by the player's choice. No rule is broken — a right
not claimed lapses anyway — but a right that over the board is used by stating the move becomes
unusable in practice on screen. This is not particular to one site; it holds on every platform
that does not let a claim be attached to a move, and premoves only make it worse.
The design consequence is plain: the claim channel has to travel with the move
itself — either the move carries the claim within it, or the right is triggered
automatically. Leave it to a separate button and the window closes again.
The offer, too, is tied to a move
The book links the claim to the offer: every claim under 9.2 or 9.3 is also an offer of a
draw (9.1.2.3). A claim that does not hold is not wasted; it stays before the opponent as an
offer, and if the opponent accepts, the game is drawn. The offer's own rules run parallel to the claim's:
Draw by agreement — 9.1.embedded in the move digitally Over the board, the offer is made after the move is played on the board and before the clock is pressed: an offer is attached to a move, not a message of its own. Its digital counterpart is to embed the offer in the move, on the same channel as the claim.
The offer lapses. An offer loses its validity when the opponent rejects it explicitly or plays a move instead of accepting it. The player who made the offer cannot withdraw it.
An offer made at any other time still counts, but distracting the opponent falls under
11.5 — so an offer while the opponent is thinking is not the path the book lays out.
On the platforms offers are not confined to that window at all: an offer can be sent at any moment, and
when repeated it turns into a device for interrupting the opponent's thinking. That is exactly
the behaviour the book means to keep out by tying the offer to the turn.
Resignation lies outside this condition: the book does not tie resigning to the turn; the detail is in section 6.
In FideLite. The window is applied: both the offer and the claim can come only from the
side to move, because both travel on the move's own channel.
8. Federation differences: FIDE, USCF and the platforms
Move rules, how the pieces travel, checkmate and stalemate are identical in both
federations. The divergence begins off the board — at the clock, at resignation, at the draw
claim. The two books govern the same game but part ways where the player, the clock and the arbiter step in.
Procedural differences
50 moves and threefold repetition — shared. In neither book does the game end by itself; a draw needs one of the players to claim it.
75 moves and fivefold repetition — automatic under FIDE, at the director's discretion under USCF. FIDE 9.6 ends the game without waiting for a claim. USCF has the same thresholds, but the director may declare the draw and is not obliged to (14K); its fivefold repetition also counts only repetitions that come in a row. If the director does not step in, play goes on.
Resignation. Under USCF a resignation is always a loss, whatever the position. Under FIDE, since 2023, 5.1.2 turns it into a draw if the opponent cannot mate by any series of legal moves.
Dead position — shared. FIDE 5.2.2 and USCF 14D lay down the same principle: once neither side can mate, the game ends at that moment, blocked positions included.
The main divide: helpmate and forced mate
When a flag falls both books ask the same question — can the opponent mate this player —
but they read “can” differently. Both look at the position and both need a search; where they
part is in what the search is looking for.
FIDE: helpmate — 6.9. A line in which the losing side plays the worst moves available, walling in its own king with its own pieces, is enough. The question is “can a mate be built”. Its hard cases are in section 6.
USCF: forced mate — 14E. If the opponent is left with only a bare king, a lone knight, a lone bishop or — when the player out of time has no pawn — two knights, the player does not lose on time, unless the opponent has a forced mate. The question is “can mate be forced against any defence”. The list says where to look; seeing the exception takes a look at the position itself.
Searching for a forced mate is lighter than it sounds. A forced mate either
exists or it does not; there is no need to write a depth or a threshold into the rule. In
these classes the attacking side holds one or two minor pieces, so a single saving reply by the
defender is enough to close a whole branch, and the tree stays very small. If a mate exists
it is short — in practice no more than about five moves. The theoretical bound can be
longer, but today's machines find that too without strain.
The two books also draw the line in different places. USCF, for instance, is the more generous to the player out of time:
with the player out of time down to a bare king and two knights on the other side, FIDE awards
the win, because a helpmate can be built, and USCF the draw, because the mate cannot be forced.
In the table below, “win” means a win for the side whose clock is still running.
Out of time
Opponent
FIDE
USCF
K+R
K+N
Wina helpmate can be built
Drawunless a forced mate exists
K+B
K+B (opposite colour)
Wina helpmate can be built
Drawunless a forced mate exists
K+Q
K+N
Drawno mate can be built
Draw
K
K+N+N
Wina helpmate can be built
Drawtwo knights cannot force it
K+P
K+N+N
Win
Winthe player out of time has a pawn
The platforms
♟♚♞♚
White has played Nf1. Black's only move is h3–h2, and Ng3 mates: the
piece locking the king into the corner is its own pawn.
One board, three measures, two results. In the position alongside, White has only a
knight and Black a pawn on the h-file. Under FIDE the question is answered by material first:
by the table in section 6, king and knight can build a helpmate against
a king with a pawn on the a- or h-file. On the USCF list a lone knight counts as insufficient,
but 14E makes an exception when the opponent has a forced mate, and this position is exactly
that exception. The two federations reach the same result by different roads: if Black's
flag falls, White wins.
Chess.com does not look at the position; it only counts material — can this material mate
a bare king — and since a lone knight cannot, it
awards the draw.
A measure that does no search follows the USCF list but cannot see its exception. In edge
cases like this the result is unfair under both federations: the side that sees the loss
coming can let its own flag fall and get a draw.
In FideLite. The engine stays faithful to FIDE, as Lichess does; but it applies 6.9
not through a material comparison alone but through a search that goes down to the position
itself when needed. For comparison there are also equivalents that follow the two platforms'
behaviour, in the Special builds section of the Builds tab: the
Chess.com equivalent, which applies half of the USCF measure — taking its list and leaving out
its exception — and the Lichess equivalent, which applies 6.9's helpmate condition through a
material comparison alone. The same position can be played in all three side by side.
9. A proposed hybrid ruleset
On a digital platform, applying either book as it stands does not always give the best
outcome. USCF leaves 75 moves and fivefold repetition to the director's discretion, so on a
server with no one to make that call, a game can keep going with no fixed point at which it
ends. Under FIDE, the 50-move and threefold claims can turn a won technical
endgame into a draw over a single slip.
The hybrid below combines what looks fairest in each book. It is a proposal: FideLite
itself applies FIDE, and everything else on this tab is written to that book. But where the two
books part and the question stays contested, these are the limits I find most reasonable and
fair. Which article comes from which book is stated openly in the table; that is also where it
differs from the blend Chess.com applies on some articles. These limits vary from federation
to federation, even from tournament to tournament; they are not the core or the body of
chess. One proposal does touch the body directly: what should reset the
counter. That is a deeper change to a rule itself, and I argue it under its own
heading.
Parameter
FIDE
USCF
Hybrid
50-move · threefold claim
On
On
Off
Automatic draw
75 moves · fivefold
Nonethe director may declare it at 75 moves or at a fivefold repetition in a row
60 moves · fourfold
Draw by agreement
Ononce both sides have made at least one move
Onno minimum number of moves
Onfrom the very first move
Dead position
Ends at once
Ends at once
Ends at once
En passant in repetition
Legal
Legal
Legal
Castling in repetition
Right
Right
Right
What resets the counter
Pawn move, capture
Pawn move, capture
Every irreversible event+ loss of a castling or en passant right
Criterion at flag fall
Helpmate
Forced mate
Helpmate
Bishops counted as insufficient material
All bishops on the board on one colour
A single bishop
All bishops on the board on one colour
Two knights, player out of time has a pawn
Win
Win
Win
Two knights against a pawnless side
Winif a helpmate exists
Drawunless a forced mate exists
Winif a helpmate exists
Resignation
Helpmatedraw if the opponent cannot mate
Always a loss
Helpmatethe same measure as the flag
50-move and threefold claim — off. No claim ends the game at these thresholds, and draw by agreement stays open; the line is drawn automatically at 60. The difference is critical in technical endgames: the bishop-and-knight mate can take up to 33 moves even with best play, and the Philidor position of queen against rook is long too. Fifty moves leave a margin of only 17 on top of that, and a single slip can turn a won game into a draw; sixty raise the margin to 27, while seventy-five, with 42, drag out a game that is already over. This is no privilege for the stronger side but a guard against one mistake deciding a won position; the defender is still protected by fourfold repetition and the 60-move limit. The claim cannot be abused, and the premove problem of section 7 is cut off at the root.
60 moves and fourfold repetition — automatic. When the counter reaches 60 moves or a position arises for the fourth time, the game is drawn. The thresholds are the harmonic means of the two books': exactly 60 for 50 and 75, and 3.75 for 3 and 5, rounded to 4. Threefold comes too early and fivefold too late; the harmonic mean falls between them, nearer the smaller value: the limit widens, but not without measure. This is the outermost net that keeps the game finite.
Draw by agreement — from the very first move. FIDE requires both sides to have made at least one move (5.2.3); USCF sets no minimum and only ties the offer to the moment after the move is determined and before the clock is pressed (14B1). The hybrid sets no minimum either; since the offer travels with the move, the timing stays the same.
Dead position — ends at once. As in 5.2.2, blocked positions included.
The ply counter — reset by every irreversible event. Besides a pawn move and a capture, the loss of a castling right and the lapse of an en passant right reset it too; the reasoning is below.
Flag fall — helpmate, as under FIDE. When time runs out, the question is not whether the opponent could force mate but whether they could mate by any series of legal moves (6.9). Time is part of the game; managing the clock, like managing the board, is a measure of a player's strength. Winning with a lone knight or bishop against heavy pieces may not look fair at first sight, but the knight is not what brings that win, the opponent's failure to manage their time is; players have to learn to count the clock too. USCF's forced-mate measure protects the side that used up its time; the hybrid does not.
Resignation — helpmate, the same measure as the flag. Since 2023 FIDE applies this measure to resignation as well (5.1.2), while under USCF a resignation is always a loss. The hybrid follows FIDE: resignation, like flag fall, closes a game before it ends on the board, and a player whose opponent cannot mate does not lose by resigning.
One note on the implementation side: the helpmate search (6.9) does not have to wait for
the flag. It can run in the background in every minor-piece position, so the result is ready when
the flag falls and the players never wait for it.
What should reset the counter
9.3 and 9.6.2 tie the counter to two events only: the movement of a pawn and a capture.
What the two share is that neither can be undone, and that is where the counter's whole
meaning comes from. But the book's own logic reaches further than its letter. Two more events
belong to the same class: the loss of a castling right and the lapse of an en passant right.
Neither comes back, and the book itself makes both part of a position's identity in 9.2.3. I
think the counter should reset on all four. This is not a reading but a change to the rule:
if a rook move costs a castling right while the counter stands at 140, FIDE ends the game in
a draw ten plies later, while under this counter play goes on.
The en passant lapse rarely makes a difference in practice: the right is born only of a
double step, which has already reset the counter, so when it lapses the counter simply
resets one ply later. The gap is small, but counting it keeps the rule exact. The right
is counted here by the measure of 9.2.3.1: if the capture is not genuinely legal, there is no
right to lapse.
The reason I think about it this finely is that three rules then fit together. A counter
defined this way means “plies since the last irreversible event”, and all three rules meet on one
signal: the 75-move rule counts it; fivefold repetition need
only look inside that window, because nothing before it can recur; and the blocked-position scan
runs at the moment it resets. The two-part condition of section 5 — the
counter was reset and there is no en passant square, or the right has just lapsed — collapses
into one: the counter was reset and there is no en passant square. A lock that a double
step hides for one ply is caught the moment the right lapses, because that moment is a
reset too. Finiteness survives: there are four castling rights and at most sixteen en passant
rights, so the longest game grows by at most a few hundred plies.
10. Rules that change the frame
The first nine sections were about the standard game. This one looks at rules that change
not the game itself but its frame. The book gives some of them after the main articles, in its
appendices and guidelines, and leaves others to the regulations of an event. What they share is
that the rules on the board stay as they are; what changes is everything around them.
Chess960 — Guidelines II
What the book says
♛♜♚♝♝♞♜♞♟♟♟♟♟♟♟♟♟♟♟♟♟♟♟♟♛♜♚♝♝♞♜♞
Position 777, QRKBBNRN. The king is on c1, between the rooks on b1 and g1;
the bishops are on d1 and e1, opposite-coloured squares. The pawns are in place, and
Black mirrors White.
In Chess960 the game is played exactly like standard chess: the pieces move the same way,
the objective is still checkmate, and legality, stalemate, repetition and the counters are
unchanged. Two things differ: the starting position and how castling is carried
out. The book gives both in Guidelines II. FIDE does not count the guidelines as part
of the Laws, but strongly recommends using them in every event where they apply.
The position. Only the back rank changes: the pawns stay on the second and seventh
ranks, the order of the eight pieces on the first rank is drawn at random before the game,
and Black mirrors it on the far side. There are two conditions: the king must stand between
the two rooks, and the bishops must be on opposite-coloured squares. Together they allow
exactly 960 positions: the bishops go in 4 × 4 ways, the queen onto one of the six remaining
squares, the knights onto two of the remaining five (10 ways), and the last three squares
are forced: rook, king, rook. 16 × 6 × 10 = 960. The positions are numbered 0–959 (the
Scharnagl number); the classical start is 518, the one shown is 777.
Named by side, not by length. In standard chess castling is short or long; in Chess960 the distance the king travels depends on the position, so those names lose their meaning. The book names castling after the file the king lands on: c-side castling (0-0-0) and g-side castling (0-0). In programming the same split also goes by the side the rook stands on: a-side and h-side.
The destination squares are the standard ones. Wherever king and rook start, they end on the squares of standard castling: on the c-side the king goes to c1 and the rook to d1; on the g-side the king goes to g1 and the rook to f1. For Black, the same on the eighth rank.
Hence four forms of castling. The book lists them one by one (II.3.2): double-move castling, where both pieces move; castling in which the two swap squares; castling by a king move only; and castling by a rook move only. A king that starts on the c-file does not move at all in c-side castling; the rook simply comes across and sits beside it (777 shown here: QRKBBNRN). A king that starts on the g-file does the same on the g-side (34: BNNQRBKR). Conversely, when the castling rook already stands on its destination, that is the c-side rook on d or the g-side rook on f, only the king moves (295: QNBRKRNB, on both sides). The two can never both stay put: a king on c has its c-side rook to its left, on a or b, and that rook must come to d; a rook on d has the king to its right, and the king must go to c. In 276 of the 960 positions the king stays put on at least one side; in 258 the rook does.
The distance varies too. In standard chess the king castles two squares either way. In Chess960 it travels anywhere from zero to five: a king that starts on the b-file covers five squares in g-side castling (741: RKBBNNQR). The free placement also means castling can be legal on the very first move: in 469, RNBBNKRQ, the king on f1 and the rook on g1 stand side by side with nothing to clear, and g-side castling simply swaps them. That holds in 162 of the 960 positions.
Which squares must be empty depends on the position. Every square between the king's start and destination, and every square between the rook's start and destination, destinations included, must be empty apart from the king and the castling rook. A square that must be clear in standard chess may stay occupied, and a square standard chess ignores may have to be cleared. The attack condition is unchanged: the square the king stands on, crosses and lands on must not be attacked, and the rook is still exempt (3.8.2.2).
In 777, the position shown, c-side and g-side castling take two different forms. In c-side castling the king
does not leave c1; the rook on b1 passes over it to d1, and only the bishop on d1 has to move
out of the way. In g-side castling both pieces move: the king four squares, from c1 to g1,
and the rook from g1 to f1; for that, d1, e1 and f1 all have to be clear.
How an algorithm reads it
Castling can no longer be written as a two-square king move. In standard chess
castling can be encoded as the king moving two squares sideways (e1g1); no other
king move covers two squares, so nothing is ambiguous. In Chess960 the king travels anywhere
from zero to five squares: a king that stays put has the same source and target square, and a
king that moves one square makes a castling move indistinguishable from an ordinary king step.
The common solution in engines is to write castling as the king capturing its own
rook: in UCI's Chess960 mode, c-side castling in the position shown is c1b1
and g-side castling is c1g1. Source and target always differ, and since no legal
king move can land on a square held by its own piece, the ambiguity disappears.
The castling right has to know its rook by file. FEN's KQkq field assumes the
rooks stand on the a- and h-files. In Chess960 the right also has to say which rook it belongs
to: Shredder-FEN always uses the rook's file letter (GBgb for the position
shown); X-FEN keeps KQkq to mean the outermost rook on that side and switches to the file
letter only when the castling rook is not the outermost one. That is why the position shown
can be written in X-FEN as
qrkbbnrn/pppppppp/8/8/8/8/PPPPPPPP/QRKBBNRN w KQkq - 0 1.
Legality is the same questions, generalised. Castling still checks the four
conditions of section 2: the right still stands, the path is clear, the king is not in check
right now, and the squares it crosses and lands on are not attacked. Standard code asks them
over fixed squares (f1 and g1 on the g-side); in Chess960 the same questions are asked over
ranges set by the starting files of king and rook. Because castling looks only at the files of
the king and the two rooks, the 960 positions reduce to 56 skeletons as far as castling is concerned;
all of them are in the Chess960_Skeletons simulation.
no digital counterpart Over the board the book settles the same ambiguity through the order in which the pieces are handled: the king
is first lifted off the board and set beside its destination, then the rook is placed, and
only then is the king put down; saying “I am about to castle” beforehand is recommended too.
On a screen, the encoding of the move does that job.
In FideLite. The builds play standard chess; Chess960 is in LUX, among the
Special builds: at the start you tick the 960 box and type the
position number (0–959), or roll the die for a random one. Typing 777 opens the position
shown at the start of this section (777, QRKBBNRN).
What it costs to apply. The starting position is data, not a rule: as cheap as starting the game from a different FEN. The whole cost is in castling: checks written over fixed squares generalise to ranges tied to files, the move encoding switches to the king capturing its own rook, and the castling right learns its rook by file. Move generation, check detection, mate, stalemate, repetition and the counters stay as they are.
Time controls and multiple periods — 6 · Appendix A · Appendix B · Guidelines III
What the book says
The book does not fix the time: it defines how time is allotted and leaves the numbers to
the regulations of an event, which must state them in advance (6.3.1). A time control is set
by three things: the time, the extra time per move, and how many periods the time is split
into.
Three kinds. One sum gives the kind: time + 60 × the extra time, as if the game
lasted 60 moves. At 10 minutes or less the game is blitz (Appendix B), under 60 it is
rapid (Appendix A), and at 60 or more it is classical, standard chess in the
book's words. “3 + 2” means 3 minutes and 2 seconds per move.
Control
Time + 60 × increment
Kind
3 + 2
3 + 2 = 5 min
Blitz
5 + 5
5 + 5 = 10 min
Blitzboundary included
10 + 1
10 + 1 = 11 min
Rapid
10 + 5
10 + 5 = 15 min
RapidFideLite
90 + 30
90 + 30 = 120 min
Classical
Sudden death. All the moves in a fixed time; nothing added.
Fischer — cumulative. A fixed extra time is added before every move, and whatever is not used accumulates. The book defines the extra time, in this mode and in delay alike, as between 2 and 60 seconds, given from the first move.
Delay — 6.3.2. The extra time does not accumulate: the main time does not run at all until the extra time has expired. Clock makers call this simple delay. The glossary also gives the mode the name Bronstein, but in Bronstein the time runs from the start and only up to the extra time is given back; the difference is below.
Multiple periods — 6.3.1 · 6.3.2 · 6.4. The time can be split into periods: a set number of moves in each, all the remaining moves in the last; time saved carries over to the next. The glossary's example is the common classical control itself: 90 minutes for 40 moves, 30 minutes for the rest, and 30 seconds per move from move one. Over the board, a flag that falls at the end of a period costs nothing if the required moves were made (6.4); digitally the time is added the moment the move number reaches the boundary, so a fallen flag is always a real overstep.
A draw claim in a quickplay finish — Guidelines III.no digital counterpart In the last two minutes of a game without increment, a player may claim a draw on the grounds that the opponent cannot win by normal means; the decision is the arbiter's, and an algorithm cannot make it. Digitally the increment and the objective measure of 6.9 take its place (section 6).
How an algorithm reads it
The clock is a small state machine. It needs each side's remaining time, the mode,
the period table and the move number. At every press the time used is deducted, the mode's
extra is applied, and if the move number has reached the period's boundary, the next period's
time is added.
Increment before the move, or after? The book gives it before; many online clocks
add it after the move. Once the move is over the remaining time is the same; the difference is
inside the move: an increment given beforehand can be spent on that very move, so within each
move the flag falls one increment later.
Bronstein and simple delay. What 6.3.2 describes is the mode clock makers call
simple delay: the main time is frozen for the length of the delay. Under Bronstein
the time runs from the start, and after the move the time used is refunded up to the delay.
On most moves the result is the same; the difference shows in time trouble: under simple
delay a player can spend the whole delay on top of the main time in that move, while under
Bronstein the flag falls the moment the main time runs out. Since the glossary uses both
names for one mode, software that offers the two separately has to define the difference
itself.
In FideLite. The game is played at 10 + 5: Fischer mode, a single period. By the
book's sum that is 15 minutes, a rapid game. In LUX, among the
Special builds, the control can be changed at the start: Fischer,
Bronstein or simple delay; separate times for White and Black; two further periods.
What it costs to apply. The clock sits outside the rule engine: a few numbers and a few additions at every press. Time has only one expensive question: when a flag falls, could the opponent mate (6.9)? Whatever the kind and the mode, flag fall ends up there.
Draw-offer periods — 9.1.1 · 11.5
What the book says
A move threshold — 9.1.1. The regulations of an event may forbid offering or agreeing to a draw without the arbiter's consent before a given move, or altogether; common thresholds are move 30 and move 40. The ban covers only offers and agreements: a correct 50-move or threefold repetition claim still ends the game.
An annoying offer — 11.5.becomes a rule digitally The book puts no number on how often a draw may be offered; an offer that annoys the opponent falls under 11.5, and the ruling is the arbiter's call. Digitally there is no arbiter to make that call, so the limit has to be written into a rule in advance.
How an algorithm reads it
An offer is not silent the way a claim is. A correct claim is verified and settled by the
system itself, with nothing asked of the opponent; only a claim that fails reaches them, as an offer. An offer takes effect only if the opponent sees it
and decides: every offer is a signal landing on the opponent's screen, and repeated, a tool
that breaks their thinking (section 7). That is why platforms and apps
may limit offers by rules of their own: keeping them closed before a given move, tying them to
a period (once every ten moves, say), or refusing a new one for a while after one has been
declined. Each is a single comparison against the move number, and an offer that comes from a failed claim goes through the same checks.
In FideLite. There is no restriction of this kind. The rule concerns the side that
runs the game, the organiser or the platform, more than anyone else; since it is an option for
an event's regulations rather than a rule of the book, the decision is left to them. Some of
the special builds can be configured this way.
Armageddon — the game that breaks the balance
At the top level chess drifts towards the draw: as long as neither side errs, the result
mostly stays level. Armageddon exists to overturn that balance for good; it is the final game,
played when a result must be reached. At super-GM level starting with White is already a
serious advantage in itself, and yet in a short blitz game White is also given noticeably
more time. In return, Black counts a draw as a win. White has to win; Black need only avoid
losing.
What the regulations say
Armageddon is not in the book; it is defined in tie-break and event regulations. Its
general shape:
The draw belongs to Black. Every game that ends drawn counts as a win for Black: stalemate, dead position, repetition, the 50-move rule.
The time difference is written into the regulations. The most common form is 5 minutes against 4 in two periods: the second starts at move 61, adds no time and only switches on a small increment. At the 2021 World Cup, for instance, White had 5 minutes and Black 4, with 2 seconds added per move from move 61. At Norway Chess White plays with 10 minutes and Black with 7, both receiving 1 second per move from move 41.
Colour by toss or by sealed bid. Usually the winner of the toss chooses; in sealed bidding, whoever accepts the least time for Black gets Black.
How an algorithm reads it
How White wins. By mate, by Black's resignation or by Black's flag. In the last two, 5.1.2 and 6.9 work as usual: if White cannot mate by any series of legal moves, the result is a draw, which again means Black.
The claim becomes a formality. The 50-move rule and threefold repetition normally need a claim. In Armageddon Black has no reason not to claim and White no reason to; the moment a threshold is reached, Black can claim in every case (9.2, 9.3). Ending the game digitally at the 50th move and at the third repetition therefore does not change the rule; it gives the same result without waiting for an arbiter.
A one-sided dead position. 5.2.2 looks at both sides at once: if no one can mate, the game ends. In Armageddon it is enough to look at White. If White can no longer mate by any series of legal moves, the result is Black's however the game ends: Black mates, White's flag falls, or Black's flag falls and 6.9 turns it into a draw. No written rule says so; but since the result can no longer change, an arbiter algorithm can end the game here. The search it needs already exists: 6.9's question “could the opponent mate”, this time asked for White after every move.
What it costs to apply. The result rule is one line: the draw goes to Black. The one-sided dead position means running the 6.9 search in the background after every move in minor-piece positions, without waiting for a flag.
In FideLite. The main builds have no Armageddon; in LUX it is switched
on with the Armageddon box in the start dialog, and it applies all three points above.
Everything is automatic: claims take effect by themselves, there is no draw offer, and after
every move a one-way search used only in Armageddon checks whether White can still mate; if not,
the game ends there as a win for Black. The game ends early only on proof: the material test, the
lock detector, or a search that closes every line without mate. If the search runs out of budget,
the game plays on. In the Soćko–Foişor position below the game does not
end: since Black's knight can shut its own king's escape square, a mate can still be built. When
Black's flag falls, the arbiter applies the same standard (6.9) and gives the game to White, just
as the appeals committee did.
A real case: Soćko–Foişor, 2008
♚♞♞♚
A mate that can be built: Black's knight on b8 shuts its own king's escape square, the knight on c7 gives check, and the white king covers a7 and b7.
In the first round of the 2008 Women's World Championship, the Armageddon game between Monika
Soćko (White) and Sabina-Francesca Foişor (Black) came down to two kings and two knights, with no
pawns. Foişor's flag fell. The arbiter declared a draw under the then Article 9.6, on the grounds
that mate could not be reached with this material; in Armageddon, that meant Black went through.
Soćko appealed, and the appeals committee overturned the ruling and gave the game to White: with
king and knight against king and knight, a mate can be built. The standard is not whether mate
can be forced, but whether it can arise by any series of legal moves
(section 6). Sources: ChessBase ↗️,
Chess Express ↗️.
Builds
Three axes: interface, rules and bot
The builds vary along three separate axes, and it helps to keep them apart.
The interface axis. The rules stay the same while the interface changes: the clickable
board, its pared-down form, numeric input, UCI, blindfold, the string board, the terminal. All
interfaces of a level give the same rulings; only the way a move is entered, the board is drawn
and the clock is shown changes. Every level has this axis: eleven interfaces in
L3, seven each in L2 and L1.
The rules axis. The interface stays the same while the rule layer changes: L3 →
L2 → L1. L3 is the full arbiter and the claim itself. L2 and L1 sit
outside the claim, but they are the clearest way to see how the rules build up
layer by layer.
The bot axis. What is measured here is neither the interface nor the rules but the
search placed on top of the rules: L1_1250elo, L2_aybars_2500, and ToledosOpponentbyMe, written
as an opponent for Toledo's program. They form a group of their own within the special builds.
Anything that fits none of the axes is among the special builds. The
packed form of L3 is part of the claim, even
though it uses deprecated methods.
The L3 family — the full arbiter
Eleven builds, one set of rules. Each implements castling, en passant, choice of
promotion piece, stalemate, dead position, threefold and fivefold repetition, the 50- and
75-move rules, draw by agreement, claims, flag fall and resignation in full, and each
produces the same fifteen result codes.
L3_packed (2,423 bytes) is the default build
packed with RegPack; it is a core part of the claim, on a par with L3.
Packing here is not compression but a self-extracting wrapper: when the file opens, the
original source is rebuilt and the game behaves identically. It gets there with deprecated
methods, though: with is a syntax error in strict mode, and eval is
blocked by any content security policy that does not allow unsafe-eval. Browsers
warn about both, and in some modes the file does not open at all. It also contains invisible
control characters, so it works when shared as a file, not when its code is copied and pasted.
In the player, L3_packed is the last build of the L3 level.
The other builds stay as plain source, because
packing does not pay off in every build.
Build
Input
Board
Clock
Time control
Bytes
L3_packed builds/FideLite.html the packed form of default
Clicking
Full board, piece glyphs
⏱ M:SSlive
600 s + 5 s
2,423
The three builds that share one row differ not on screen but inside.prompt_string_a1 and
prompt_string hold the board not as an array of numbers but as a
string of letters: an empty square is '-', the pieces are
RNBQKP and their lowercase counterparts, and the colour test is a character
comparison. They enforce the same rules, but the board representation
differs, which makes them architecture variants rather than interface ones, and
working examples of the architectures that were eliminated.
prompt_string goes a step further: it reverses the index order, so index 0 is a8
rather than a1. The pawn's direction test becomes f>i instead of
f<i, and nothing else in the code changes — proof that the direction of play hangs on a single comparison.
All builds with a clock measure time the same way; they differ only in how it is shown.
default and light round the remaining time up, so
0:00 appears only once the flag has actually fallen. The other builds that show
seconds truncate instead, so the figure on screen can be up to a second lower than the real
time. The clicking builds are therefore slightly more precise on screen, but rule correctness
is unaffected: in every build the flag falls on the unrounded time, and the display is never
off by a full second.
Result codes
Fifteen outcomes
There are fifteen ways a game can end: six decisive, nine drawn. The engine names them in
two encodings: the numerical builds and engine.js return an integer, every other build a two-letter abbreviation. Each cell below shows both.
This is my periodic table :) As with the elements, each code takes its meaning, and its
relation to the others, from the row and column it sits in, and sometimes from the letter it
starts or ends with!
W#1
White mates
B#2
Black mates
SM3
Stalemate
WT10
White wins on time
BT11
Black wins on time
TM14
Flag fall, mate impossible
WR12
White wins by resignation
BR13
Black wins by resignation
RM15
Resignation, mate impossible
754
75-move rule
5R5
Fivefold repetition
DP6
Dead position
507
50-move claim
3R8
Threefold repetition claim
DA9
Draw by agreement
White winsBlack winsDraw
The abbreviations are self-explanatory: those beginning with W are wins for
White, those beginning with B wins for Black. Those beginning with
D, those ending in M, and any code containing a digit are draws.
All fifteen codes follow these patterns.
The rows carry meaning too: the first concerns mate and stalemate, the second flag fall,
the third resignation. The fourth row holds the draws that happen automatically, the fifth
those that need a player's request. Where each row comes from, and which takes precedence when two coincide, is shown in the pipeline of endings.
One naming choice is worth explaining. DP is named after the article rather
than the specific case: 5.2.2 covers dead positions caused by insufficient material and
blocked ones alike, and the engine reports both (measured coverage on the blocked side is
93%). Naming the code after the narrower half would have been easier; naming it after the
article left the door open, so when
blocked positions were added they came under the same code, and the
list stayed at fifteen.
These fifteen codes follow FIDE; another federation's rules would produce a different list.
Under USCF, for instance, there is no RM outcome, 75 and 5R are not automatic (the tournament director may declare them but need not), and TM works quite differently. The engine could be adapted to
USCF.
Gameplay
How the L3 builds are played
This section covers how the L3 builds are played. L1 and
L2 play the same way, only more simply; the few small differences are in
the levels section. Gameplay really changes only in
the special builds, which have their own section.
How a move is entered
Clicking — default and light
Everything is done by clicking: first the piece, then the destination square. When a pawn
reaches the last rank, a picker opens, and the move is not complete until a piece is chosen;
the clock keeps running in the meantime.
The draw radio button works independently of the move. If your opponent has offered a
draw, selecting it accepts and the game is drawn by agreement — even when a claim threshold is
also met, because every claim is an offer as well. With no offer pending, if a claim threshold
is already met on the board, selecting it ends the game in a draw at once. Otherwise it stays
selected and is evaluated when you make your move: as a claim if the move reaches the
threshold, as an offer to your opponent if it does not. Making your move also declines your
opponent's pending offer.
UCI — the prompt, input and nonbrowser families
Eight builds use the same format: the three prompt builds (prompt,
prompt_string_a1, prompt_string), prompt_blindfold,
input, input_blindfold, nonbrowser and
nonbrowser_blindfold.
The format is <from><to><promotion><draw>. It is
case-insensitive: E2E4, e2e4 and e2E4 are the same
move.
The promotion letter is r, b or n. Any other
letter, q included, or no letter at all gives a queen, so
e7e8 is enough. The engine takes care of the colour; you only choose the piece
type.
Castling is entered as the king's move: e1g1 and
e1c1 for White, e8g8 and e8c8 for Black. There is no
king-takes-rook form. En passant is entered with the square the pawn actually lands on, so
it needs no special notation.
To ask for a draw, append = to the move. b2b3= means: if this
move reaches a claim threshold, I claim the draw; if not, I offer one to my opponent. The
engine decides by looking at the position after the move; if the move resets the
counter or produces a position not seen before, no claim can succeed and the request stands
as an offer. A request with a move does not accept a pending offer: the offer lapses the
moment the move is played. = on its own accepts a pending offer if there is one;
otherwise it claims a draw in the current position, or offers one if the claim does not
hold.
To resign, type r. In prompt, cancelling the dialog also
resigns; pressing Enter on an empty line does not — it counts as an illegal move, and the clock keeps running. In nonbrowser, closing the input (Ctrl+D on Unix) resigns; an empty Enter is an illegal move there too.
In e7e8=N the = is the fifth character, so it lands in the
promotion slot; since it is not r, b or n, the
result is a queen. The draw suffix is checked at the end of the string, where
N sits, so no draw is requested either. Here the SAN habit works against you
twice. To promote to a knight, type e7e8n; to promote to a knight and ask for
a draw, e7e8n=.
If a move delivers mate, a trailing = is ignored: checkmate takes precedence
over the claim, just as it does over every automatic draw.
Numeric — numerical
The same layout, with squares as two-digit numbers: the first digit is the rank, the
second the file, both counted from 0 to 7. That is the engine's own
index written in hexadecimal: a1 = 00,
h8 = 77. The driver only shifts the first digit four bits and
joins it to the second; there is no other conversion.
The code for each square in numeric input: first digit the rank, second the file. e2e4 = 1434.
Promotion is chosen with a digit: 0 bishop, 1 rook,
2 knight, 3 or anything else a queen. There are no letters anywhere
in this build. Castling again uses the king's squares: White
0406 and 0402, Black 7476 and 7472.
There is no separate draw button; draw requests travel in the input itself.
Any non-numeric input is a draw request. If the opponent has an offer pending, a
request without a move accepts it. Otherwise, if the request matches a valid claim (threefold
repetition or the 50-move rule), the game ends there; if not, it goes to the opponent as an
offer. That gives four cases:
A request with a move.6777asd or 6777? — the extra characters can be anything; the move is played normally and carries the request with it. The move drops the opponent's pending offer; the request becomes a claim on the position after the move, or a new offer if the claim does not hold. If the move delivers mate, the request is never processed: checkmate ends the game on the spot.
A request with no move.asd, or even the single character
g — with no offer pending, a claim on the current position, or an offer if the
claim does not hold.
Accepting a pending offer. The opponent also types something non-numeric, but
without a move, and again the characters can be anything: dssdf accepts the offer
at once. As soon as both offer bits are set, the game ends by agreement — even when a claim
threshold is met at the same time, because every claim is an offer as well. Input with a move,
such as 1434x, does not accept: the offer lapses the moment the move is
played.
Numeric but illegal input.6500 — neither claim nor offer; the
move is rejected and the prompt appears again.
Attaching the request to a move is not a convenience but a requirement of the Laws. When “the counter stands at 99 plies, and this move makes it
100”, or “the position has occurred twice, and this move brings the third”, the claim
rests on the move about to be played. That is why the request travels with the
move, and the behaviour follows 9.2 and 9.3 exactly.
The fourth case has a side benefit. The clock in this build is not live; it is shown in
the title of the prompt() dialog. Since numeric but illegal input reopens the
dialog without triggering anything, it is also the only way to check the remaining time.
Resignation has its own channel: Cancel, Esc, an empty Enter and leaving the tab all
resign. 0, 00 and a space do not — all three are numerically
zero, so they are just illegal moves. Typing r does not resign either; it
contains a letter, so it counts as a draw request.
The awkwardness of this interface is deliberate: the aim is to do everything the other builds do, given good-faith input, in the fewest bytes. It is the lowest rung at which the game can be played without breaking the engine.
Promotion, resignation and draws
Everything other than the move itself works differently from build to build. In default and light each has its own control; in the others, everything goes through the same input as the move.
Build
Promotion
Resignation
Draw
default · light
A picker opens; the move is not completed until a piece is chosen
⚐ button
Draw radio button
The UCI family
5th character of the move: r b n, anything else a queen
r; also Cancel in prompt, closing the input in nonbrowser
= appended to the move; = alone accepts or claims
numerical
5th character of the move: 0 bishop, 1 rook, 2 knight, anything else a queen
Cancel, Esc, empty Enter, leaving the tab
Any input containing a non-numeric character
Indicators
The engine itself never reports the game state; what is shown is entirely up to the
driver, and the indicators multiply as the byte budget grows. Six builds report the state in text: default, input, nonbrowser and the three prompt builds. light shows only the clocks and the draw radio; the three blindfold builds and numerical say almost nothing.
Build
Coordinates
D?
C!
M=R=
Clock
Last move
default
a8-h8 / h1-a1board orientation
—an amber outline on the radio instead
Yes
Yes
⏱ 10:00live · bottom row is to move
Green outlineon the destination square
light
—
—the radio is selected instead
—
—
♔ 10:00live · bottom row is to move
—
input nonbrowser
Rank number + file letterflips with orientation
Yes
Yes
Yes
W: 600sbottom row is to move · live in input, on move in nonbrowser
—
input_blindfold nonbrowser_blindfold
—
—
—
—
W:600s B:600sfixed order · live in input, on move in nonbrowser
[e2e4]
prompt prompt_string_a1 prompt_string
a8 / h1corner square only
Yes
Yes
Yes
W: 600son move · bottom row is to move
—
prompt_blindfold
—
—
—
—
W:600s < B:600son move · fixed order, the arrow shows who is to move
[e2e4]
numerical
—
—
—
—
900000 900000on move · raw ms, unlabelled
Raw input
In the builds with indicators, the fields always appear in the same order.
default's status line has the form a8-h8 C! M=0 R=1; input, nonbrowser and the prompt builds write the same fields above the board.
M= is the ply counter — the halfmove clock, in FEN and engine parlance. It resets on a capture and on a pawn move. At
M=100 the 50-move draw can be claimed; at
M=150 it is declared without a claim. In practice: if you see
M=99 on screen, the move you are about to play will reach the threshold
as long as it is neither a capture nor a pawn move — which is exactly what
b2b3= in UCI is for.
R= is the repetition count. This is how many times the current position has
been seen. R=3 opens the threefold claim; R=5 is an automatic
draw. It is not a countdown but a value recalculated after every move: it drops back to
R=1 as soon as a new position appears.
Each number has two thresholds: one opens the claim, the other ends the game automatically.
At levels without claims, only the second applies; the details are in
the levels section.
C! means the side to move is in check — so on your turn it is always
your own king. D? marks an open draw offer. In
input, nonbrowser and the prompt builds the two share one slot, and D? takes
precedence; default does not show D? at all. When the game ends,
default replaces the whole status line with the result code, while
input and nonbrowser write it into that slot only, leaving M= and
R= in place. Of the blindfold builds, input_blindfold and
nonbrowser_blindfold append the result to the end of their single line; the
prompt builds, prompt_blindfold and numerical show it in an
alert() window.
D? means an offer bit is set; it does not say who made the offer. Normally
the offer you see is your opponent's, but if you enter = without a move, your
own offer shows up as D? too. Only default makes the
distinction, and there it is the only signal: the draw radio gets an amber outline when
it is your opponent's offer that is open.
Playing the builds without indicators. In all four, keeping track of check, the counters and
repetition is entirely up to you; the engine reminds you of nothing. All you have left
are the last-move log and the clock, and both tell you more than you might think.
In the blindfold builds the draw suffix shows up in the log: if your opponent
played e7e5=, it is logged as [e7e5=], so you can see that they
offered or claimed a draw. The same goes for promotion:
e7e8n is logged as [e7e8N], so you can check what they
actually promoted to without holding the board in your head. The side to move is shown by the running clock in input_blindfold and by the arrow between the two clocks in prompt_blindfold; in nonbrowser_blindfold the clocks only update after a move, so the one that went down last tells you.
In numerical the log is the raw input itself, so an opponent's draw request
shows up as non-numeric characters, whatever they are: 1434asd and 1434x are both the move e2e4 plus a request. A request without a move leaves no trace. The clock is two unlabelled millisecond
counts — White on the left, Black on the right — and the one that is going down tells you who is
to move.
If you want to see the board. In the L3 builds played
through prompt(), the board is not shown when the game ends; only a result notice
(alert) appears. The final state is still in memory: type the
variables described on the engine tab into the developer console.
z gives the result and b the board in its own representation;
t, e, c, n, R and
o can be read the same way. In the player the build runs in a frame, so select that
frame in the console. L1 and L2 do not need
this: the dialog never closes and the result stays inside it.
How the result is read in light
light does not display the result; the board, the clocks and the draw radio already on screen are
enough, and not a single extra mark was added for it. The clock tells a win from a draw beyond
doubt. Which kind of win or draw it was, you work out yourself by following the game and ruling
things out step by step. Since the board and the clocks turn with the side to move, “the side to move”
means the side at the bottom when the game ends. Each clock is headed by the king of its own colour
(♔ White, ♚ Black), so the king on the bottom clock tells you whose move it was.
Clock
What happened
White to move
Black to move
Stopped at 0:00loss
No legal move, king attacked
B#
W#
⚐ pressed
BR
WR
Time ran out
BT
WT
Runs below zerodraw
No legal move, king not attacked
SM
⚐ pressed
RM
Time ran out
TM
Both clocks stoppeddraw
Radio pressed
3R50DA
After a move; the last move is irreversible
DP
After a move; the last move is reversible
5R75
For the last two rows, the elimination works like this. If the last move was a capture or a pawn move, the
result is a dead position (DP): right after an irreversible move, neither a repetition nor the 75-move
rule can occur. If the last move was reversible but let an en passant right from the previous move
lapse, it can still be a dead position: a pawn's double step locked the board, and the lock is recognised only
once the en passant right is gone. If the last move lost a castling right, the chain of repetitions is broken,
so the result is the 75-move rule. If none of these applies, it is 5R or 75. In other
words, you narrow it down by ruling out what cannot have happened rather than by spotting what did.
For games ended with the radio: if the radio was already selected when your turn came, it was an
agreement (DA); otherwise a claim (3R or 50).
Telling 3R from 50, or 5R from 75, is not always possible:
both are counter thresholds and can be reached on the same move. For light's purposes this does not matter;
what matters is knowing it is a draw. If you are curious, open the browser console with F12 and look at
n (the ply counter: 100 for the 50-move rule, 150 for 75) and R (how many times the
position has occurred: 3 for threefold, 5 for fivefold); in the player the build runs in a frame, so select
that frame in the console.
On mate and stalemate the clock drops to zero at once. The clock of the side with no move left goes
straight to 0; after mate it stops at 0:00, after stalemate it keeps running below zero. Mate
takes precedence over every draw: even if the last move both mates and completes the 75th move, the result
is mate.
The draw radio doubles as the offer indicator. If the radio is checked when your
turn comes, your opponent has offered a draw: select it to accept, or make a move to decline. You can withdraw your own offer by clicking the radio again before you make your move.
Negative time is written as 0:-3 or -1:-14; since the
clock rounds up, the display still reads 0:00 for a second or two after the flag
falls before turning negative. There are no M= or R= counters; keeping
track of them is up to the player, as in the builds without
indicators.
The light builds of L1 and L2 have no clock; there the board is the only channel. If the
side to move has no move, it is mate when its king is attacked and stalemate when not; in L2, a board that
freezes while moves remain means a draw arising from the board, though which one is not shown.
All three are written without a DOCTYPE or <head>
tags, so the page opens in quirks mode. Only the board is styled: square colour, square size
and magnification (three times in
L1–L2, twice in L3). In L3 the style element also centres the board and the text and enlarges the promotion buttons. The
buttons, the radio and the text keep the browser's default look.
Levels
L1 and L2 — the lower rule levels
These are the two rungs of the rules axis below L3. Their interfaces are seven of L3's eleven — the
clickable board, its pared-down form, numeric input, UCI, blindfold, the string board, the
terminal; what changes is how far the rule layer goes.
The levels build on each other. L1 knows only playability: is the move legal, has the
game ended in mate or stalemate? L2 adds the draws that arise from the
board. L3 then steps off the board: the clock, resignation,
offers, claims.
Rule / driver part
L1
L2
L3
Move generation and king safety
✓
✓
✓
Castling, en passant, choice of promotion
✓
✓
✓
Checkmate · Stalemate
✓
✓
✓
Ply counter n
—
✓
✓
Repetition count R
—
✓
✓
Legality test for en passant
—
✓
✓
Castling rights as a bitmask c · C
—touched-squares ledger
✓
✓
Dead position — material and blocked
—
✓
✓
Fivefold repetition · 75 moves
—
✓
✓
Clock and flag fall
—
—
✓
Resignation
—
—
✓
Threefold repetition · 50-move claim
—
—
✓
Draw by agreement
—
—
✓
The 6.9 search — flag or resign with mate impossible
—
—
✓
Result codes it can produce
3
6
15
Status line
coordinates, C!
+ M=R=
+ clock, D?
The four faint rows in the table are not rules but the machinery that supports them. They are not
missing from L1, they are unnecessary: if repetition is not counted,
there is no point keeping the table, nor in testing whether an en passant capture is genuinely
legal — that test exists only to keep
the repetition count correct, so without the count it is not needed. Castling rights depend on the same chain: with no repetition key, they need not be
kept canonical. L1 marks the squares a move touches in a ledger, and castling needs the king's
square and the rook's square never to have been touched. That chain is why L1 can stop at 1,404 bytes.
On the code side the difference is sharper still. L1 has four functions —
G, T, U, L — and the
verdict is not even a function of its own but inlined into the expression that applies the
move. L2 adds C, the blocked-position detector and an
inlined material test. A separate M, V, O,
Z, D, F, A and the 6.9 search
H come into being only in L3.
The builds of both levels side by side. The files are in builds/L1/ and
builds/L2/; the last two columns give each level's size in bytes, so you can read
the cost of the rule layer for each interface along its row.
Moving from L1 to L2 costs between 486 and 579 bytes for the same interface.
A packed implementation of the L1 rules, DOM_911, is among the special builds; when the game ends, the board turns a single colour.
Gameplay
Gameplay is as in L3; the differences come from the rules and
can be read off the table above. With no player layer there is no clock, no resignation, no
offer and no claim, so none of their controls or indicators either. Moves and promotions are
entered the same way. With no clock to show, input_blindfold
starts the line with the side to move: W or B. The only difference that does not come
from the rules is in the two builds that use prompt().
The dialog never closes. In numerical and prompt_string, if
Cancel, Esc, an empty Enter or switching tabs closes the dialog, the loop reopens it at once.
No alert() appears when the game ends either; the result is written in the same
dialog — in prompt_string into the check slot, in place of C!; in
numerical in place of the last move, as a number. The dialog keeps coming back with the
same screen, any move entered is ignored, and the game ends only when the tab is closed. This is
deliberate. The same behaviour was wanted in L3 too, but there it broke the clock: with
the dialog never closing, the clock froze — in a timed game, a breach of the
rules. So in L3 closing the dialog counts as resignation, and the never-closing dialog
stayed only in L1 and L2, which have no clock.
Special builds
Off the ladder
The builds/special/ folder holds the files that sit off the ladder, between
its rungs or above it. A file ends up here for one of three reasons: its interface or rules branch off the
ladder (LUX, which raises the ceiling above L3, the platform equivalents, ChessMiniature
without the detector, chessinbytes under 1 KB); it measures something other than the interface
or the rules, namely the bot axis; or it breaks the project's general principles,
being written with deprecated methods that make the browser warn or fail. Most are projects in
their own right: the files of six separate GitHub repositories live here as well, each
linked to its own repository. None is part of the claim; they are kept for comparison
and for the record.
Every game file can be opened in the player under the special
level; each repository's README has the details.
Chess LUX is FideLite with a looser byte budget and higher rule accuracy. Its engine
is engine_4x; the rules are those of L3, but it goes further
in two places. The blocked-position detector takes in, beside kings, pawns and
bishops, the knights, rooks and queens that are boxed in and can never move; the detector grows
from 432 bytes to 961. The 6.9 search also runs on a far richer budget: 300 plies and
400,000 nodes instead of 9 plies and 2,000, and after every move a short dead-position search
runs for both sides.
Chess960 comes on top: the starting position is set up from its official number (0–959),
and castling is done by moving the king onto its own rook.
The result is measurable. Of the 204,060 games CHA-Solver found wrongly decided on time,
it gives the correct verdict in 204,058: 99.999%. It closes all but
2 of the 3,676 games FideLite misses: 3,663 with its lock scan, 11 with
the search. Its lock scan recognises 52,146 games as locked — 4,011 more than FideLite's —
and in that class coverage rises from 92.98% to 100%. The remaining 2 are not
wrong verdicts but undetected draws: with no proof found, the win is awarded as a precaution,
as in FideLite. The repository
also contains a benchmark page for testing the engine on its own, and the CHA-Solver
data.
The interface is closer to a real platform, too. The clock runs on
performance.now, a monotonic timer that is unaffected if the system clock is moved
back or forward. A FEN and a time control can be entered at the start; White and Black can be given different
times, two further periods can be added, the board's orientation can be chosen, and threefold
repetition and the 50-move rule can be made to apply without a claim. The FEN is checked for
legality: one king of each colour, no pawn on the first or eighth rank, and the side not to move
must not be in check; castling rights and an en passant square that disagree with the board are ignored,
and invalid input does not start the game. The type of time control can be chosen: Fischer increment, Bronstein delay or simple delay, with sudden death when the
increment is zero. FIDE 5.2.3 is applied too: no draw by agreement until both sides have
made at least one move; the move from which offers are allowed can also be set (½ Offer, default
1). The draw radio turns green not only when the opponent has an open offer
but also when a 50-move or threefold claim would be valid.
Dragging with the right mouse button marks squares with circles: every square along a
straight or diagonal line, otherwise just the two end squares; right-clicking a single square marks only that one. A piece's legal destinations are marked in two ways, as in the Lichess and Chess.com equivalents: a dot in the middle of an empty square and a ring around a square holding an enemy
piece. A king in check has its square highlighted in red, as in the equivalents. Captured
pieces and the material balance are shown beside the clocks. The end-of-game indicators and the whole layout are roomier.
Armageddon. The Armageddon box in the start dialog turns the game into an
Armageddon game:
A time difference is required. Ticking the box unticks ⚖ by itself and opens Black's time box; the first box becomes White's time. White must have more time than Black, for example 5 minutes for White and 4 for Black; if the times are equal, or White has less, the input counts as invalid and the game does not start. Unticking Armageddon brings ⚖ back.
No claims and no offers.Auto 3R/50 is ticked and locked: since a draw counts as a win for Black anyway, threefold repetition and the 50-move rule end the game without waiting for a claim. The ½ button is hidden as well; there is nothing left to offer.
A one-sided dead position. After every move the arbiter asks “can White still mate?”, and if not, the game ends with DP ⟹ BA. There is no separate code: the question DP asks for both sides in a normal game is asked in Armageddon only for White's chances of mate. The question is answered by the material test, the wider lock detector and a 9-ply search. The search rules mate impossible only when it has closed every line; if its budget runs out, the game plays on.
Flag and resignation. These work as under FIDE: the 6.9 search is asked as always, with 300 plies and 400,000 nodes. If the opponent can mate, the player whose flag falls or who resigns loses (WT, WR, BT, BR); if not, the result is TM or RM. In Armageddon TM and RM are wins for Black too, so White's flag or resignation is BA either way.
The score. The result line shows the score together with the code: W# ⟹ WA, 3R ⟹ BA. White's wins are the three codes beginning with W; every other code is Black's.
Armageddon usually has two periods: the second adds no time and only switches the
increment on. For 2 seconds per move from move 61, leave the main increment at 0 and enter
per2# 60 +0 min +2 s. Armageddon works together with Chess960 and FEN too. How the
rules work is explained in section 10 of the Rules tab.
Black must be sure of the rule. Even with White down to king and knight and Black to
king and rook, a helpmate exists: Black's own rook blocks the king's escape square. So the game
does not end there by itself, and a Black player who resigns or lets the clock run out at that
point really loses (the table in section 6). Something similar happened at a
world championship in 2008, with king and knight against king and knight:
Soćko–Foişor. The arbiter also has to be able to prove the draw: if no proof
is found, the win goes to White as a precaution. In LUX this is very rare, but it can
happen.
Build
What it is
Input
Bytes
LUX builds/special/LUX.html in the repo: index.html
The full arbiter with a rich interface: drag and drop, right-click marking; FEN, time control, clock mode (Fischer, Bronstein or simple delay), Chess960 and Armageddon picked at the start.
An arbiter that applies Lichess's rules: 2,804 bytes with the clickable board, 1,088 in its numeric form without a board. The clicking build also plays like Lichess: the platform's colours, markers on reachable squares, the last move and a king in check highlighted, and promotion chosen on the board itself; the clock is 600 + 5 seconds, as in L3. The numeric form uses the L3_numerical interface and is packed.
Build
Interface
Input
Bytes
Lichess-equivalent builds/special/Lichess-equivalent.html in the repo: index.html
Lichess's colours and markers; live clock, ½ and ⚐
Clicking
2,804
Lichess-equivalent_minimum builds/special/Lichess-eqv_min.html in the repo: numerical_packed.html
The same idea with Chess.com's rules: 2,643 bytes with the clickable board, 1,050 in numeric form. The clicking build uses Chess.com's colours and markers; otherwise it plays like the Lichess equivalent. The numeric form again uses the L3_numerical interface and is packed.
Build
Interface
Input
Bytes
Chesscom-equivalent builds/special/Chesscom-equivalent.html in the repo: index.html
Chess.com's colours and markers; live clock, ½ and ⚐
Clicking
2,643
Chesscom-equivalent_minimum builds/special/Chesscom-eqv_min.html in the repo: numerical_packed.html
The L3_numerical interface; packed
Numeric, prompt dialog
1,050
Both platforms beside FIDE
Why the platforms apply the rules as they do is covered in the federations section; in short, the two equivalents differ as follows:
Rule
FIDE · L3
Lichess
Chess.com
Threefold repetition
By claim
By claim
Automatic
50 moves
By claim
Automatic
Automatic
Fivefold repetition
Automatic
Automatic
—ends at threefold
75 moves
Automatic
—ends at 50
—ends at 50
Dead position
Material and blockedcode DP
Material onlycode IM
Material only, but close to USCFcode IM
On flag fall
The full 6.9 searchhelpmate
6.9, but material only
Only the opponent's pieces are counted
On resignation
The full 6.9 searchhelpmate
6.9, but material only
Always a loss
En passant in repetition
Legal
Legal
Pseudo-legal
Both equivalents implement only the material branch of dead position, so to keep the mnemonic
accurate their two-letter result code is IM (insufficient material) rather than
DP. It is triggered by the same material test that reports DP in
the main builds.
The bare-bones rules of chess, line by line. This repository was written as teaching
material. Right-click the page and choose “View page source”: nearly every rule that arises from
the board itself — move generation and king safety, castling, en passant, promotion, checkmate,
stalemate, fivefold repetition, the 75-move rule and dead position recognised from material —
can be read from start to finish in a single HTML file under two thousand bytes. The aim is to
show intuitively how far chess can be simplified and how few characters its rules need;
it was written in the hope that it can serve as material for software and algorithms courses.
A two-player chess arbiter in 1,893 bytes of one HTML file, and its twin in 1,815. Both enforce the same rules: the L2 level without the blocked-position detector, so a dead position is recognised from material alone, while fivefold repetition and the 75-move rule are declared without a claim. What separates them is how the board is stored — one in letters, as a FEN does, the other in numbers. Side by side, they show what the choice of representation costs: the letters cost 78 bytes. Neither is packed; the source can be read as it is. Gameplay is the same as in L2.
For readability the two builds are written in parallel: function and variable names are
defined in the same order and the lines break at the same places, so the two files can be read
side by side, line by line. That makes them far easier to read than the other builds, which are
written on a single line. The blocked-position detector was left out on purpose: it is not a well-known
algorithm, and it is hard to follow for someone who does not know chess. The remaining rules are found
in practically every chess engine and are quick to grasp.
Build
Board representation
Input
Bytes
L2_string_base builds/special/L2_string_base.html in the repo: index.html
In letters, like a FENflipped index
Clicking
1,893
L2_base builds/special/L2_base.html in the repo: hexadecimal.html
In numbers
Clicking
1,815
Since dead position is recognised from material alone, these two builds also report
IM rather than DP.
Two-player chess in bytes: three builds, each a single HTML file under 1 KB; two are packed, and the smallest is plain source, because packing gains it nothing. All three enforce the same rules — full legality, castling with every condition, en passant, promotion to any piece. All three use the 0x88 rule code of L1; they differ in gameplay and indicators. There is no clock, no counter, no repetition, no material test, no offer and no result code; mate and stalemate are not told apart. The name reflects the byte budgets, which step down from the 911-byte main build through the 722-byte intermediate build to the smallest, at 650 bytes.
Build
Input
Board
End of game
Bytes
DOM_911 builds/special/DOM_911.html in the repo: index.html
Clickingpromotion from buttons above the board
Full board, piece glyphsside to move at the bottom
Every square turns white when no move is leftmate or stalemate, which one is not shown
911
DOM_minimum builds/special/DOM_minimum.html in the repo: minimal.html
Clickingpromotion read from the text box as the move is made: 1 knight, 2 rook, 3 bishop; an empty box, 0 or anything that is not a digit is a queen
Pieces as hexadecimal digitsfixed orientation
Not recognisedclicks get no response
722
Lowest builds/special/Lowest.html in the repo: Lowest.html
Numeric, prompt dialogsquares by rank and file digit: e2e4 = 1434
None, last move only63 before the first move
Not recognisedno further moves are accepted; the dialog keeps showing the last move
650
The bot axis
The third axis: a search placed on top of the rules. This section covers the bot builds of the two levels and the bot written as an opponent for Toledo's program. L1_1250elo and L2_aybars_2500 are played against the bot on the clickable board; the status line shows no coordinates. Aybars has its own repository: Aybars_ChessAI ↗️. There it is index.html; the repository also holds its Black-side twin, black.html, the strength measurement against Stockfish and an account of how the bot was built.
Level
No bot
With bot
Cost of the bot
L1
1,404builds/L1/L1.html
2,051builds/special/L1_1250elo.html
647
L2
1,922builds/L2/L2.html
7,927builds/special/L2_aybars_2500.html
6,005
L1_1250elo runs on the same new board as L1, the 0x88 board, on the core of engine_4x; almost all of the first row's difference is the bot, and the remaining 35 bytes or so are the speed tweaks the rule core takes from engine_4x. L2_aybars_2500 still runs on the previous board representation, a flat 64-square array, while L2 has moved to the new board; the second row's last column therefore carries the difference between the two boards as well, not the bot alone. Nor are these two measurements of the same thing: the two levels carry different bots. As the 1250 and 2500 in their names suggest, the one in L1 is a light bot, while Aybars in L2 is a far stronger one whose search and evaluation go much further. Within each level the rules are the same with or without the bot, so the difference comes largely from the bot itself; the roughly ninefold gap between the two reflects the bots' strength, not the rules. The L2 bot build also ends the game on threefold repetition and 50 moves; that is inside the difference too, but only a small part of it.
The two level bots sit outside the claim, and that is a consequence of the design rather than a caveat. Had the bot been the priority, the board representation itself
would have been written differently from the start: a flat, square-centric array makes “what
is on this square?” cheap and set operations expensive, and a search tree wants exactly the
reverse. The choice here was made in favour of rule density; the bot was added on top of it
afterwards.
What made this possible is that the rule layer stands apart: since the engine core knows
nothing about the driver, layering a search on top of it, or intertwining the two, does not
break rule equivalence. The bot builds leave out the clock and the player-to-player
interactions; the rules stay the same.
A deliberate exception in L2 · bot. With no player layer, the
claim thresholds would normally trigger nothing, yet this build ends the game at
R=3 and M=100, on purpose. The bot's search uses those
same two thresholds internally, and raising the game's thresholds while leaving the search's unchanged would make the two disagree. In plain L2 those thresholds really do trigger nothing:
the game ends only at the automatic R=5 and M=150.
Toledo
Oscar Toledo G.'s program and the bot written as its opponent are here to test one question: “could a more capable build — a stronger bot, a better interface — be written at the same rule level in about as many bytes as Toledo's?” These two have no repository of their own; they live in the builds/special/ folder of the FideLite repository.
Both were given one extra rule: the 50-move rule. After 100 plies without a capture or a pawn move, the game freezes silently, just as at mate and stalemate — no further move reaches the board and nothing is shown on screen. The point is to keep matches between the two bots from running forever or getting stuck in a loop. The counter is updated, and checked, only where a played move is written to the board; the search never looks at it, so neither bot's strength has changed.
Oscar Toledo G.'s chess program, one of the best-known tiny chess programs: L1-level rules in a little over 2 KB, and a bot of roughly 1200 Elo that searches to a fixed, shallow depth. I shrank it a little further with ES6 techniques while keeping it equivalent, and gave its interface a light rework.
Clicking, against a botpromotion chosen beforehand from the list below the board
The bot I wrote on this engine as an opponent for Toledo's program: L1-level rules plus the 50-move rule, an iterative-deepening alpha-beta search and a time budget of about 50 milliseconds per move. The bot plays White.
Clicking, against a bot
1,904
ToledosOpponentbyMe uses the same search and evaluation as L1_1250elo; the differences:
Difference
L1_1250elo
ToledosOpponentbyMe
Time budget
~250 ms
~50 ms
Bot plays
Black
White
50-move rule
No
Yesin the driver, not in the search
Check warning and result
C! in the status lineat the end W#B#SM
No status lineat the end the board freezes
Document mode
Standards<!DOCTYPE html>, <meta charset>
Quirksno DOCTYPE, a BOM
Obsolete elements and attributes
None
Yes<center>, bgcolor, cellspacing, width; .bgColor in JS
This tab covers the engine itself. The rules it implements are on the Rules
tab; the design history, the edge cases and the sources of engine_4x.js and engine_string.js are on the Technical detail tab.
Engine
engine.js — the core without an interface
The engine without a front end: pure state and functions — no DOM, no dependencies, no
build step. One line, 1,527 bytes. The source below is the actual file from the
builds/ directory, loaded when the page opens.
Press a chip to highlight the parts of the source that belong to that concept; one concept is highlighted at a time, and pressing the same chip again clears it.
The With the simplest driver button at the top of the window opens
L3_numerical in the same view: this engine plus the shortest driver that makes it
playable, in a single 1,715-byte HTML file. The driver is a 283-byte loop. It reads a move from
a prompt dialog as digits, two per square: rank first, then file, each from 0 to
7 — the square's hexadecimal index. So e2 is 14, e4 is
34, and 1.e4 is 1434. An optional fifth digit selects an
underpromotion (0 bishop, 1 rook, 2 knight). The same dialog shows both clocks and the last
move, and an alert reports the result code when the game ends. An empty input
resigns; any non-numeric input accepts a pending offer, or else claims or offers a draw; and a
non-digit typed after a move plays the move first — which drops a pending offer — and then
claims or offers. In this view only the driver's code is
highlighted, together with the own-piece check the driver adds to the engine.
The engine body is almost identical in the two files, with three differences. In the
driver, L itself checks that the piece on the origin square belongs to the side
to move (~b[i]&8^t&&), so O drops its colour filter
(p&8^t||); and D, F and A do not exist
as separate functions — their work is inlined in the loop.
The signature is in the header: b=[c=un=15,e=y=t=8,n=o=0] — read in order,
the names it assigns spell c·un·e·y·t. Most of them have a job: c is the
mask of the four castling rights (15), e the en passant square (8: none),
t the side to move (8: White), and n and o the ply
counter and the offer bits. Two are there only for the signature: un is never
read, and y is never read by the engine — drivers use it to hold the last move or
the chosen promotion piece. The signature costs five bytes: three for un=, two
for y=.
engine.js—
How the board is represented
The board is a flat array, but not a 64-element one: it uses the 0x88 layout. Each
rank occupies 16 cells in the array; the first eight are real squares, the last eight an
off-board gap. The index is 16 × rank + file, both counted from 0: a1 is 0, h1 7, a2 16, e4
52, h8 119. In hexadecimal, the two digits are the coordinates themselves — the high digit is
the rank, the low digit the file: e4 is 0x34, h8 0x77. That is why
the numeric drivers take each square of a move as two digits.
The array is 120 elements long: h8 sits at index 119, so the gap to the right of the last
rank is never allocated. Of the 120 cells, 64 are squares and 56 are holes. The holes
are never assigned: the array is sparse, and reading a hole returns
undefined.
The 0x88 board, each cell showing its index in hexadecimal. The left eight columns are the squares (a1 = 00 bottom left, h8 = 77), the right eight the holes. The top row's holes lie beyond the end of the array: b.length is 120, that is 0x78.
The layout is named after the classic off-board test: a 0x88 engine checks whether an
index is on the board with i&0x88. An index that runs past the h-file sets
the 0x08 bit; one that runs past the eighth rank, or below the first, sets the
0x80 bit. This engine never uses that mask. A hole holds undefined,
and undefined fails every test the engine applies: in the ray walk
b[i]<1 is false, so the walk stops; in the target filter
b[x]+~t>>3 comes out 0, so the target is rejected. Indices before the
start of the array or past its end read as undefined in the same way. The array
methods (map, some, every, indexOf) skip
the holes too, so every loop over the board visits only the 64 squares.
What the gap really buys is direction. The difference between two squares,
d = f − i, is 16 × the rank difference + the file difference; since the file
difference stays between −7 and 7, every pair of rank and file differences maps to a distinct
value. In a 64-element array this does not hold: +7 can mean seven squares to the right on
the same rank, or one up and one to the left. In 0x88 the difference alone identifies the
line: a multiple of 15 is one diagonal, of 16 a file, of 17 the other diagonal; anything else
lies either along the rank or on no line at all. The details are under
Geometry.
The value of a square is the code of the piece on it: a number from 0 to 14 that fits in
four bits. A four-bit unit is called a nibble (half a byte) and is written as a single
hexadecimal digit: 0–9 as digits, 10–14 as the letters a–e. That is
why the starting position can be written as a hex string — White's back rank is
dcebaecd — with one character per square. The four bits fall into two
parts:
The 8 bit: colour. Set for White, clear for Black. The side-to-move variable
t holds the same value: 8 for White, 0 for Black. Comparing a piece's colour with
the side to move is therefore a single XOR: p&8^t.
The lower three bits: type, from 1 to 6. 1 pawn, 2 king, 3 queen, 4 knight,
5 rook, 6 bishop. p&7 gives the type (P in the engine); code =
type + colour, so a white piece's code is the black one's plus 8. An empty square is
0; 7, 8 and 15 belong to no piece.
Piece
Type P
Black
White
Short-range pieces (1, 2, 4)
Pawn
1
10001
91001
King
2
20010
10 (a)1010
Knight
4
40100
12 (c)1100
Sliding pieces (3, 5, 6)
Queen
3
30011
11 (b)1011
Rook
5
50101
13 (d)1101
Bishop
6
60110
14 (e)1110
The engine has a single expression for the sliding pieces: P+P*4%x&S().
x is the step size — 1 along a rank, 16 along a file, 15 and 17 along the two
diagonals — and S() says whether the path is clear. The direction is filtered by
the expression's lowest bit, that is, by its parity. On a straight line (x 1 or
16) P*4%x is always even, so the parity comes from P itself: the odd
types, queen (3) and rook (5), pass. On a diagonal (x 15 or 17),
4P — 12, 20 or 24 for the three sliders — exceeds the modulus only for 5 and 6;
the odd modulus is then subtracted once and the parity flips. The result comes out odd for 3
and 6 (queen, bishop) and even for 5 (rook). That lowest bit is ANDed with the result of S(): if the
path is blocked, the move fails. No separate branch for the queen was ever written: it comes
out odd in both cases.
Each of the three short-range pieces has its own branch in G, and
G tells them apart by the type itself: P-4 zero means a knight,
P-2 zero a king, P-1 zero a pawn; if none is zero, the piece is a
slider.
The order of the codes is not arbitrary; each placement pays off as a shortcut somewhere
in the engine. Making the pawn 1 means that in the pawn codes (1 and 9) both the 2 bit and the
4 bit are clear, while every other piece has at least one of them set. M's ply
counter relies on this: if !b[x]*b[i]&6 is zero, either a piece was captured
or a pawn moved, and the counter resets. Placing the sliders at 3, 5 and 6 is what the parity
expression above requires. And the king at 2 and the knight at 4 serve V's
material count: p%8>2 counts queen, knight and rook (3, 4, 5) with their
weights and leaves king and pawn out, while p%8>5 has already sent the bishop
(6) to a branch of its own.
The representation is square-centric: information is attached to the square, not to
the piece. The opposite approach is piece-centric: in a bitboard engine the information is
spread across one bit set per piece type, so “what is on this square?” becomes expensive
while set operations become cheap. Here it is the other way round: a square query is a
single array access, b[i], four characters. The one exception is V.
For the blocked-position scan it turns pawns and kings into BigInt bitboards, because there
the question is not “what is on this square?” but “how far can this set grow?” — exactly what
set operations are for. Those bitboards are 64 bits wide: since map skips the
holes, the bit order is not that of 0x88 but that of the plain board, 8 × rank + file.
Six calls to write a driver
A driver needs no chess knowledge: validate the click or the typed move with
L, play it with A, then redraw the screen. The other four are for
results and display: Z reports the endings that arise on the board,
D and F those that a player claims or the clock brings about, and
T drives the check indicator.
L(from, to)
Squares are 0x88 indices. Returns true if the move is legal and false otherwise; a move that leaves the mover's own king in check is illegal too. It tests a single move: a driver that wants to show every destination of a piece must ask about each square in turn. The piece on the origin square must belong to the side to move; engine.js does not check this, the driver does. For an opponent's piece the answer is meaningless: the pawn's direction is taken from the side to move, and the target filter lets the piece “capture” one of its own colour. Call it immediately before A: in castling, this call is what records the rook's square.
A(from, to, promotion)
Plays the move for real: updates the ply counter and the castling rights, sets the en passant square, switches the side to move, records the position in the repetition table and returns the result code. The promotion type is 3 queen, 5 rook, 6 bishop, 4 knight; if it is omitted, the pawn promotes to a queen. It does not check legality; that is L's job.
Z()
The result that follows from the position alone: checkmate, stalemate, dead position, fivefold repetition, the 75-move rule. 0 while the game continues. A calls it before returning, so there is no need to call it separately.
D(side)
The given side offers a draw or accepts a pending offer; if that brings no agreement, draws by claim (threefold repetition, the 50-move rule) are returned here too. D() with no argument only evaluates, leaving the offer bits untouched.
F(side, kind)
Kind 0: that side's flag has fallen. Kind 1: that side resigns. If the opponent cannot possibly checkmate, the result becomes a draw — FIDE Articles 6.9 and 5.1.2.
T()
Is the side to move in check? It locates the king itself and tests that square. T(square) asks the same about any square: is it attacked by the opponent of the side to move? For display only — legality is L's job.
Throughout, a side is given by the colour bit of the piece code:
8 White, 0 Black, and t holds the same value. The
clock is entirely the driver's business — the engine neither stores nor measures time; the
driver decrements its own clock on each tick and calls F(side,0) when one
reaches zero.
Three more things are worth knowing. The engine initialises itself: as soon as the
file loads, the board, the side to move, the castling rights, the counters and the
repetition table are in the starting position; there is no separate setup call.
There is no undo:A only moves forward. L copies the
board for its own trial move and restores it, but does not expose this. A driver that wants
undo must save b, e, n, t,
c, k and R before the move; to undo, it first
decrements the new position's repetition count (G[k]--) and then restores the
saved values. Skip the decrement and a silent bug follows: the count added by the undone move
stays in the table, the position is counted one time too many when the same move is played
again, and R comes out higher than it should — a threefold claim becomes
available too early.
There is no FEN and no PGN: the engine neither parses positions nor records moves.
A driver that wants to start from a given position writes the board into b by
0x88 index, leaving the holes unassigned, and itself sets the side to move in t,
the castling rights in c, the en passant square in e and the ply
counter in n. It must also rebuild the repetition table for the new position:
delete the old keys stored on G (for(K in G)delete G[K]; the
function's own properties are not enumerable, so the loop sees only the position keys), then
set R=G[k=b+e+t+c]=1. It must clear the offer bits as well (o=0). If
they are left set, an offer from the previous game carries over into the new position, and a
single offer from the other side ends the game as a draw by agreement (9). If the holes are
filled — with 0, say — the off-board space turns into empty squares: sliders wrap from the
end of one rank to the start of the next, and a rook on h1 can suddenly reach a2. Likewise,
the en passant square is set only if a pawn on an adjacent file can actually make the
capture; otherwise it is 8. M maintains e by the same
rule; if the FEN's square is copied as is, the key differs from the one the engine would
build, and a repetition of the position is counted one time too few.
State variables
Everything a driver reads to draw the screen is here; the last three belong to the 6.9
search. The clock is not in the engine: the driver keeps the time and
calls F when a flag falls or a player resigns.
Name
Holds
Detail
b
Board
A sparse 120-element array: 64 squares, 56 holes (see the representation above). A driver may read it directly; it never needs to write to it.
t
Side to move
Whose turn: 8 White, 0 Black. M switches it on every move (t^=8).
z
Game result
The result code; 0 while the game continues. It does not appear in engine.js: it belongs to the driver, which stores the return value of A, D or F in it.
n
Ply counter
The halfmove clock of FEN. Resets on every capture and pawn move. At n>99 a draw can be claimed under the 50-move rule; at n>149 the 75-move rule ends the game automatically.
R
Repetition count
How many times the current position has occurred. R>2 allows a threefold-repetition claim; R>4 ends the game automatically (fivefold repetition).
o
Offer bits
The 1 bit is White's pending offer, the 2 bit Black's. When both are set, the game is drawn by agreement.
c
Castling rights
A four-bit mask: 1 white kingside, 2 white queenside, 4 black kingside, 8 black queenside; 15 at the start. It records the right to castle, not whether castling is possible right now.
e
En passant square
8 when there is none: the index of a hole, which corresponds to no square. Set only if a pawn on an adjacent file can actually make the capture — the correctness of the repetition count depends on this.
k
Position key
The string b+e+t+c: the board, the en passant square, the side to move and the castling mask, concatenated. M rebuilds it on every move; both the repetition table and the search table are keyed by it.
G[k]
Repetition table
Position key → number of occurrences. No separate object is declared: a function is an object too, so the table is stored as properties of G itself.
N
Node budget
How many moves the 6.9 search may try; in a promotion, each piece counts as a separate move. F resets it to 2e3 on every call. When it runs out, the search stops and reports that mate is possible: an impossibility that cannot be proved does not count as a draw.
$
Search table
Searched position → the value of n when it was visited. If the same position recurs with the same or a higher counter, it has no more room left before the 75-move limit than it had the first time; the earlier visit already covers it, and the search does not descend into it again.
I
Searched side
The side whose flag fell or who resigned; the letter stands for insufficient material. F sets it; H and V(I) read it. Throughout the search the question stays the same: can I be mated?
Functions
Six of them were introduced above. The other seven are internal machinery: a driver never
needs to call them, but you need them to read the engine. They are listed in source order;
each function calls only those defined before it, with two exceptions shown on the
layer map. The Bytes column gives each function’s size — the quickest
way to see where the byte budget goes.
Name
Signature
Bytes
What it does
C
C(i)
28
Square → the castling rights tied to it, as a bit mask. 0 for every square except the king and rook home squares. M uses it to clear rights, G to check the right to castle.
G
G(i, f, m)
236
Geometry. Can the piece on i reach f, ignoring king safety (a pseudo-legal test)? m is attack mode: when it is set, the pawn moves in the opponent's direction, its diagonal step counts even onto an empty square, and the king may not castle.
T
T(x)
52
Is square x under threat — attacked by the opponent of the side to move? If x is omitted, the king of the side to move is used, so T() is the check test itself.
U
U(i, f, p)
134
The raw move — it updates the board. Clones the board, moves the piece, promotes (p, a queen if omitted), removes a pawn captured en passant, and for castling calls itself to move the rook. Returns the new en passant candidate: the skipped square after a pawn's double step, 8 otherwise. It does not touch the side to move, the counters or the rights — because it also serves as L's trial move.
L
L(i, x)
54
The legality of a single move. Is the target square empty or occupied by an enemy piece, does G allow the move, and is the king still safe after U plays it (T)? Restores the board and returns true or false.
M
M(i, x, p)
109
Plays the move and updates the state: the ply counter, the castling rights, U, the side to move, the en passant square — kept only if a capture is actually possible — and the new key k. It does not touch the repetition table — because it also runs inside H's search tree.
V
V(s)
405
The verdict: is it impossible for s's opponent to checkmate s, because of the material or a blocked pawn structure? A one-sided question: Z asks it for both sides to detect a dead position (V(0)&V(8)), H only for I. Its blocked half covers kings and pawns only; with any other piece on the board, or with an en passant capture available, that half does not run at all.
O
O(h)
59
The absence test. Calls h(i, x) for each legal move of the side to move and stops as soon as h returns a falsy value. O(_=>0) returns true only when there is no legal move: that is the checkmate-or-stalemate question.
H
H(d)
149
The search half of 6.9. Can I be mated within d plies by any series of legal moves — even with I itself cooperating? A true return means it cannot. It tries to prove that even a helpmate is impossible; without a proof it counts mate as possible.
Z
Z()
51
The result of the position. First it checks whether any legal move exists (O); if none does, it distinguishes checkmate from stalemate. Otherwise it asks V about a dead position, then checks the repetition count and the ply counter.
D
D(s)
36
Draw claim, offer and acceptance.
F
F(s, r)
44
Flag fall and resignation. Sets up I, $ and N, calls H(9) and builds the result from its answer.
A
A(i, f, p)
34
The real move — it applies what L only tried: calls M, records the position in the repetition table, updates R and returns Z().
The thirteen functions come to 1,391 bytes, that is 91.1% of the file. The
remaining 136 go to the initial state and the separators between the parts: the signature
header 25, the loop that fills the board 80, the first entry of the repetition table
16, and fifteen commas and semicolons 15.
The distribution is uneven, and telling. V alone is 405 bytes — about
27% of the file, and its longest function. Next come G (236),
H (149) and U (134). So the most expensive parts of the engine are,
in order, the static detection of an impossible mate (material and blocked
pawn structures), piece geometry, the 6.9 search and making a move on the board. By group:
geometry, attack and legality (C, G, T,
L) take 370 bytes; making a move (U, M) 243; move
iteration and search (O, H) 208; the verdict, claims, the clock and
move application (Z, D, F, A) 165
together.
Names
The file is made of sixteen parts: the signature header, the loop that fills the
board, thirteen functions and the first entry of the repetition table. The first two are
followed by semicolons and the rest are separated by commas, because the part that fills the
board is not an expression but a for…in statement. The header alone creates eight
names — b and, assigned inside the array, c, un,
e, y, t, n, o — and the last
part two more: R and k. The loop leaves j and
s behind as globals too. Add three state names created inside a function —
$, I and N, set up by F — and eight scratch
globals. Every other letter is a function parameter, and so free for a driver to reuse. The
state variables and the functions are in the tables above; the scratch values and parameters
are below.
Seven letters never appear in the source at all: E, J,
K, Y, l, w and z; drivers can
use them without any risk of a clash. L3_numerical's loop does exactly that,
storing the input in K, the destination square in Y, the time the
clock was started in w and the result in z. The rest of the alphabet
is taken — the clearest measure of how hard the byte budget has been squeezed.
The naming is not arbitrary. Most names are the initial of an English word. Among the
functions, Aapply, Ccastling, Ddraw, Fflag, Ggeometry, Hhelpmate, Llegal, Mmove, Tthreat, Uupdate, Vverdict; in the state,
bboard, ccastling, een
passant, Iinsufficient material, kkey,
Nnode budget, ooffer, Rrepetition, tturn; among the parameters,
iinitial, ffinal, ddifference, ppiece, rrook — and in the
tables that letter is set in bold within the word it comes from. A few follow a
different logic: n is the conventional counting letter; W and
B are the white and black pawns; z and Z sit at the end
of the alphabet because they hold the end of the game (z lives only in the
drivers). O is an absence test: O(_=>0) asks whether no legal
move is left. X comes from no word at all; it is a cross, that is, a “not”: the
flag that says a move is not a promotion. And $ looks a little like
#, the hash sign — and it really does hold a hash table.
Letter case follows a convention of its own: functions tend to be uppercase, state
variables and parameters lowercase. It is a tendency rather than a rule — R is an
uppercase counter, and W, B, S and X are
uppercase scratch values — but the case of a letter usually tells you what kind of name you
are looking at. And where both cases of a letter are in use, they often name the same idea at
two levels: p the piece code and P its type, c the
castling rights and C the function that yields them, s the step and
S the ray walker that advances by it, z the stored result and
Z the function that produces it. Inside V the convention switches
to FEN's: S and s are the blocked white and black pawns,
A and a the squares the white and black pawns can reach — uppercase
for White, lowercase for Black.
Scratch values and parameters
Eight letters are global scratch variables, created inside a function. Six of them are
meaningless once the call returns, and all six belong to V: W and B the
bitboards of the white and black pawns; _ and f those
of the white and black king, and in the saturation loop the squares each can reach;
S the blocked white pawns; a the squares the black pawns can reach.
The other two carry information from one function to another: r is the square
of the castling rook — G's castling branch writes it, U reads it;
X is the cross that says a move is not a promotion — U writes it, and
H reads it to try the four pieces of a promotion one by one.
f and S are parameters in other functions: f is
the destination in G, U and A, S the ray
walker in G; and _ is a never-read parameter in three places.
V writes these letters as globals. No collision arises, because the parameter
shadows the global and the two are never live at the same time; but a driver that unknowingly
uses one of these letters as a global of its own will be caught out.
The remaining letters are all function-local; a driver can safely use the same names as
globals. The same goes for j and s: the loop that fills the board leaves
both behind as globals — j as "63", s as the
64-character position string — but the engine never reads them as globals again. The
Where column shows which functions each letter appears in;
V's own alphabet follows separately below.
Name
Where
Role
i
C G U L M A
The initial square; in C, the square in question.
f
G U A
The final square — the destination. G's castling branch moves it on to the rook's square.
x
G T L M O H
In G, the step size: 1, 15, 16 or 17. In T, the square in question. In L and M, the destination — L then writes T's answer into the same letter. In O and H, the destination of the move being scanned.
p
T U M O H A
In the scans of T, M and O, the piece code — the raw value read out of b. In U, M and A, the promotion type (in U it defaults to 3, the queen). In H, the type the promotion loop is trying.
P
G U
The piece type. In G it is b[i]&7; in U it is b[i]-t, the same type for a piece of the side to move. In both, P-1 is the “is it a pawn?” test.
d
G U H
In G, the index difference f-i. In U, P*(i-f)**2: 8 for castling, 1024 for a pawn's double step — this is how the two special moves are recognised. In H, the remaining search depth.
m
G
The attack-mode flag; T passes 1. When it is set, the pawn moves in the opponent's direction, its diagonal step counts even onto an empty square, and the king may not castle.
s
G D F H
In G, the signed step (±x). In D and F, the side: 8 White, 0 Black. In H, the six-element snapshot saved for undoing the move: [b,e,n,t,c,k].
S
G
The ray walker. Steps along a sliding piece’s path — or a pawn’s double step, or the squares between king and rook in castling — checking that nothing stands in the way. It calls itself one step at a time and stops at the first cell that is not empty: a piece or a hole.
j
T M O H
The index in a board scan.
q
L
The copy of the board taken before the trial move.
Q
U
The midpoint of two squares: the square the castling rook lands on, and the en passant square after a pawn's double step.
h
O
The callback invoked for every legal move.
v
H
Starts as the leaf test's answer; when moves exist, it holds the result of the last move tried.
r
F
The kind of ending: 0 flag fall, 1resignation. It shadows the global r, which F has no use for.
_
G O Z
A parameter that is never read: S's, that of O's inner callback, and Z's. One byte shorter than empty parentheses (()=>).
V builds an alphabet of its own
The table above does not include V, and for a reason: V has
ten parameters of its own — two of them from a destructuring pattern — and eleven
names counting its inner loop's parameter. Eight of them are letters used as globals
elsewhere in the engine.
V = ( sjA [ m o ] u cHN g = n => … )
bold: shadows a global
One of them stands out: H. Everywhere else in the engine H is the
6.9 search — the very function that calls V at every node; inside
V it is a helper that spreads a bit set one file to the left and right. The
others follow suit: c is not the castling rights but the mask that stops a shift
from wrapping around the board edge; N is not the search budget but one step of a
king; s is not the position string but first the side in question, then Black's
blocked pawns in the loop; j is not the index of the loop that fills the board but the bit counter;
o is not the offer bits but s's own material; A is not
the function that applies a move but first a
material tally, then the squares the white pawns can reach; and the loop's parameter
n holds not the ply counter but the number of rounds left. Even the board scan
runs inside the parameter list: the default value of [m,o] is the
b.map(…) itself.
The shadowing here is not a design choice but a consequence of the byte budget: with only
seven letters left free, V takes most of its names from letters already in use.
Names created in the parameter list never leak: when the function returns, the globals are
as they were. Six assignments are exceptions: W, B, _,
f, S and a; all of them are explained under
the saturation loop. Pitfalls covers where this becomes fragile.
Flow
engine.js — what happens, line by line
What the letters mean is in the tables above. What follows is
what those letters do, and in what order: which expression enforces which rule, which
operator turns what into what, and which functions run in which order as a move is made on
the board.
The order of the sixteen parts is not arbitrary — each function
builds on the ones before it. The source looks forward in only one place: G calls
T, which comes right after it. That is genuine mutual recursion: T
calls G for every piece while G calls T in its castling
branch. Every other call refers back: when you read a function, everything it calls has
already appeared above it.
The sections below follow the layer map: from the bottom up,
geometry, attack, legality, adjudication and application. In this engine the layer order and
the order of declaration are the same; reading the source from left to right means climbing
the layers from the bottom up.
Layer map
The engine has five layers. At the bottom is pure geometry: can a piece go from this
square to that one — without asking what stands on the target or whether the king is left in
check (C, G)? Above it, attack: is a square attacked
(T)? Then legality: the layer that actually makes the move, checks the
king's safety and restores the board (U, L, M). Then
adjudication: is the game over, and if so how; is mate still possible
(V, O, H, Z)? At the top,
application: three functions — A makes a move permanent and returns the
verdict, D settles claims and offers, F handles flag fall and
resignation. Losing castling rights, advancing the ply counter and switching the side to move
are not their job but M's, down in the legality layer. A driver plays the game
through this top layer; it reaches down only to validate a click with L and to
mark check with T.
Solid arrows follow the layer order: calls go down or stay within a layer,
sometimes skipping one (L → G, Z → T,
A → M). The dashed gold arrows mark the two places where the
dependency points back up — both arise from the chess rules themselves.
The dependencies run almost entirely one way. The rule is broken in two places:
G → T. The geometry layer reaches up one layer for
castling: it cannot decide whether castling is valid without asking whether the king is in
check right now and whether the square it crosses is attacked. So G
and T are mutually recursive — T calls G for every
piece while G calls T in its castling branch. The recursion never
runs away, because the G that T calls is in attack mode, and in
attack mode the castling branch is never entered.
M → L.M, which makes the move on the
board, drops back into the legality layer while setting the en passant square: it keeps the
square only if a pawn of the side now to move can legally make that capture.
There are also recursions within a single layer. G's ray walker
S calls itself: a slider's path, the pawn's double step and, in castling, the
path between king and rook are all tested by that walk. U calls itself once more in
castling to move the rook. H calls itself down the search tree, and
V's inner loop g calls itself through the rounds of
saturation.
Function
Calls
Layer
C
—
Geometry
G
S · C · T
Geometry
T
G
Attack
U
U
Legality
L
G · U · T
Legality
M
C · U · L
Legality
V
—local helpers only
Adjudication
O
L
Adjudication
H
T · V · O · M · H
Adjudication
Z
O · T · V
Adjudication
D
Z
Application
F
H
Application
A
M · Z
Application
Setting up the board
Two parts build the board, an expression and a loop:
b=[c=un=15,e=y=t=8,n=o=0];
for(j in s=`dcebaecd${10n**40n-10n**32n}1111111154632645`)b[j*2-j%8]='0x'+s[j]-0;
The header is a declaration list. The first expression is an array literal whose
elements are assignments: c=un=15, e=y=t=8, n=o=0.
Evaluating the literal writes the whole initial state — t=8 puts White to move,
c=15 grants all four castling rights, e=8 means no en passant
square, n=o=0 zeroes the ply counter and the offer bits — and the resulting
array [15,8,0] becomes b. The state variables and the board array
thus share one pair of brackets; no separate b=[] is written. The three elements
are only placeholders: the loop that follows writes a1, b1 and c1 to indices 0, 1 and 2,
overwriting them. The clock is not in the engine; the driver keeps the time.
The position string. The template literal produces 64 characters: White's back
rank dcebaecd, 40 digits in the middle, then Black's pawns
11111111 and Black's back rank 54632645. The middle 40 characters
are not written out in the source but generated at run time by
10n**40n-10n**32n. The difference equals 10n**32n * (10n**8n - 1n):
eight 9s (White's pawns) followed by thirty-two 0s (four empty
ranks), exactly 40 digits. It has to be a BigInt: as an ordinary Number, the same
value would lose its exact digits to floating-point rounding.
From the plain index to 0x88.for…in walks the string's indices, 0 to
63, though not as numbers but as the strings "0"–"63"; arithmetic
turns them into numbers by itself. j is the plain board's index, 8 × rank +
file; j*2-j%8 turns it into the 0x88 index: j*2 is 16 × rank + 2 ×
file, and subtracting the file once leaves 16 × rank + file. s[j] gives the
character at that index. The cells that are never written — the eight to the right of each
rank — remain unassigned; that is where the holes come from. An expression that split the
string into characters with the spread operator and walked them with map would
take 83 bytes; the loop takes 80. The price is two names:
j and s stay behind as globals. And since the loop is a statement,
it is set off from the parts before and after it by semicolons, not commas.
The trick in the value conversion is precedence: + and -
have equal precedence and associate left to right, so '0x'+s[j] concatenates
first — '0xd' for 'd' — and then -0 coerces that string
to a number, whereupon JavaScript reads the 0x prefix as hexadecimal and yields
13. Written as +0 the concatenation would have continued and produced
'0xd0'.
The last expression builds the repetition table with the starting position already
counted once:
R=G[k=b+e+t+c]=1
The key has four parts: the array converted to text — 120 cells, comma-separated; the
holes become empty strings, so the eight holes after each rank show up as a run of commas —
followed by the en passant square, the side to move and the castling mask, with no
separator. The table is not a separate object but the function G itself;
the keys become its properties.
The missing separator looks like a collision risk at first sight, and here a unique parse
really is not guaranteed: after the last comma come h8's code (one or two digits),
e (8 or 32–39 / 80–87, one or two digits), t (8 or 0) and
c (0–15), run together. Enumerating every possible value finds a single family
of collisions: a black pawn on h8 (1) with e on g3
(38), against a white rook on h8 (13) with no e
(8) — both are written 138…. A chess rule rules that case out: a
black pawn can never stand on h8, because black pawns start on the seventh rank and only move
towards the first. Every other combination of values reads in only one way.
Geometry — G
G(i,f,m) answers a single question: can the piece on square i
reach square fas far as the geometry of the board is concerned? It
ignores whether a friendly piece occupies the target and whether the move leaves the king in
check. Both are left to the layers above. 236 bytes — the second-longest function in
the file, after V.
The third argument m is attack mode. When it is set, three things
change: castling is disabled (castling is not an attack), the pawn's diagonal square counts
even when it is empty (the pawn attacks that square), and the pawn's direction is
reversed, because the attacking pawn belongs not to the side to move but to its
opponent. So G(j,x,1) means “does the enemy piece on j attack
square x?”
The parameter list is a spreadsheet
Because default parameters are evaluated before the body and from left to right,
G's header does the work of a let block without paying for the
keyword. By the time the body starts running, the whole geometry has been derived:
The first thing to notice is that the piece's colour is never read: the type is derived
in the header with P=b[i]&7, and the pawn's direction comes not from the
piece's colour but from the side to move, t. This relies on L only
ever being called for a piece of the side to move; reading the colour would cost bytes.
The rest is geometry that 0x88 provides. Three of the derived values deserve a closer
look:
d=f-i. The difference between the two squares. Since in 0x88 every combination of rank and file offsets has a difference of
its own, this single number identifies the line; there is no need to compute rank and file
distances separately.
x: the step size. Three nested remainder tests: if the difference
is divisible by 15, the line is one diagonal and the step is 15; by 16, a file and 16; by
17, the other diagonal and 17; otherwise the step is 1. That last case covers both the rank
and every difference that lies on no line at all. s is the same step with its
sign: no direction table, no offset array — three remainders and a sign.
S. The ray walker. It advances i by s; if
it has reached f it returns true, otherwise it calls itself again as long as
the cell is empty (b[i]<1). It stops at a piece, and at a hole as well:
undefined<1 is false. This single line checks that every square in between
is empty. The pawn's double step and castling use the same walker.
A difference that lies on no line — from a1 to b3, say — gets a step of 1. For such a
pair the walker runs along the rank and stops at the first hole without ever reaching
f: in 0x88 every rank has a wall at either end. On a 64-square board, such
geometries would have to be filtered out in advance by branch conditions; here the holes
filter them. The
same walls defuse broken inputs. Give the same square twice and d is zero, the
step 15, and the walk ends at the first piece, the first hole or the end of the array. With no
king on the board T asks about square -1, and again the walk ends
outside the array. The answers are meaningless, but no walk ever runs forever.
Four branches, six pieces
The body is a single ternary chain. Each link subtracts a number from the type and looks
for zero: P-4 zero means a knight, P-2 zero a king, P-1
zero a pawn. The final branch handles the remaining three pieces — queen, rook and bishop
(3, 5, 6) — together.
Knight.d*d%765%128==68. In 0x88 the knight's eight moves come down to
four differences: ±14, ±18, ±31, ±33. Squaring drops the sign (196, 324, 961, 1089); the
remainder modulo 765 folds the larger two onto the smaller two (961 → 196, 1089 → 324); and
the remainder modulo 128 brings 196 and 324 down to the same number, 68. Checking every
difference that can occur between two squares on the board shows that only these eight give
68. One multiplication and two remainders instead of an eight-direction table. If the target
is a hole, the knight's move still comes out “geometrically fine”, but L's
target filter rejects holes anyway.
King. Two branches, split on ~m+3%d. ~m is −1 in normal
mode and −2 in attack mode; 3%d is 1 only when the difference is ±2 (0 for ±1 or
±3, and 3 beyond that). The sum is zero only in normal mode and for a two-square move along
the rank; only then is the castling branch entered, and in attack mode it never is. The other branch
is d==s: does the difference equal a single step — is the move one square in any
direction?
Every castling condition sits in a single chain. First the target is turned into the
rook's square: f+=s-!~s — on the kingside s is 1, ~1 is
−2, !−2 is false, so f moves by 1: from g1 one square on, to
h1. On the queenside s is −1, ~−1 is 0, !0 is true, so
f moves by −2: from c1 two squares back, to a1. The asymmetry is handled
without a ternary. The rook's square is stored in the global r at the same time;
U, which will move the rook, reads it from there. c&C(r) tests
that the castling right tied to that rook is still set. T()+T(i+s)<S() asks
the remaining three conditions in a single comparison. T() says whether the king
is in check now, T(i+s) whether the square it crosses is attacked;
T's default square is the king of the side to move, that is i. If
both are false the sum is 0. On the right, S() walks from the king to the rook
and checks that every square in between is empty; on the queenside b1 is included in that
walk. If the path is clear it gives 1. The comparison holds only as 0 < 1. The expression
is evaluated left to right, so S still runs after the attack tests — as it must,
because it advances i as it walks. Whether the rook is actually there needs no
separate test: M clears the right the moment the rook moves or is captured. The king's destination square is not tested here, because
L's trial move already catches an attack on it.
Pawn. Direction first: m^d<d*t. With White to move t is
8, and d<8d holds only when d is positive — White must increase
the index. With Black to move t is 0, and d<0 is simply “does the
index decrease?”. Using the side's value as a multiplier fits both colours into one
comparison. In attack mode m^ inverts the answer: the question is now about the
opponent's pawn. If the direction is wrong the ternary returns 0 and neither branch is
entered. Otherwise there are two branches, split on x-16: a move along the file
is a push; anything else is a capture.
Capture.1&~x%d asks whether the move is exactly one diagonal
step. ~x%d means (-x-1)%d; if the difference is a single step (±15
or ±17) the remainder is −1, which is odd; for two steps or more it is −16 or −18, and with a
step of 1 it is 0 or −2 — always even. Then m|b[f]|f==e accepts any of three
conditions: attack mode, a piece on the target (L's filter has already
ensured that it is an enemy piece), or the target being the en passant square.
Joining the three with or turns en passant into an ordinary capture without
opening a branch for it.
Push. Distance first: d/s<2+(i%96<24) — the number of steps
must be below three on the starting rank and below two everywhere else: at most two squares
from the starting rank, one square elsewhere. The comparison yields 1 or 0 as a number,
and >b[f] sets it against the piece on the target: only when the target is
empty (b[f] is 0) is 1 greater. One operator asks about both the distance and
the empty target; on a hole b[f] is undefined and the comparison
fails. S() does the rest:
on a single step it reaches the target at once and returns true, on a double step it looks
at the square in between first and reaches the target only if that is empty.
The push branch has no term asking about attack mode, so in attack mode a pawn can seem
to “attack” the empty square in front of it. T can only see this when it asks
about the square crossed in castling — the only queried square that is empty. But for a black
pawn on f2 to “attack” f1, it must already be giving check to the king on e1, and castling
out of check is refused anyway. The false positive changes no answer.
How does i%96<24 pick out two ranks at once? A rank's indices start
at 16 × rank. Those whose remainder on division by 96 is below 24 are 0–23 and 96–119, that is
the first, second, seventh and eighth ranks. A pawn never stands on the first or the last
rank, so only the second and the seventh remain — the starting ranks of White's and Black's
pawns. Because
the comparison yields true or false, 2+(i%96<24) is
3 on the starting rank and 2 everywhere else.
Sliding pieces. The last branch of the chain handles queen, rook and bishop
together, and does so by reading the parity of the type number. If
P+P*4%x comes out odd, the piece can move in that direction:
Piece
P
rank (x 1)
file (16)
diagonal (15)
diagonal (17)
Queen
3
3
15
15
15
Rook
5
5
9
10
8
Bishop
6
6
14
15
13
On a straight line the remainder of 4P divided by 1 or 16 is always even; the
parity comes from P itself, and the odd types, queen and rook, pass. On a
diagonal, 4P exceeds 15 and 17 only for 5 and 6; the odd modulus is then
subtracted once and the parity flips. The queen, at 3, stays below that threshold and remains
odd, while 5 and 6 swap parity: the rook turns even, the bishop odd. That lowest bit is ANDed
with the result of S(): if the path is blocked, the move fails. No separate branch for the queen was ever written; it comes out odd
in all four columns.
Using & instead of && saves a byte but drops the
short circuit: S() runs even when the parity fails. The price is a wasted walk;
the result does not change. An empty square falls into this branch too — P is 0
and the expression stays 0 — and the empty squares that leak through the filters of
T and O are harmlessly weeded out here.
Attack — T
The geometry layer deals with one piece; the attack layer polls the whole board — in 52
bytes.
T(x) asks: is square x attacked by the opponent of the
side to move? The filter is p&8^t — the XOR of the piece's colour bit (the 8
bit) with the side to move, which is 8 when the colours differ. Then G(j,x,1),
in attack mode. some stops at the first attacker it finds; a full scan happens only when the square really is safe, and since
some skips the holes, the scan is limited to the 64 squares.
The real subtlety is in the parameter's default: x=b.indexOf(2+t) finds the
king — the king's type is 2 and its code 2 + colour; since t is the colour
itself, 2+t is directly the king of the side to move. So if x is
omitted, the question automatically becomes “is the side to move in check?”.
There is no side parameter; the question is always about the side to move. That is
enough, because every king the engine asks about is that side's king: during
L's trial the side to move has not yet been switched, so the mover is still
t; and the leaf test of Z and H asks about the king of
the side about to move.
The king's position is stored nowhere; it is searched for afresh on every call. A cache
would have to be updated on every move, and kept correct across the moves L
takes back. Searching is cheaper.
Empty squares leak through the filter: on an empty square p&8 is zero, and
with t at 8 the XOR gives 8. So with White to move, T asks
G about every empty square along with the black pieces; with Black to move the
empty squares are filtered out. Why this is harmless lies one layer below: on an empty
square P is zero, so G falls into the slider branch and the
expression stays 0. But it is not free: that branch still runs S(). The mirror
image of this leak is in O, this time on Black's move.
Legality — L, U and M
L — filter, try, restore
Geometry looks at the shape of a move, legality at its consequence. Between them lies one
rule: a move that leaves your own king in check is illegal. There is no short way to test
that statically, so Lactually makes the move, asks about the king, and
restores the board. It answers yes or no for a single move; anyone who wants all the moves of
a piece must ask about each square in turn — as O does. 54 bytes.
L=(i,x,q=b)=>
filter b[x]+~t>>3 &&
geometry G(i,x) && (
play U(i,x),
test x=T(),
restore b=q,
!x)
The filter is a single shift. Addition binds tighter than shifting, so the
expression is (b[x]+~t)>>3. ~t is −9 on White's move and −1
on Black's. On White's move an empty square gives −9 and a black piece something between −8
and −3; shifted right by three, both stay negative, hence truthy. A white piece gives 0 to 5,
which shifts to 0. On Black's move an empty square gives −1 and a white piece 8 to 13 (−1 and
1 after the shift); a black piece gives 0 to 5 and comes down to 0. A hole is
undefined, the sum NaN, the shift 0. One expression lets through the
empty square and the enemy piece and rejects your own piece and the off-board cells — before
G ever runs. The origin square is excluded the same way: your own piece stands
on it.
The backup is a reference.q=b is created as a default parameter, so
no let is needed. L does not clone the board: cloning is
U's first step. U builds a new array and binds b to it,
never touching the old array object; restoring the board is therefore a single assignment —
not a copy, just the old reference put back.
The letter is reused. Once the trial is over, the destination square is no longer
needed, so x holds T()'s answer. In the comma sequence the board is
restored first, then !x is returned: if the king is not attacked, the move is
legal.
U(i,x) is called without a promotion type, so the default queen is used —
irrelevant to legality, because whether the king is left in check depends on the destination
square being occupied, not on the type of the piece standing there.
L has two side effects, both intentional: in the castling branch
G writes the rook's square to r, and in the trial move
U writes the promotion flag to X. So L(i,f) prepares the
A(i,f) that follows; that is why a driver calls the two back to back.
U — the raw move
U makes the move on the board and nothing else: it neither switches the side
to move nor touches the counters or the rights. It is both L's trial move
and M's real one. Taking it back is the caller's job. 134 bytes.
The header is a spreadsheet again. P=b[i]-t is the piece's type: subtract the
colour from a piece of the side to move and the type remains (a white code minus 8, a black
one minus 0). d=P*(i-f)**2 is the fingerprint of the two special moves:
in castling the king (2) moves two squares, 2 × 4 = 8; in the pawn's (1) double step
the difference is 32, 1 × 1024 = 1024. No other move yields either number.
Q is the midpoint of the two squares: the square the castling rook lands on, and
the square the pawn skips over in a double step.
Clone, destination square and promotion.b.slice() copies the board,
holes included; b is rebound to the new array. The promotion test is
X=P-1|f%112>7: zero only if the piece is a pawn (P-1 is zero) and
f%112 is at most 7, that is, f is on the first or the eighth rank.
If so, the chosen type is written — p|t, the type plus the colour of the
side to move; otherwise the piece itself. The flag stays in the global
X; H reads it to try the four pieces of a promotion one by
one.
Origin square and the pawn captured en passant. A chained assignment is
evaluated right to left. On an ordinary move the origin square is cleared twice. When a pawn
lands on the en passant square (e-f and P-1 both zero), the square
cleared becomes f^16, and that expression finds the direction by
itself: the en passant square is either on the third rank (index 0x2_) or
on the sixth (0x5_); XOR with 16 flips the lowest bit of the rank digit, 2 to 3
and 5 to 4 — in both cases the rank on which the captured pawn stands: e3 → e4,
e6 → e5.
The en passant candidate and the castling rook. The last line is a nested
ternary. If the fingerprint is not 8, the return value is the en passant candidate: the
skipped square if the fingerprint is 1024, 8 otherwise. Whether the square is
actually kept is for M to decide. If the fingerprint is 8, U calls
itself once more and moves the rook from r to the square in between — f1, d1,
f8 or d8; r was written by G's castling branch during the
L call just before. The return value is then that inner call's: the piece is a
rook, so the fingerprint comes out 20 or 45, the recursion ends there and 8
comes back — castling leaves no en passant candidate.
M — six steps in a fixed order
M plays the move and updates the state: the ply counter, the castling rights,
the board, the side to move, the en passant square and the key. It does not write to the
repetition table. The rights and the counter are updated here, where the move is made,
because the search makes moves too: if c were left untouched, a castling right
already lost would still look available deeper in the tree. Taking the move back is the
caller's job: the search saves [b,e,n,t,c,k]. The last permanent effect, the
repetition count, lives one layer up, in A. 109 bytes.
The ply counter.!b[x] is 1 if the target is empty and 0 if not;
multiplied by the moving piece it leaves 0 on a capture and the piece's code on a quiet
move. &6 is zero for a pawn (1 and 9) and non-zero for every other piece.
If the result is zero — a piece was captured or a pawn moved — &&
returns that zero and the counter resets; otherwise it increments. On en passant the target
is empty but the piece is a pawn, so the counter still resets. The whole rule is one
expression.
Castling rights. Two separate rules combined into one expression.
C(i) looks at the origin square: if the king has left its square or a rook its
corner, the corresponding right is lost. C(x) looks at the destination: if a
rook is captured on its home corner, that right is lost too. For the king, a single
square carries both rights: C(e1) returns 3, so any king move — castling
included — removes both of White's rights at once.
The board.U makes the move and returns the en passant
candidate.
The side to move.t^=8.
The en passant square. The candidate is kept only if a pawn of the side
now to move can legally make the capture: p==1+t selects that side's
pawns (1 for Black, 9 for White), and L(j,e) tests the capture with the full
legality check — which is why a pinned pawn does not create an en passant right. With no
candidate (e is 8), L stops at its filter on the hole; the scan
runs after every move but examines at most eight pawns.
The key.k=b+e+t+c is the new position's string; M
returns it, and A uses it to count the position in the repetition table.
The order is fixed. The counter (1) looks at the piece on the destination square, so it
has to come before U (3). The en passant check (5) asks a legality question of
the finished board and the new side to move, so it has to come after U and after
the side is switched (4). The key (6) comes after everything. Only the castling rights (2)
could be moved; as long as they stay before the key, nothing changes.
The fifth step is this careful because of the repetition count.
Since the definition of a position includes whether an en passant capture is possible,
recording a square nobody could use would make the same position count as two different ones
and silently miss a threefold claim.
Castling rights — C
The four castling rights are four bits in c. The function that maps each
square to its bit is 28 bytes — the shortest in the file.
C=i=>'20003001'[i%112]<<i/56
Two steps. i%112 folds the first and eighth ranks onto the same eight
indices: a1 (0) and a8 (112) to zero, e1 and e8 to four, h1 and h8 to seven. The six ranks in
between stay in the range 16–111, beyond the end of an eight-character string, so reading
there gives undefined. The string itself is the table.
Then <<i/56. The shift operator converts both operands to integers:
'2' becomes 2, undefined becomes 0; and the right operand comes out
0 on the first rank and 2 on the eighth (112–119 divided by 56 is between 2 and 2.125).
Square
i%112
Character
Shift
Bit
h1
7
1
0
1
a1
0
2
0
2
h8
7
1
2
4
a8
0
2
2
8
e1
4
3
0
3
e8
4
3
2
12
the other 58
—
'0' / none
—
0
The '3' in the string is what makes a king move clear two bits at once.
C has two callers. The second step of M does all the clearing:
c &= ~C(i) & ~C(x). The origin square signals that the king or a rook has
moved; the destination square, that a rook has been captured on its home corner. Two
separate rules, one expression. Since all other squares return zero, their complements are
-1 and leave the mask untouched. G's castling branch reads the same
table the other way round with c&C(r): is the right tied to that rook still
set?
What is stored here is the right to castle, not whether castling
is possible at the moment — the distinction comes from the rulebook itself. If castling is
temporarily impossible because a piece stands in the way, the bit stays set.
Mate ruled out — V
Both the dead position (5.2.2) and the exception at flag fall and
resignation (6.9 and 5.1.2) rest on the same question: can a side be
mated by its opponent? V answers it statically, from the board alone — without
playing a single move. At 405 bytes it is the longest function in the
file.
The question is one-sided: V(s) asks only whether it is impossible for
s to be mated. A true return means impossible; a false one means possible, or
not decidable statically. It is called from two places, for different jobs.
Z asks it after every move, for both sides:
V(0)&V(8) — if neither side can be mated, the position is dead,
DP. H asks it during the search, at every node
and only for I: if I can no longer be mated on this line, the line
closes. So V is not merely H's neighbour in the same layer — it is
also part of H's work, called inside it.
The body consists of three terms. The first two look at material, the third at
structure; the board scan that does the counting runs before the body,
in the parameter list:
V=(s, … the count and helpers …)=>
!(s?B:W) > m the opponent has no pawn and no piece: a bare king
| !(W|B) > (4%m | o&(m>3?15423:63^m)) no pawns; same-colour bishops or one knight, no helper
| !(o|m|e-8 || g(99,A=S=W,a=s=B)) kings and pawns only: a blocked structure
Insufficient material — V's material half
The count scans the board once and fills in two things: each side's material tally, and
the pawn and king bitboards the blocked half will use. All of it happens inside the default
value of [m,o]:
[m,o]=(W=B=0n, b.map((p,i)=>(
p%8>5 ? A[p>7^!s] |= i%15%2+1 bishop: write the square colour into the mask
: p%8>2 ? A[p>7^!s] += 4<<p*8 queen 2²⁶, knight 4, rook 1024
: p&8 ? p>9 ? _=j : W|=j white king and pawns: bitboards
: p>1 ? f=j : p ? B|=j : 0, black king and pawns: bitboards
j+=j)), A)
The two sides' tallies.A=[0,0] holds two tallies, and
p>7^!s picks the one to add to. p>7 is true for a white
piece; XOR with !s inverts that when the side in question is Black. The result:
index 1 for a piece of the side in question, 0 for one of the opponent's. Once the scan is done,
[m,o]=A assigns the two to names: m the opponent's material,
o that of the side in question.
Bishops by square colour. What matters is not how many bishops there are but
the colours of their squares. In 0x88 i is 16 × rank + file, and 16
leaves 1 when divided by 15; so i%15 is rank + file (at most 14, never reaching
15). Its parity is the colour of the square: i%15%2+1 is 1 on a dark square and
2 on a light one. |= ORs it in: five bishops on the same colour still set just
one bit, while one bishop on each colour makes 3. A bishop on f1 sets 2, one on c8 sets 2 as
well (both stand on light squares), and one on d8 sets 1. With a white bishop on f1 and a
black one on c8, Z returns 6; move the black bishop to d8 and it returns 0.
Heavy pieces and knights are counted.4<<p*8 adds each type into
a bit field of its own. JavaScript takes the shift count modulo 32: for the queen
3*8 is 24, so 2²⁶; for the knight 4*8 is 32, so 0 and the result 4;
for the rook 5*8 is 40, so 8 and the result 1024. White's codes are 8 higher, so
the product is 64 higher, and 64 modulo 32 is zero — the colour bit drops out by itself.
Because += accumulates, knights pile up in bits 2–9, rooks in bits 10–25 and
queens from bit 26 upward; no piece count possible on a board makes one field overflow into
the next.
Pawns and kings into bitboards. The remaining codes — pawn and king — go to BigInt
sets: W and B the white and black pawns, _ and
f the white and black king. j doubles on every square, and since
map skips the holes, the bit order is that of the plain board: 8 × rank + file.
When the scan ends j is 2⁶⁴; the blocked half uses it as a bound.
Then the two terms. The first is !(s?B:W)>m: the opponent has no
pawn (Black's pawns if the side in question is White, White's if it is Black) and
m is zero, so the opponent has nothing but the king. The ! yields 1
or 0 as a number, and 1 is greater only when m is zero: >m
instead of &!m, one byte. A bare king cannot deliver
mate, whatever the position; the pawns of the side in question make no difference here.
The second catches, on a board with no pawns at all, the two kinds of material
that cannot mate on their own. 4%m is zero only when m is 1, 2 or 4:
bishops on a single colour, or a single knight. Bishops on both colours (3) give 1; two
knights (8), bishop and knight (5, 6), a rook or a queen give 4 — all meaning “mate is
possible”. Then the real subtlety: with this material, a mate is possible only if the side in
question blocks its own king's escape square with one of its own pieces — a helper is
needed. o&(…) looks for that helper:
Against a lone knight (m>3) the mask is 15423,
hexadecimal 0x3C3F: the bishop bits (0–1), the knight field (2–5) and the rook
field (10–13). A bishop, a knight or a rook can block the escape square; the queen never
helps, so it is not in the mask.
Against bishops on one colour the mask is 63^m: 63 covers both
bishop bits and the knight field, and the XOR removes the colour of the opponent's bishops.
The remaining helpers are a bishop on the other colour or a knight. A bishop on the same
colour does not help; nor does a rook or a queen.
With no helper, 4%m|o&… is zero and !(W|B) — 1 with no
pawns on the board — is greater: mate is impossible. The rule itself and a case-by-case breakdown are in the rules
section.
The opponent holds (no pawns on the board)
m
Helper that makes mate possible
With no helper
King only
0
—
Impossible (pawns or not)
Bishops on one colour
1 / 2
Bishop on the other colour, or a knight
Impossible
A single knight
4
Bishop, knight or rook
Impossible
Bishops on both colours
3
—
Possible
Two knights; bishop and knight
8; 5 / 6
—
Possible
Rook or queen
1024+
—
Possible
The one-sided question becomes the two-sided article in Z through a single
&. In each example White's material is named first. King and knight
against a bare king: V(0) and
V(8) are both true, and the position is dead. King and knight against king and
knight: Black's knight can help its own king, so V(0) is false and the game goes
on. King and two knights against a bare king: V(0) is false — mate is possible —
and V(8) true; the distinction matters at a flag fall, not for a dead position.
King and knight against king and queen: V(0) is true, because the queen does not
count as a helper; V(8) is false.
Dead position — V's blocked half
Material is one branch of the dead-position rule. The second is structure: the material
may be sufficient and the position still blocked. V's third term is the scan for
that: !(o|m|e-8||g(99,A=S=W,a=s=B)). Its own part — the helpers, the loop, the
exit test and the gate — takes 221 bytes; the board scan is shared by both halves.
The gate. The loop starts only if three conditions hold at once. o|m
must be zero: neither side has anything but king and pawns. A single knight, rook, queen or
bishop closes the gate, and the saturation loop never starts; that single line is the whole
reason blocked positions containing a bishop slip through — the cause
is the scope, not the bishop. e-8 must be zero too: no en passant capture is
available. This is a matter of chess, not of code. While an en passant capture is available
the position cannot be dead: the capturing pawn lands on the file the captured pawn
has just vacated, and its path forward is open there. This model cannot see that sideways
jump, because its advance sets grow vertically only; so the scan is not allowed to rule at
all.
The helpers sit in the parameter list, all of them BigInt. u=256n is
one rank: 8 bits. c=~j/~-u is a file mask. After the scan j is
2⁶⁴, so this is −(2⁶⁴+1)/255; integer division truncates towards zero, so the result is
−0x0101010101010101. In two's complement that is a number with every bit set —
including those above bit 63 — except the a-file bits of ranks two to eight.
H=s=>s+s&c|(s&c)/2n spreads a set one file to the right and left:
masking after the shift to the right blocks the wrap-around from the h-file into the a-file
of the rank above, and masking before the shift to the left blocks the wrap-around from the
a-file into the h-file of the rank below. N=s=>H(s|=s*u|s/u)|s first adds the
squares one rank up and one rank down, then spreads the result sideways and adds the set
itself back: a full king step.
The sets are bound to colour. The engine always counts the board from a1, so the
white pawns always walk up (*u) and the black ones down (/u). The
loop updates six sets: S/s the white and black pawns whose way ahead
is shut, A/a the squares the pawns can advance to,
_/f the squares the kings can reach. The blocked sets only shrink;
the other four only grow. Every round feeds them into one another: a king may enter neither a
square holding one of its own blocked pawns nor a square attacked by an enemy blocked pawn,
and a pawn may not advance onto a square occupied by an enemy pawn. The pawn sets never look
at the kings; whether a king can reach a pawn is asked at the exit. When no set changes any
more, saturation is reached.
Six questions remain at the exit. Two are about promotion: can White reach the eighth
rank, can Black reach the first. Four are about contact: can a pawn of either side capture an
enemy pawn or check the enemy king, can a king of either side reach an enemy pawn. If all six
answers are no, nothing on the board can ever change: the position is dead.
The saturation loop, line by line
The loop's body is six assignments and a recursion. The trick is in the shape:
g takes only n, but calls itself with six extra arguments.
Arguments are evaluated left to right before the call and then discarded, so the argument
list is the loop's body. No semicolon, no block, no return. When n
reaches zero the exit expression is returned. The starting values are passed in the same
way:
g(99,A=S=W,a=s=B).
g=n=>n?g(n-1,
S&=(B|S)/u, white pawns with the way ahead shut
s&=(W|s)*u, black pawns with the way ahead shut
A|=A*u&~B, White's advance: one rank up
a|=a/u&~W, Black's advance: one rank down
_|=N(_)&~S&~H(s/u), the white king: one more step
f|=N(f)&~s&~H(S*u)) the black king: one more step
:A*u/j | a%u can a pawn still promote
|H(A)*u&(a|f) can a white pawn capture or give check
|H(a)/u&_ can a black pawn give check
|_&B | f&W can a king reach an enemy pawn
Blockage.S&=(B|S)/u: /u shifts the squares in front
down one rank, onto the pawns' own squares. A white pawn stays blocked if the square in front
of it holds a black pawn or a white pawn already counted as blocked; the second condition
handles pawns stacked on one file as a chain. S starts out as all the white pawns
and only ever shrinks: a pawn that drops out of the blocked set takes the one behind it along
on the next round. s is its mirror for Black; Black moves down the board, so the
shift is *u. A black pawn on the square in front is a permanent obstacle: this
white pawn blocks that black pawn's way too, and only a capture can break the pair — and
captures are what the exit asks about.
Advance.A|=A*u&~B: White's advance set starts on the pawns' own
squares and reaches one rank further up every round, but it cannot enter a square occupied by
a black pawn. a does the same for Black going down, stopping at white pawns.
These sets do not claim that a pawn can actually get there; only that no pawn blocks the
way.
The kings._|=N(_)&~S&~H(s/u): the white king's reach starts
on its own square and grows by one king step per round. It may enter neither the squares of
its own blocked pawns nor the squares Black's blocked pawns attack. s/u shifts
those pawns one rank down, in Black's capturing direction, and H spreads them one
file to each side: the diagonal capture squares. f does the mirror calculation
for the black king. Only the blocked pawns' attacks are taken into account; squares attacked
by mobile pawns are left open, which can make the reach look larger than it is — any error
always falls on the side of “still alive”.
The six terms at the exit.A*u/j asks whether White's advance touches
the eighth rank: with a bit on that rank, A*u is at least 2⁶⁴, that is
j, and the quotient is not zero. a%u asks whether Black's touches
the first rank (bits 0–7). H(A)*u&(a|f) finds the squares a white pawn could
attack diagonally from anywhere along its advance, and asks whether any of them is a square a
black pawn could occupy — a capture — or lies within the black king's reach — a check.
H(a)/u&_ is the black pawn's same question against the white king; a black
pawn capturing a white pawn is just the same pair seen from the other side, already in the
previous term. _&B and f&W ask whether a king can reach an
enemy pawn's square and take it. On a board with only kings and pawns left, only these
contacts can lead to a mate; with none of them, the position is dead.
No set assumes an obstacle without proof. Pawn advance stops only at pawns, king reach
only at blocked pawns and the squares they attack; anything in doubt falls on the “possible”
side. That is the source of the one-sided guarantee of the “dead” verdict — valid only once
the sets have saturated, which is what the 99 rounds below ensure.
The starting values come from the scan: W, B,
_ and f were filled in the count.
g(99,A=S=W,a=s=B) starts the advance and blocked sets on the pawns themselves.
Two letters are reused: A was the array of material tallies a moment ago and is
now White's advance; s was the side in question — the first term has already
read it — and is now Black's blocked pawns. Whether both kings are on the board is
not asked separately: in every legal position both are.
Six leaks.W, B, _, f,
S and a are declared nowhere and write to globals. Harmless: nothing
but V reads those globals; the f of G, U and
A and the S of G are parameters of their own. There are
off-board bits too: the sideways shift carries h8, and *u the whole eighth
rank, beyond bit 63, and up there the king reach grows by another rank every round. They trigger no exit
term: the pawn sets stay inside 64 bits, and White's advance can only get up there by passing
through the eighth rank — at which point the first term is true anyway.
99 rounds. The pawn sets do not depend on the kings; since at most six pawns can
stand stacked on one file and a pawn can advance at most six ranks, the blocked and advance
sets stabilise within six rounds at most. From then on the kings' forbidden squares stop
changing too, and every round adds at least one square until the reach is complete; on a
board of 64 squares that takes at most 63 rounds. The total cannot exceed 70, and the loop
stops at 99 — so the bound is not measured but proved. 99 is the largest number that
fits in two digits. In practice saturation comes much sooner: between four and eight rounds in the
blocked positions I measured.
The bound's real job is to protect the guarantee. If the exit questions are reached before
the sets have saturated, the kings' reach is incomplete and the engine may call a live
position “dead” — it errs in the wrong direction. The only proved property of this half
— every position it calls dead really is dead — rests directly on that bound.
Soundness, measured. The blocked half works in one direction only: it does not call
a position dead unless it is sure, because a wrongly declared draw cannot be taken back. From each of
the 48,135 king-and-pawn positions in the CHA-Solver record whose verdict comes from
the blocked scan, every reachable position was scanned by an independent search that uses
none of the engine's own functions — captures and en passant included, 46.6 million
positions in all. Not one of them allows a promotion or a mate, nor even a capture: no king
can take an enemy pawn, and no pawn can take another. Every position the scan calls dead is
dead.
What it costs.V alone is 405 bytes — about 27% of the
engine; it is the most expensive article of the rulebook. The blocked half covers pawns and
kings only; there are also completely dead, blocked positions with bishops on the board. Why they are
left out is explained on the Technical detail tab.
The move walker — O
Both adjudication questions require visiting every legal move: Z must show
that there is none, H that every line closes. Both use the same walker —
59 bytes.
Two nested every calls, both of which skip the holes. The outer one walks the
origin squares; if p&8^t is non-zero — the piece is the opponent's, or the
square is empty on White's move — the square is skipped, and || returns true to carry on. The inner one walks the
destinations: !L(j,x) skips an illegal move, and for a legal one
h(j,x) is called. If h returns a falsy value, both every
calls stop there and O returns false; otherwise it returns true once every move
has been visited.
So O(_=>0) is exactly the checkmate-or-stalemate question — an absence test: on the first
legal move h returns zero and the walk stops, so O(_=>0) is true
only when there is no legal move at all. In H, h is the search step
itself, and every means “every line has closed”.
h is given the origin and destination squares, but what really matters is a
side effect: when h is called, L(j,x) has just run and written the
castling rook's square to r and the promotion flag to X.
H relies on both when it plays the move.
The filter p&8^t lets empty squares through on Black's move: on an empty
square p&8 is zero, and so is t. It is the mirror image of the
leak in T, this time on the other colour. Harmless, because no
move from an empty square is ever legal; but not free: L is called 64 times for
every empty square. The measurement is below.
Adjudication — Z
Every ending that arises on the board turns on one question: does the side to move have
a legal move? If not, it is checkmate or stalemate; if so, the game can end only in an
automatic draw. Z has exactly the shape of that distinction — in 51 bytes.
Z=_=>O(_=>0)
? T() ? 2-!t : 3 no move → checkmate or stalemate
: V(0)&V(8) ? 6 : R>4 ? 5 : n>149 ? 4 : 0 a move exists → automatic draws only
The mate code is 2-!t. With White to move (t=8), White is the
side mated: !8 is false and the code comes out 2 — Black wins by checkmate; with
Black to move, !0 is true and the code is 1. A single ! names both
outcomes. Without check, the same “no move” is stalemate: 3.
The dead position takes a single &: V asks for one side,
5.2.2 for both — if neither side can be mated, the position is dead and
the code is 6. The material half and the blocked half live inside the same
V; whichever returns true, the code is the same, because the article is the
same.
The order of the branches is itself a rule. FIDE Article 9.6 says
that fivefold repetition and the 75-move rule end the game in a draw — unless the last move
delivered mate. Here that exception was never written, because mate lives inside the
“no move” branch: on a mate with the ply counter at the 75-move limit, the engine never even
looks at the counter, because it never enters that branch. The precedence is not encoded; it is a consequence of the
shape.
The cost of the distinction is asymmetric. Concluding “the game continues” takes only the
first legal move found; concluding “mate” requires trying every move and proving that none is
legal. And because O's filter lets empty squares through on Black's move, the
cost also depends on whose turn it is — the measurement is below.
The impossibility of mate — H
V looks at the board statically: can this material, in this structure, ever
produce a mate? Article 6.9 asks something narrower: can a mate arise
from this position? The material may be more than sufficient while the position rules
mate out entirely. H is the search that closes the gap —
149 bytes.
H=(d,v=t^I|!T(),s=[b,e,n,t,c,k])=>
$[k]<=n || V(I) two gates
|| O((j,x)=> v = n>149 75-move limit reached → drawn anyway
|| d depth
&& (X?[3]:[3,5,6,4]).every(p=> one pass unless promoting
N-- budget: one unit per piece tried; stops at 0
&& (M(j,x,p),
H(d-1)|([b,e,n,t,c,k]=s))), recurse, then undo
$[k]=n) the visit: O's unread second argument
&& v leaf: has I been mated
What it asks is exactly what the article asks: can the side whose flag fell, or who
resigned, be mated by its opponent through any series of legal moves? It does not test
for a forced mate; it tests whether even a helpmate is impossible. The side in question
is not a parameter but the global I set up by F; if
H(d) returns true, mate is impossible and the result is a draw; if it returns
false, mate is possible or its impossibility could not be proved. Within the engine,
F is its only outside caller.
There are two gates. $[k]<=n exits if the position has been visited before
with the same or a smaller counter — the table stores neither a yes/no answer nor a depth but
the counter value at the time of the visit; the reason is explained below. The key
k is the same as the repetition table's. F resets the table on every
call, so nothing one search learns carries over to the next. V(I) exits if
I cannot be mated in this position because of material or structure — checked at
every node.
Where the visit is recorded is a subtlety of its own: $[k]=n is passed to
O as a second argument. O reads only h, but since
arguments are evaluated before the call, the position is marked before its children are
searched. No separate statement, no extra comma; a line that loops back to the same position
is cut at the first gate.
Its neatest trick is the second parameter. v=t^I|!T() is a leaf test: if it is
not I to move, or I is not in check, there is no mate here. When
both are false and there is no legal move — O returns true without ever calling
the callback — v keeps its default value, zero: a mate has been found. The mate
test is never written as a branch of its own; it is folded into a parameter default.
The same expression also tells stalemate apart.
The undo is inside the expression too. The third parameter, s=[b,e,n,t,c,k],
takes the six-element snapshot on entering the node; in H(d-1)|([b,e,n,t,c,k]=s)
the array assignment, as an operand of |, converts to NaN, that is,
0, and contributes nothing: the recursive answer passes through unchanged. The promotion types are chosen from
X: just before O calls the callback, L(j,x) has run and
written the flag. If the move is not a promotion the array is [3], a single pass;
if it is, [3,5,6,4] — queen, rook, bishop and knight. The array is built before
the loop starts, so deeper calls overwriting X change nothing.
What it seeks is not a mate but a proof that mate is impossible.H does
not try to find a helpmate; it tries to prove that not even a helpmate can be built — it looks
for evidence against mate. The static gate comes first: V(I). If it cannot
decide, the search begins. In every position that passes the gates, every legal move of the
side to move is tried — for both sides, since a mate can also arise through cooperation. On a
promotion all four pieces are tried. A line closes only when it reaches an outcome that ends
without mate: a stalemate, a counter-mate (I mating its opponent), a position in
which
I can no longer be mated (V(I); positions dead by material or by a
lock close here too), a return to a position already seen ($ — in a
blocked position, the only thing that lets the search finish) or the 75-move limit
(n>149). All of these are logical cuts: cutting there can never hide a
mate. The N budget and the 9-ply depth are artificial limits. Both
O and every mean “all”: H returns true only once every
line has closed logically. If I is mated, or a line runs into one of the
artificial limits, the walk stops there and returns false: mate is possible, and the
verdict is a loss (WT, BT, WR, BR).
This is a one-sided guarantee. Every position H calls impossible really
is impossible, because there is no draw without a proof. The converse is not guaranteed: if
the proof does not fit within the budget, a position that is in fact impossible may count as
“possible”. Any error always falls on the rule's default outcome — a loss, at flag fall and on
resignation.
The search goes through O's filter, so on Black's move the empty squares are
visited too; and the T() in the leaf test polls the empty squares on White's
move. Both are harmless, and neither is free.
$ is not an optimisation
At first glance the search table looks like an ordinary transposition table: never search
the same position twice. But in a blocked position its job is different — it is what
allows the search to finish at all.
In a blocked position the only available moves are king and bishop shuffles, and every one
of them sooner or later returns to a position already seen. Without the table the search
never notices the loop; the depth runs out, the line is left unclosed and the answer becomes
“mate is possible”. With it, every line that returns is cut, every line closes, and
the impossibility is proved.
The difference is measurable. In the position below the pawns of both sides are locked
head-on, the bishops are confined to their own halves and the kings to a narrow area:
1kb5/1p1p4/1P1P4/8/8/4p1p1/4P1P1/5BK1 w - - 0 1
F(0,0) — Black's flag falls
Nodes
Tries
Table entries
Result
The engine as it stands
5
4
4
14 · draw
The $ gate disabled
10
9
—
10 · White wins
Five nodes, four tries and four table entries; the fifth node is the visit that returns to
the starting position and is cut at the first gate. Switch the table off and the search does
twice the work and returns the wrong verdict: it follows a single shuffle line
9 plies down and gives up when the depth runs out. The blocked scan is not
even involved here — there is a bishop on the board, so it is out of scope — and the material
is more than sufficient, so V(I) has nothing to say either. The search decides
alone, and the table is what makes that possible.
Why does the table store a counter? Three observations are enough. First, the search
can end early in only one way: if any line comes out “mate is possible” — a mate found, or the
depth or the budget exhausted — the whole search stops there and F declares the
loss. So every recorded visit has either finished as “closed” or is still an ancestor on the
current path. There is no need to store a depth: a visit that hits the depth limit never
finishes as “closed”. Second, the tree below a position depends on the counter: the 75-move
limit closes a line at n>149, so the smaller the counter, the more play lies
ahead. A position that closed with a smaller or equal counter also closes with a larger one.
Third, for an ancestor: if the path has returned to the same position, there has been no
capture and no pawn move in between, so the counter has grown; everything reachable from here
is already being explored in the ancestor's own search.
The key (b+e+t+c) does not contain the ply counter; the counter is stored as
the table's value, and the gate compares it with <=. A visit that reaches the
same board with a smaller counter — after a capture or a pawn move, say — is searched again,
because more play lies ahead of it.
The budget — 2,000 tries, 9 plies
Every time F calls H it sets two limits: N=2e3 and
H(9). The first is a budget shared by the whole tree: each move tried takes one
unit off it through N-- (in a promotion each piece counts as a separate move, so
the four promotions cost four), and the 2,000 covers the entire search, not each branch. The
second limits the depth of any single line: nine plies — the largest number that fits in a
single digit. Whichever runs out first leaves that line unclosed, and the answer is that
mate is possible. A limit never creates a draw; it only reduces the chance of proving
one.
The two limits do different jobs. Depth sets the cost of ordinary positions. With
plenty of material and an open game, mate is plainly possible, but H does not set
out to prove that; it reports it when it fails to prove the impossibility. It walks down a
single line; whether it meets a helpmate on the way or fails to close the line at the bottom of
the 9 plies, the walk stops at the first false and carries it back up. From the starting
position that takes 9 tries, whichever side is in question. The budget is the ceiling for
the extremes: it caps how much work the search can do in narrow but long trees of forced
moves.
In practice neither limit is reached very often. A forced capture closes in a single try,
and positions where the opponent is one ply away from a repetition or the counter limit close
within a handful. The measurement is on the Technical detail tab:
over all 201,060 positions of the CHA-Solver record up to 31 July 2026, with the game's own limits,
engine.js reaches its verdict in a median of 0.1 milliseconds; 99% take at most
0.7 milliseconds, and the heaviest takes 56. Most positions of the record close in a single
node. On the same record engine_4x is about 6 times faster — its median below the
clock's 0.1-millisecond step, 6.9 milliseconds at worst. The coverage agrees with these
limits: on the record extended to 30 September 2026 the draw is proved within them in
200,384 of the 204,060 positions. In all of
the 3,676 left unproved the search runs into the depth limit, and the budget runs out in none;
3,638 of them have a bishop on the board, which puts them outside the blocked
scan. That comes to roughly one game in 2.2 million across the whole record. So in more than 99.9% of the positions that arise at a flag fall or a
resignation these limits are enough, and the verdict arrives within milliseconds.
The positions that do reach the ceiling are rare. In the two-knight position from
the Rules tab, the draw is proved in 72 tries, within tens of
milliseconds, with the counter at 148; at 147 it takes 688 tries, and at 146 the budget runs
out and the answer is that mate is possible. Even a search that hits the ceiling runs for a
single position, at most once a game. The budget stops at 2,000
because of a trade-off: a larger one would narrow the rare strip a little further, and
lengthen the wait proportionally in every position that hits the ceiling.
Chess-LUX, with a looser byte budget, works with a depth of 300 plies and a
budget of 400,000 tries.
Application — A, D, F
A — three steps, all inside one argument
A=(i,f,p)=>Z(R=G[M(i,f,p)]=-~G[k])
1 M(i,f,p) apply the move — rights, counter, turn inside; return the new key2 R=G[…]=-~G[k] count the new position3 Z(…) return the verdict
The castling rights, the counter and the side to move are updated not by A but
by M, because the search makes moves too and they must stay up
to date in its branches. A's only business with the board is calling
M; what remains is the count and the verdict.
The order is the order of evaluation. The left-hand side of an assignment is evaluated
before the right: while the property key of G[M(i,f,p)] is being computed,
M runs, plays the move and sets k to the new position. The
right-hand side, -~G[k], is evaluated after that, so it reads the new position's
count. ~undefined is minus one, and negating it gives one: a position seen for the
first time goes straight to 1 without any initialisation. The chained assignment writes the
same value to R as well. All of this happens inside Z's argument;
Z never reads its argument — the argument only fixes the order of events. No
comma and no parenthesised sequence are needed.
Three things Adoes not do are part of its definition too: it does not
check legality, does not touch the clocks and does not clear the draw offer. The first is
L's job — and in castling the rook's square comes from that call; the other two
are driver policy, not rules.
D — claim and offer
D=s=>(o^=2-s/8)>2?9:R>2?8:n>99?7:Z()
The offer first: the bits live in o, White's in the 1 bit, Black's in the 2
bit. 2-s/8 picks between them in one expression: 2 − 1 for White, 2 − 0 for
Black. XOR toggles the bit, so the same call both makes and withdraws an offer. With both bits
set o is 3, and >2 gives a draw by agreement (9). Then the
claims: if the position has occurred for the third time, threefold repetition (8); if the ply
counter has passed 99, the 50-move rule (7); if neither holds, it returns
Z().
The order comes from the rule itself: every claim under 9.2 or 9.3 is also an offer of a
draw (9.1.2.3). So the bit is toggled before the claim is examined,
and on every call. If the opponent has an offer pending, the claim accepts it and the game
ends by agreement (9); the claim itself need not be looked at. A claim that does not hold is
not wasted either: the bit stays set, and it stands before the opponent as an offer.
Called with no argument, 2-undefined/8 is NaN, and
o^=NaN leaves o as it was — so the same function can also be used to
read the verdict alone, without disturbing the bits.
That the thresholds differ from those in Z is no accident. The 50-move rule
and threefold repetition must be claimed; the 75-move rule and fivefold repetition
apply automatically. FIDE puts the two in separate articles; the
engine puts them in separate functions. What is optional goes in D, what is
mandatory in Z.
F — flag and resignation
F=(s,r)=>($=[I=s],N=2e3,H(9))?14+r:11+2*r-!s
s is the side whose flag fell or who resigned, and r is 0 for a
flag fall, 1 for resignation. The setup fits in one pair of parentheses: $=[I=s]
both stores the side in question in I and provides a fresh, empty search table —
the array's only element sits at index 0, while the position keys are long strings, none of
them "0", so that element never mixes with the table's entries.
N=2e3 sets the budget, and H(9) asks the question.
If the answer is true, the opponent cannot possibly mate and the result is a draw — 14 or
15. Otherwise the opponent wins, and a single expression names all four outcomes:
11+2*r-!s. r separates a flag fall from resignation, and
!s picks the side — if White lost, !8 is false and the result is 11 or
13 (Black wins); if Black lost, !0 is true and it is 10 or 12 (White wins). 10 and
11 come from the clock, 12 and 13 from resignation.
Measured on a blocked position (k7/8/8/1p2p2p/1P2P2P/8/8/K7):
F(0,0) returns 14 and F(8,1) returns 15 — two separate codes for flag
fall and resignation, 6.9 and 5.1.2 each reported on its own.
The full trace of one move
The numbers below were measured by running the engine: a counter was attached to each
function, and the number of calls during a single top-level call was recorded. Squares are 0x88
indices: e2 is 20, e4 52, e1 4, g1 6.
An ordinary move — e2-e4
The driver first validates the clicked or typed move with L(20,52): true. A
driver that wants to highlight every destination of the piece asks about all 64 squares;
L then runs 64 times and returns true only for 36 and 52, that is, e3 and e4.
Then A(20,52). Inside, M's six steps run: a pawn has moved, so the
counter resets; the castling mask does not change (C(20) and C(52) are
zero); U puts the pawn on 52 and returns the candidate square, 36; the turn passes
to Black; the candidate is filtered; the key is built. Then the new position is counted once,
and Z returns 0.
The en passant square is not kept. It is a double step, but none of Black's eight
pawns can legally capture on 36, so e reverts to 8. The same move, with a black
pawn on d4, would have kept the square (e=36) — both were measured.
Call
G
T
U
L
M
C
V
O
Z
L(20,52)
49
1
1
1
—
—
—
—
—
L(20,·), 64 targets
144
2
2
64
—
—
—
—
—
A(20,52)
1,593
1
2
2,089
1
2
2
1
1
The distribution captures the shape of the engine. A single L asks 49 geometry
questions: one for the pawn itself, 48 in T's check scan — on White's move, 32
empty squares pass the filter along with the 16 black pieces. The move itself is almost free —
one M, one U, two C; the en passant filter adds eight more
L calls, but since no black pawn gets past the geometry, all of them are cheap.
Nearly all of the 1,593 geometry questions come from the last step: to prove the game is not
over, Z has to show that Black has a legal move, and O tests more than
two thousand moves before finding one. The engine's effort goes not into making the move but
into arbitrating.
2,048 of the L calls come from empty squares. O scans the
board from a1, and on Black's move its filter p&8^t lets empty squares through
as well; until it reaches the first black piece, the pawn on a7, it asks about 64 destinations
for each of 32 empty squares. Testing moves from an empty square is cheap — G
falls into the slider branch and returns zero, and neither U nor T
runs — but it is not free. The first legal move it finds is a7-a5. The same Z call was
measured for each colour:
Z() at the starting position
G
L
Ratio (G)
White to move
97
81
—
Black to move
1,585
2,081
16.3×
The same position, the same verdict, more than sixteen times the work. On White's move
O starts at the rook on a1, which can reach none of its 64 targets, and finds the
first legal move of the knight on b1 on the 17th try: 81 L calls in all. This
asymmetry is not slowness but a bias built into the shape of the code — and
the speed section describes how that gap is closed.
Kingside castling
After 1.e4 e5 2.Nf3 Nc6 3.Bc4 Bc5 has cleared the way, a driver asking for the king's
destinations calls L(4,·) for all 64 squares; those that return true are 5, 6
and 20: f1, g1 and e2. g1 appears among them like an ordinary destination; the driver
never learns that it is castling — it merely clicks a square. Every condition for castling has
been tested inside G.
In that scan C runs once — in G's castling branch, for g1 — and
T five times. Two of the five come directly from G's castling branch
— the calls that check that the king is not in check and that the square it crosses is not
attacked — and three from L's trials on f1, g1 and e2. U runs four
times: three trials, plus the inner call that moves the rook in the g1 trial. Queenside castling
is never even considered: with the bishop still on c1, L's filter rejects the
target before geometry is reached.
Then L(4,6) and A(4,6). In the castling branch, L
stores 7 (h1) in r. Inside A, C(4) returns 3 and the
mask drops from 15 to 12 — both of White's rights go in one step. In U the
fingerprint comes out 8, and the inner call moves the rook from r to the square in
between, f1, leaving h1 empty. The measured final state: white rook on f1 (13), white king on
g1 (10), h1 empty.
Call
G
T
U
L
M
C
V
O
Z
L(4,·), 64 targets
288
5
4
64
—
1
—
—
—
L(4,6)
145
3
2
1
—
1
—
—
—
A(4,6)
893
1
3
1,174
1
2
2
1
1
En passant
In the sequence 1.e4 a6 2.e5 d5 the real work happens on Black's double step,
A(99,67). In U the fingerprint comes out 1024, and the candidate
square, the midpoint of 99 and 67, is set as e=83 (d6). The turn passes to White,
and M's fifth step tries White's pawns one by one. For the pawn on 68 (e5),
L(68,83) returns true: G's pawn branch sees the diagonal step, and
f==e holds. That is only possible because the candidate has already been written
to e. The result: e=83 is kept.
The same move, with no white pawn on e5, ends with e=8. Both were measured.
When White captures with A(68,83), U's second step comes into
play: since the pawn has landed on the en passant square, the square to be cleared is
83^16, that is 67 — d5. The measured result is d5 empty, a white pawn on d6, e5
empty. And because this move is not a double step, the candidate is 8 and e reverts
to 8.
Call
G
T
U
L
M
C
V
O
Z
A(99,67) d7-d5
154
2
3
89
1
2
2
1
1
A(68,83) exd6
1,274
1
2
1,640
1
2
2
1
1
The pipeline of endings
The fifteen outcomes are not independent of one another; they are separate exits of one
pipeline. That is why the result table reads like a periodic table: the
rows tell you at which stage an ending is decided, the columns who wins. Z
produces checkmate, stalemate and the automatic draws, D the claims and offers,
and F, with H, flag fall and resignation.
The order of the stages is an order of precedence, and it comes from the rulebook.
Z first asks whether a legal move exists, which is why mate comes before the
75-move rule and fivefold repetition — the “unless the last move was mate” proviso of
9.6. Within a stage, too, the first match wins. A dead position can
never coincide with the other two automatic draws: a position becomes dead only through a
capture or a pawn move, and both reset the counter. A quiet move can change what the scan
returns only by letting the en passant right of a double step lapse; at that moment the
counter stands at 1 and the position is new. Fivefold repetition and the 75-move
rule, on the other hand, can be reached on the same move, and then the label is 5R. Both are
draws; only the name differs.
Claims and offers are considered only if the game has not ended by itself and a request
bit is set. How a driver calls this after a move is covered in the driver section. Inside D the order is fixed again: agreement,
threefold repetition, 50 moves. Since every claim is also an offer
(9.1.2.3), a claim made while the opponent's offer is pending ends
the game by agreement; if the threefold and 50-move conditions hold together, 3R wins.
Flag fall and resignation come in on a separate branch, and only while the game is still
in progress: both go to F, and F asks H. If
H says mate is impossible, the result is TM or RM; otherwise the opponent wins.
That is the opening clause of 6.9 itself: checkmate, stalemate, a dead
position and agreement all take precedence over flag fall.
The box on the left is each stage's question, the cards on the right its exits.
› means the earlier one takes precedence within a stage; the card colours match the result
table.
The ladder of short circuits
Four separate tests reach the same result: the two halves of V (material and
lock), the repetition count and the ply counter. All of them declare the game drawn. What
separates them is how early and how cheaply they do so.
There are two safety nets at the bottom, the 75-move rule and fivefold repetition: every
blocked position falls into one of them eventually (section four), and
since a blockade makes mobility collapse, repetition often comes first. The two halves of
V short-circuit both by recognising the position as soon as it arises. And the
same V, asked about I, short-circuits H: if material or
structure rules out mate, the search never opens on that line — and this is checked at every
node.
In principle H could subsume all of them. With unbounded depth and budget it
would exhaust the reachable state space and prove the impossibility itself. But that has been
measured, and the measurement is not encouraging.
k4b2/8/8/p1p1p1p1/P1P1P1P1/8/8/K4B2 wblocked
Nothing in this position can cross to the other side. The pawns are locked head-on, with
no captures available on the diagonals; each bishop is confined to squares on its own half;
neither king can get past the rank covered by the enemy pawns. It is a dead position. But
there is a bishop on the board, so V's blocked half is out of scope, and the
material is more than sufficient, so the material half has nothing to say either. That leaves
the search itself. F(0,0) was measured with the depth varied:
Depth
Answer
Tries
9 the engine's depth
mate is possible
10
15
mate is possible
19
23
mate is possible
31
35
mate is possible
43
99
mate is possible
178
149
mate is possible
255
150
mate is possible budget exhausted
2,001
Up to 149, the number of tries grows linearly with depth, not exponentially — and the
answer never changes. The reason lies in H's design: O and every
stop at the first false, and when the depth runs out the line is left unclosed and reports
“mate is possible”. The search walks a single line to the bottom and gives up there. It moves
on to a sibling only when the previous move closed by returning to a position already seen;
the moment a line hits the depth limit, false travels back up and no other branch is
tried.
So H can only prove impossibility when every line closes before the depth
runs out. With the kings and bishops free to move, there is always a line that keeps
visiting new positions until it hits the depth limit. The 75-move limit comes into play only once the depth
reaches 150, closing every line there at the latest; but then the tree grows to the product of
king and bishop placements, and the budget runs out at 2,000 tries. That is exactly why the
short circuits exist: V's blocked half reaches the same ruling — when there is
no bishop on the board — by a scan, without the search ever opening.
And this explains why the two functions stand apart. V and H ask
the same question but take different routes: one statically, from the board alone, the other
by searching ahead. Where one ends, the other begins — every node of H asks
V first — and both arrive at the same article.
Verification
The tests it passes
The engine's handling of the rules was tested with perft: the number of legal move
sequences from a position down to a given depth, compared against published reference
values. A discrepancy of even a single node means an error in the rules, which makes perft
the strictest test of an arbiter engine — it exercises move generation, king safety,
castling rights and en passant availability all at once.
The comparison draws on four sources: the Chess Programming Wiki's published node counts,
van Kervinck's list of tricky positions, the 6,838-position Vajolet corpus, and Stockfish.
Where no published value exists at shallow depths, the comparison comes from a naive 0x88
implementation written directly from the rules, which shares not a single line of code with
the engine.
The depths below are consolidated: where a position was split across several runs — first
up to d5, then d6 and d7 in a separate run — it appears on a single row at the greatest depth
reached. Every position was also verified at every intermediate depth from d1 up to that
one.
The standard CPW positions
Seven positions, including the colour-mirrored version of the fourth. The mirror is not
there by chance: an error that breaks colour symmetry shows up only there.
Position
Depth
Nodes
Compared against
Starting position
d6
119,060,324
CPW published values
Kiwipete
d5
193,690,690
CPW published values
Position 3
d7
178,633,661
CPW published values
Position 4
d5
15,833,292
CPW published values
Position 4 mirrored
d5
15,833,292
CPW published values
Position 5
d5
89,941,194
CPW published values
Position 6
d5
164,075,551
CPW published values
van Kervinck's tricky positions
Illegal en passant, castling out of check, a discovered check opened by an en passant
capture, promotion and underpromotion giving check, the stalemate–checkmate distinction,
evading a bishop pin. In ten of the fifteen positions the tests went beyond the published
depth; since no published values exist there, those depths were verified against
Stockfish.
Position
Published
Reached
Verification
Illegal en passant #1
d6 · 1,134,888
d6
Published value, Stockfish-confirmed
Illegal en passant #2
d6 · 1,015,133
d6
Published value, Stockfish-confirmed
En passant gives check
d6 · 1,440,467
d6
Published value, Stockfish-confirmed
Kingside castling gives check
d6 · 661,072
d7
Stockfish
Queenside castling gives check
d6 · 803,711
d7
Stockfish
Castling rights
d4 · 1,274,206
d5
Stockfish
Castling prevented
d4 · 1,720,476
d5
Stockfish
Escaping check by promotion
d6 · 3,821,001
d6
Published value, Stockfish-confirmed
Discovered check
d5 · 1,004,658
d6
Stockfish
Promotion giving check
d6 · 217,342
d8
Stockfish
Underpromotion giving check
d6 · 92,683
d8
Stockfish
Self-stalemate
d6 · 2,217
d8
Stockfish
Stalemate and checkmate I
d7 · 567,584
d8
Stockfish
Stalemate and checkmate II
d4 · 23,527
d4
Published value, Stockfish-confirmed
Evading a bishop pin
d5 · 1,063,513
d6
Stockfish
Bulk runs
Hand-picked positions catch the edge cases; bulk runs catch what turns up in ordinary
play.
Run
Coverage
Method
Vajolet corpus real game positions
100 positions × d4 for coverage, the first 10 positions × d5 for depth — 450 perft checks
The corpus's own reference values
Random play not perft
20 seeded random games from Kiwipete, up to 80 plies each
The complete legal move list at every position, sorted, matched exactly against Stockfish
The random-play run does not look at node totals; it compares lists. A total can match
even when two errors cancel each other out; the list comparison rules that out.
The tests run by default on engine_4x.js. To test engine.js itself,
set ENGINE=./engine.js.
Run them yourself
All the numbers above amount to a single claim: the engine produces these node counts in these positions. You do not have to take my word for it — you can check.
The repository is public:
github.com/cuneytinann/FideLite. To
download the whole test folder as one file, use
test.zip · 251 KB — it contains the suite, copies
of all five engines, the Vajolet position list, eight double-clickable test .bat
files and the speed comparison (bench.js with speed-comparison.bat), and
nothing else is needed. On Windows, double-clicking a .bat file is
enough; on other platforms, run node test.js <command>.
Five of the eight runs need only Node. The standard CPW positions, the Vajolet
suite and the deep single-position runs compare against published values; no external engine
is required. The quickest is 1-sanity-test.bat.
Two require Stockfish: the random-play simulation and the verifications that go past the
published depth. One uses it optionally — the tricky-positions run adds a cross-check if
Stockfish is available and falls back on the reference values if not.
Stockfish is not included in the package; by itself it exceeds 100 megabytes. Put
your own binary in the test folder or add it to PATH; the suite looks first at the
STOCKFISH environment variable, then for stockfish.exe on Windows,
then in the usual install paths on Linux and macOS.
For the cases where neither a published value nor Stockfish is available, the package
includes ref.js: a naive reference written straight from the rules, which shares
not a single line of code with the engine. When the two arrive at the same number, that shows
genuine agreement rather than a shared bug. Its board is 0x88 too,
like the engine's, but the two implement it in entirely different ways: ref.js
detects off-board squares with the classic i&0x88 mask, reads its directions
from tables and stores its pieces as objects. Everything for which the engine relies on holes,
remainder tests and single-digit codes is spelled out there in full.
Verification
Testing the verdicts: the Arbiter
Perft tests move generation; it says nothing about how a game ends. Yet that is where an
arbiter engine's hardest verdict lies: when a flag falls or a player resigns, can the opponent
mate by any series of legal moves (6.9 and 5.1.2)? The Arbiter asks
that question in bulk: it judges each position with the engine's own F, shows
which test settled it, and compares the result with a reference.
Like the builds, it is a single HTML file: nothing to install, no server, nothing sent
anywhere; the positions are judged inside the browser. The interface is in English and
Turkish. It opens in a new tab:
fidelite.art/arbiter.
How a verdict is reached
The side whose flag fell is the side to move in the FEN: in a game only its clock runs.
On request each position is also judged twice, once for each side's flag. Four tests run in
this order; the first one that settles the question gives the verdict, and the ones after it
are shown for information only.
Test
What it shows
Verdict
1. Material V
The other side has no mating material left: V's material half, with the lock switched off.
TM
2. Dead position V(0)&V(8)
The structure is locked for both sides; neither can mate. In a game, the question Z asks.
DP
3. Lock against the other side V(I)
The structure is locked against the other side only. H asks this at its root, without spending a node.
TM
4. Helpmate search H
Every line is followed until mate becomes impossible, or a mate is found. With no legal move at the root, or the 75-move rule already reached, it returns before it starts.
TM, WT or BT
The limits are the game's: F searches 9 plies deep and up to 2,000 nodes,
and the page lets you change both. If the search hits a limit without a proof, the verdict
falls to the win, WT or BT: a draw may be missed, but a false draw
is never declared. That is the safe direction set out under
5.2.2.
Against the reference
The reference is either a CHA-Solver label at the start of the line (WB,
W-, -B, --: which side can build a helpmate) or a
file in which every row is a draw. Every verdict that has a reference falls into one of four
classes:
Agrees. By proof, or by the default win.
Missed draw. The reference says draw, but no proof was found within the limits.
It costs completeness, not soundness.
False TM. The engine declared a draw where the reference says the other side can
still mate. The one error that breaks soundness; it must be zero.
Contradiction. The search found a mate the reference calls impossible. Either the
reference or the move generation is wrong; each one is worth reading.
Build against build.engine.js and engine_4x.js are
embedded byte for byte and return the same verdict on every input. “Also run the other
build” reports any difference and the speed factor, and the page shows what separates the
two builds, definition by definition, computed from the two sources as it loads. The
letter-board engine_string.js and LUX's engine (300 plies, 400,000 nodes) can
be selected too.
Detail view. For each position, the result of all four tests, the nodes spent,
the time, the reference, and a link that opens the position in Lichess's board editor.
FEN checks. Necessary conditions: one king per side, no pawn on the first or last
rank, extra pieces explained by promotions, the side not to move not in check, a check that
can arise, a consistent en passant square and halfmove clock. A line that fails one gets no
verdict and stays out of the comparison.
PGN test. Replays games move by move and finds the moves played after the rules
had ended the game, and the games lost on time or by resignation that should have been
drawn.
Export. The results download as CSV, class by class.
Try it yourself
Open fidelite.art/arbiter. Paste
FENs one per line (with a CHA-Solver label in front if you like) or drop CSV and TXT files
onto the page; Ctrl+Enter judges.
The data is not hosted on the site; the measurement set is in the repository:
chasolver-data-until-10-2026.csv · 30 MB.
These are the games CHA-Solver found wrongly decided in the Lichess open database, one row
per game with the FEN of its final position. The page recognises the file and marks every
row a draw on its own.
The same arbiter/ folder holds CHA-Solver's own labelled test sets:
chasolver-lichess.txt, 65,536 positions sampled from Lichess games, and
chasolver-positions.txt, 3,414 positions from queries submitted to
chasolver.org since 2021. The page reads both formats directly.
This tab is about how the engine reached its present form, and about its edge cases. First
the engines outside the main version: engine_4x: the same
rules, a few more bytes, far more speed. The previous generation: the
build that holds the board as a string of letters. The move generator
alone: the 653-byte file that shows how small the core the tests exercise can be.
LUX's engine: engine_4x with a wider lock detector, castling and
searches, and the byte count of the layers taken out of the engine or never written for it. Roads tried and
eliminated: board representations and shortcuts that were measured and dropped. Then what
lies outside the engine: The driver's parsers: clicking, UCI and numeric
input, and where a driver meets the engine. L1's castling ledger and
Setting up a position from FEN: why L1 keeps its castling
rights differently, and how a position is loaded into it. Packing: RegPack, aliases,
obsolete techniques, quirks mode and build-by-build compatibility. Last,
Pitfalls: the engine's fragile spots and what to watch when changing
it.
Speed
engine_4x.js — same rules, +117 bytes, 3.5 times the speed in move generation, about 6 times in adjudication
The source below is engine_4x.js itself; every part that is not in
engine.js is painted. Each change this section describes can be seen in the code
at a glance.
engine_4x.js—
The two engines are logically identical: the same moves at every depth, the same node
counts, the same result codes. Perft d1–d4 from the starting position gives
20 / 400 / 8,902 / 197,281 in both, and on the 201,060 positions of the
CHA-Solver record up to 31 July 2026 both return the same verdict, position by position. The difference is only
in how many operations it takes to reach the same answer — and in this generation that
difference is 3.5 times in move generation and about 6 times in adjudication.
Where the 117 bytes go
The two files differ in seven functions. Every difference is a short circuit or a narrower
scan; no new function, table or state was added, and the text of the rules is the same.
Function
What changes
Bytes
G
A rook or queen walks its ray only when the target can lie on it: d/s<8 rules out a target on another rank before the walk along the rank begins. The pawn's straight step walks only when the target is empty, too (&&S() instead of &S()).
+8
T
(p^t)>8 keeps only enemy pieces — empty squares and own pieces leave in a single comparison — and a pawn, knight or king more than 33 index squares from the king is never asked through G: (22>>p%8&(x-j)**2>1089).
+26
M
e-8&& — the en passant legality scan runs only after a two-square advance.
+5
V
The lock loop stops at its fixed point instead of running 99 rounds: the sum of the sets, A+a+_+f-S-s, is compared on every round, and the loop ends when it stops changing. For the fixed point to be reached, the three sets that grow upwards — White's advance and the two kings' reach — are cut to 64 bits (&F, written in two places); otherwise the bits that spill off the board would grow every round and the sum would never settle. One | becomes ||: the lock scan does not start when the material half has already answered.
+27
O
p+~t>>3 skips empty squares and enemy pieces in one test; for a pawn, knight or king only the 5×5 box around it reaches L: x-j+34&136|j-x+34&136.
+36
Z
(n+e<10|_)&& — the dead-position test runs only in the ply of a pawn move or a capture and the one after it, with no en passant square open, or when asked with Z(1).
+12
A
Calls Z() without an argument, so that trigger applies.
+3
Total
engine.js 1,527 → engine_4x.js 1,644
+117
Of the 117 bytes, 38 came in the last round, all of them in move generation: 24 in
T, 9 in O, 5 in G; the other 79 lie outside it. Over the six standard positions at depth 4 the new file
runs at 1.259 times the speed of the previous one; that is what the 38 bytes buy. How
the candidates were measured one by one is below.
Move generation: perft
The table below counts every move sequence through the engine's own O(), the
way a search built on top of the engine would: L() filters each candidate
through G(), U() and T(), and the move is played with
the plain c, U, t (with X() on the letter
board); the repetition key of M() stays out of the count. The positions and
depths are the same as in the previous edition of this table. Each measurement runs in its own
process, the order of the engines rotates every round, the median of five rounds is taken
after a discarded warm-up run, and every node count is checked against the known value. The
G column is machine-independent.
Perft
engine.js
engine_string.js
engine_4x.js
G calls
Start d4
15,248 ms
11,989 ms 1.27×
3,368 ms 4.53×
5.97×
Kiwipete d3
2,765 ms
4,210 ms 0.66×
1,214 ms 2.28×
5.15×
CPW 3 d5
30,992 ms
25,990 ms 1.19×
8,923 ms 3.47×
7.52×
CPW 4 d4
17,806 ms
17,379 ms 1.02×
5,269 ms 3.38×
4.66×
CPW 5 d3
1,915 ms
2,656 ms 0.72×
761 ms 2.52×
4.85×
CPW 6 d3
2,586 ms
3,904 ms 0.66×
1,139 ms 2.27×
4.96×
Total
72.07 s
66.21 s 1.09×
20.53 s 3.51×
6.05×
Ryzen 7 8845HS, Node 24.14. Each row is that position's median; the total is the median of
the round totals, not the sum of the column. The ratios are against engine.js;
the G column is engine.js's G calls divided by engine_4x's.
4x is 3.5 times faster in total and between 2.3 and 4.5 times faster per position,
and it enters G six times less often. Two more counters say where that comes
from. T() and U() are called exactly as often in all three engines —
1,719,023 and 1,760,215 times over the plan: every engine copies the board for the same
candidates and asks them the same check test. What differs is how much the rejections cost on
the way there. In engine.js the scan conditions of O() and
T() let empty squares through — with White to move T() asks every
empty square, with Black to move O() takes empty squares for Black's pieces — and
because the slider branch ends in &S(), the ray is walked on every call,
whatever the earlier tests said: for a bishop asked about a file, for a rook asked about a
square on another rank, even for the empty squares let through above. 4x walks a ray only
when the piece can move that way and the target can lie on that line. The ray steps inside
G fall from 262 million to 5.7 million, 45.7 times fewer.
The letter board sits between the two: 1.09 times engine.js in total, ahead in
three positions (Start, CPW 3, CPW 4) and behind in three (Kiwipete, CPW 5, CPW 6). Its
G is built differently, so its G count (49.5 million) does not
compare across boards; its T() and X() counts are the same as
above.
The same asymmetry shows in a single Z() call, the test that runs after every
move of a game. Counted in the starting position:
Z() in the starting position
White to move
Black to move
Ratio
engine.js geometry calls
97
1,585
16.3×
engine_4x.js geometry calls
54
6
0.11×
The numbers are call counts, not microseconds: how many times G is
entered during the same Z() call; being machine-independent, they are
reproducible. Because engine.js takes empty squares for Black's pieces, with
Black to move Z() runs a target scan for each of the 32 empty squares before it
finds a move. 4x leaves empty squares out, and Z() stops at the first legal move
it finds: in scan order Black's first piece is the pawn on a7, which has one at once, while
White's scan starts with the boxed-in rook on a1. The asymmetry is not merely removed but
inverted.
Adjudication: 201,060 positions
Perft never reaches V() or the 6.9 search H(). The second
measurement is the flag-fall verdict itself: the whole CHA-Solver record up to 31 July 2026
(201,060 positions; in the current CSV, the rows whose source column is
chasolver), in the referee page, with the game's own limits — 9 plies and a budget of 2,000 nodes.
engine.js
engine_string.js
engine_4x.js
Whole record
53.5 s
31.7 s
8.7 s
Positions per second
3,762
6,335
23,074
Per position, mean
0.13 ms
0.08 ms
0.02 ms
Median
0.10 ms
0.10 ms
< 0.10 ms
99th percentile
0.70 ms
0.40 ms
0.10 ms
Worst
56.1 ms
108.4 ms
6.9 ms
All three return the same verdict: 197,464 draws proved, 3,596 positions left
unproved at the ply ceiling, the budget exhausted in none, no false TM. In the two 0x88 engines
the certificates split the same way too: 8,139 by material, 47,047 by the lock, 141,814 by the
search, 464 by a game already over at the root. With the record extended to 30 September
2026 (204,060 positions) engine_4x proves 200,384 draws; 3,676 positions again stay
at the ply ceiling, and again the budget runs out in none; the certificates are 8,139 by
material, 48,135 by the lock, 143,646 by the search, 464 at the root. engine_4x is about 6
times faster than engine.js and 3.6 times faster than the letter
board. Times are taken with the browser's 0.1-millisecond clock step, so the median and the
99th percentile are coarse; the comparison rests on the totals.
A verdict has a cost perft never sees: the V() calls. Z asks
V twice after every move, H asks V(I) once at every
node, and this is one of the places where the two engines part. In engine.js,
V() joins its terms with |, so all three are evaluated on every
call; the lock scan itself starts only when nothing but kings and pawns is on the board and
no en passant capture is available, and then it runs a fixed 99 rounds.
engine_4x joins the lock term with ||: when the material half has
already answered, the scan never starts, and when it does start it stops at the fixed point,
usually within a few rounds. The number of V() calls and search nodes is the
same in both engines. Most positions of the record close in a single node — the median search
spends one — so most of the cost of a verdict is that node's move generation and its
V(). That is why the gap is wider than perft's 3.5 times: these two early exits
in V() come on top of the gain in move generation.
In a game this means the verdict at flag fall takes less than the clock's 0.1-millisecond
step at the median and 6.9 ms at worst, against 0.1 and 56.1 ms in engine.js. The budget
itself is on the engine tab, under its own heading.
This round: 38 bytes, 1.26×
The candidates of this round were measured one by one against the previous
engine_4x.js, each in its own file, on a plan that kept the three heavy positions
at depth 3 (Start, CPW 3 and CPW 4 at depth 4). The chosen combination was then measured once
more on all six positions at depth 4. The gains are wall-clock against the previous engine;
the call counts say what each candidate actually removes.
Candidate
Bytes
What it removes
Measured
Verdict
T: attacker filter
+24
40% of G calls (7.1 → 4.3 million at d3)
1.16×
taken
O: 5×5 box
+9
7% of G calls, and the L calls before them
1.11–1.19×
taken
G: rook/queen on a line (d/s<8)
+4
2.8 times fewer ray steps; no calls
alone 0.96×
together, on top of T and O: 1.06× — taken
G: && in the pawn push
+1
ray steps
—
Castling: the path before the check test
+5
0.3% of T calls
equal
rejected
Together, on all six positions at depth 4: the previous engine 180.8 s, the new one
143.7 s — 1.259 times for 38 bytes, 0.68% per byte.
Two of these are shorter than the form first measured. The &1 of the
guard in T is redundant: the comparison on its right is already 0 or 1, and
& keeps only its lowest bit; 26 bytes became 24. The box in O was
first written as (x-j+34&15)>4|x-j+34>>>0>68; written as the
0x88 board's off-board test applied from both sides, x-j+34&136|j-x+34&136,
the same 5×5 box costs 5 bytes less. The counters of the two forms agree call for call, and in
a run of seven rounds on an idle machine their times agreed too.
On the adjudication side five more candidates were measured on the 201,060-position record up to July 2026,
on top of the new move generation; none was taken. Four did not speed it up: computing the
search's check test only at the leaf (+3), gating V() inside the search with the
same n+e<10 trigger as Z() (+14) — visibly faster on the previous
engine —, || between the two V() calls of Z() (+1), and
forEach instead of map in V() (+4). Computing the BigInt
divisor of V() only when the lock is to be scanned (+4) changed nothing.
The measurement and its traps
The perft numbers come from bench.js in the test bundle
(node bench.js --site --rounds=5), the verdict numbers from the referee
page. Two mistakes that inflate a speed measurement were already known: taking the best
run instead of the median, and an artificial bench that loops move generation over
fixed positions and leaves out the state management of a real count. This round added three
more lessons.
The same work can measure differently in one run. The two ways of writing the box in
O make the engine do exactly the same work — every counter agrees call for call —
yet a single run showed 1.19× for one and 1.03× for the other. In a run of seven rounds on an
idle machine they came out at 12.82 and 13.00 s. A single run decides nothing; when the
counters say two candidates do the same work, only many rounds can tell whether the time
differs.
A busy machine moves every number. With something else running on the laptop — a
verdict run in the browser, say — the same engine's rounds spread by ±20–30%; on an idle
machine, at high priority, by ±1–2%. Rotating the order spreads the disturbance across all the
engines, but it does not remove it.
A change is worth what is already in. The rook-and-queen check in G
cut the ray steps by 2.8 times and, measured alone, came out 4% slower: the walks were short and
cheap, not the bottleneck. Once the guards in T and O had cut the
G calls almost in half, the same check together with the pawn's
&& bought 6%. The opposite happened on the adjudication side: gating
V() in the search sped up the previous engine and not the new one. What a
measurement credits to a change is its share of the bottleneck at that moment.
Lessons measured on an earlier generation still hold: a true unmake, restoring only the
squares a move touched, was 13% slower than copying the board and fed the garbage collector; a
hand-written for loop instead of forEach was 10% slower. Intuitions
carried over from C — bulk copies are expensive, hand loops are fast — do not hold in
JavaScript.
Why we stopped here
The counters show where the remaining work is. T() and U() are
called as often in engine_4x as in engine.js: every candidate that
passes G is copied once and asked the check test once. Cutting that means changing
the structure — making and unmaking moves in place, or attack tables — and both roads were
measured and dropped: unmake was slower, and move tables cost
kilobytes. Within the same structure, what remains are guards whose share of the time is
already small; the last ones measured either did not pay or did not pay enough for their
bytes.
Which change goes into which file follows one rule: engine.js is the shortest
form, engine_4x the fastest; none of the 117 bytes is in engine.js.
engine_4x.js is embedded in none of the L1–L3 drivers: in a game the engine's microseconds are
invisible next to a player's thinking time. Its place is wherever the hot path stays open —
validating a database, and a search that calls the engine at every node; the bot builds and
LUX in builds/special/ carry its core too. Its source sits
under builds/, with a copy in the test bundle.
The letter board
The previous generation — holding the board as a string of letters
In its first form the engine did not hold the board as an array of numbers. The board was a
string of letters: - for an empty square, RNBQKP and their
lowercase counterparts for the pieces — chess's own notation, written straight onto the
board.
The move to a numeric encoding was planned from the start, and when it came two things
happened at once: the file shrank and the engine got faster. But the letter builds were not
deleted — five of them are still in the repository, and they are here because they make the
comparison possible.
Build
Interface
Layout
Bytes
L1_prompt_string builds/L1/
prompt
a8=0
1,039
L2_prompt_string builds/L2/
prompt
a8=0
1,618
prompt_string_a1 builds/
prompt
a1=0
2,202
prompt_string builds/
prompt
a8=0
2,205
L2_string_base builds/special/
clicking
a8=0
1,893
The core of this encoding is engine_string.js: the same rules as
engine.js, with the board as a string of letters; 1,721 bytes. The source
below is the file in the builds/ directory itself.
engine_string.js—
How the encoding works
The board comes out of a single string, with the empty squares filled in by
padEnd:
b = [...'RNBQKBNRPPPPPPPP'.padEnd(48, e = _ = '-') + 'pppppppprnbqkbnr']
_ = '-' E = 'a' j = p => p?.toUpperCase()
Two constants carry the whole logic. _ is the empty square, and E
is the colour threshold: uppercase letters sort below 'a', lowercase
above. So the colour test is a comparison — p < E means White. The type test
is the letter itself: j(b[i]) upper-cases the piece so it can be compared against
'N', 'K', 'P'.
With sliding pieces the matter is subtler. In the numeric engine the decision is left to the
arithmetic of the type number: the parity of P+P*4%x; in the
previous generation's numeric engine it was a bit field, (h*v?h==v:2)&P. The
letter build has neither, but it has alphabetical order: B < Q < R. The
diagonal test h==v & P<'R' lets bishop and queen through, the straight
test P>'B' rook and queen. The same two questions, answered by letter order
instead of arithmetic.
The code above is prompt_string_a1's; there the board starts at a1. The four
flipped-index builds — prompt_string at L1, L2
and L3, and L2_string_base — build the board from
a8. In all five the empty-square letter is named _, and the name is created inside
the board expression itself, in one of two spellings. In prompt_string_a1,
padEnd(48,e=_='-') fills the thirty-two empty squares, names the letter and sets the
en passant square to it in the same argument. In the four a8 builds — and in
engine_string.js — the board is a template literal: ${(_='-').repeat(32)}
names the letter and fills the thirty-two squares, and the en passant square is set afterwards
with e=_. The side to move, castling rights and halfmove clock follow;
L1 has neither a castling mask nor a halfmove clock, so there only the side to
move and the en passant square come after the board.
The same interface, the same rule layer, two board representations. Measured:
The prompt family
Board
Bytes
Full scan
prompt
Numeric array
2,089
1,210 µs
prompt_string_a1
Letter string
2,202 +113
1,640 µs 1.36×
A hundred and thirteen bytes and more than a third more time. The reason comes down to one thing:
zero is a natural “nothing” value; - is not.
In the numeric encoding an empty square is 0, and zero falls out of every
test through the arithmetic itself: p&7 is zero, it fits no type branch,
it passes no mask. In the letter encoding - sits somewhere among the letters and
falls out of nothing by itself — it has to be filtered explicitly in every test. That is a
small tax spread across the whole source.
Here is how concrete the difference is: in the letter engine L(i) still
returns a list of moves, and asked for the moves of an empty square it does not come back empty.
In the starting position e4 yields eight phantom moves, and each of the thirty-two
empty squares yields between four and eight — - passes the diagonal half of the sliding
branch, because '-' < 'R' is true. The numeric engine's L(i,f), on the
other hand, says yes to no move at all from an empty square: the type is zero, and so is
P+P*4%x. It never shows in play, since the driver never asks about an empty square;
but it says in one line where the cost comes from.
The engine itself tells the same story. engine_string.js — the file in the
code window above — comes to 1,721 bytes,
194 bytes more than engine.js. The rules are the same; what changes is the
board and the code that reads it: b[i] is now 'P' rather than 9, an empty square
'-' rather than 0, and the colour test is a character comparison rather
than a bit test (p<'a' means White). Where the bytes go is the interesting part: the
starting position is fourteen bytes cheaper in this representation — the template that
names the empty square plus the e=_ that sets the en passant square come to 66
characters, against 80 for the numeric engine's hexadecimal trick and the for loop
that spreads it; with
prompt_string_a1's padEnd spelling the gap is seventeen bytes — while
every test that reads the board is dearer. It is kept to show that the engine hands down the same verdicts on a
board of letters, which makes the numeric board a choice rather than a requirement.
What it buys is readability. P=='K' and P==2 ask the same
question, but the first reads like chess. Readability is not the aim in Golfstack — and yet
this is exactly why the encoding was written first: get it right legibly, then measure and
carve.
One more detail: in the letter builds j is the helper that upper-cases a piece.
The numeric engine has nothing that needs a toUpperCase, and the same letter has moved
on to another job: the key of the for…in loop that builds the board and the index of
T's scan. When the encoding changed, what came free was not only bytes but letters —
and a freed letter does not stay idle for long; the current state is in the
free letters table.
What the interface gains — three prompt builds
The gap above is what the rule layer costs; in the interface the account runs the other way.
The three builds draw the same dialog and read the same UCI input: prompt
on the numeric board (0x88, a1=0), prompt_string_a1 on the letter board
counted from a1, prompt_string on the letter board counted from a8. The four
slots that belong to the interface, side by side, in bytes:
The letter board saves 23 to 25 bytes in the interface, and for one reason: the board itself is
already the letter to be written on screen. The numeric board has to turn every square into a
letter through a table, '-pkqnrb..PKQNRB'; the letter board reads the character's code
directly. The parser is the same story: the numeric board carries the *2-V%8 that turns
a flat index into 0x88, while on the letter board the flat index already is the board's index. The
one slot that runs the other way is promotion. On the numeric board an unknown letter falls through
the object lookup to undefined and from there to U's default queen,
p=3 (the parsers); on the letter board the typed letter goes onto
the board as it is, so it has to be filtered first, or e7e8k would put a king on the
board. The filter costs five bytes. In the file as a whole the letter board is still 113 bytes
behind: what it saves in the set-up and the interface, it pays back in every test of the rule
layer.
Flat and flipped board.prompt_string and
prompt_string_a1 are two layouts of the same engine, and in the rule layer
the difference is zero bytes: the pawn's direction test becomes f>i instead of
f<i, and two names swap in the blocked scan
(a8=0 indexing). In the interface the flipped board is two bytes
ahead. Counted from a8, the array already stands as White sees it: on White's turn it is written as
it is, and on Black's a single XOR, w^!t*63, turns the board through 180 degrees. Counted
from a1, the board has to flip the ranks for White as well, and tells the two sides apart with
w^t*49+7 — w^56 for White, w^7 for Black —: one byte more. The
parser's constant is shorter from a8 too: square = letter code − 8 × rank − 33, against
+ 8 × rank − 105 from a1. And there is a gain that never shows in bytes: counted from a8, the board
is in FEN's own order, so loading a position needs no flipping of ranks
(the FEN section).
In the file totals, though, the flipped board is three bytes behind: 2,205 against 2,202. The
set-up makes the difference. prompt_string_a1 sets up the empty-square
letter and the en passant square in a single argument, padEnd(48,e=_='-');
prompt_string's template, ${(_='-').repeat(32)}, is a byte
shorter but does not set the en passant square, so e=_ is written separately in the
header and the header grows by six bytes: five net. The interface's two bytes win two of them
back. The other three are the price of a deliberate choice: the template shows the thirty-two
empty squares by their count, and the header follows the order of the FEN fields — board, side to
move, castling, en passant, counter — without a break (the signature).
The full account of the letter encoding lives in a repository of its own:
ChessMiniature.
This is only the summary — it is not counted among the
eliminated architectures, because it was not eliminated: it handed over to its successor.
The core
The move generator alone — 653 bytes
builds/special/engine_onlyMoveGenerator.js is the engine's move-generating
core on its own: C, G, T, U, L
and M. It tells whether a move is legal under all the rules, castling, en passant and
promotion included, and plays it; it passes no verdict. There is no result code, no counter, no
repetition, no material test, no dead position and no 6.9 search. The move list comes out as it
does in the engine itself, from two nested loops: L(i,f) is asked for every
destination of every origin.
The file exists as a proof: what the correctness tests — perft, the
CPW positions, the tricky suite, the Vajolet corpus — actually check is this core alone. Perft
counts every move sequence from a position; it never touches result codes, counters or draw
rules. That is why the test suite only ever calls L, G,
C and U, which makes the raw move — on the 0x88 engines it never uses M (on the
letter board the raw move goes through M), and writes the castling mask and the en passant square itself — and why it can run this file as its
third engine (node test.js --engine=3): the sanity, tricky and CPW counts match the
main engine exactly. So the core the tests exercise fits in 653 bytes. The remaining 874 bytes of
engine.js are what turns it into an arbiter: 57% of the engine goes into judging,
not into generating moves.
The file is a cut-down engine.js, not a separate implementation. From the header
to the end of L the text is identical letter for letter; all the header loses is the
ply counter and the offer bits, n=o=0. The only other difference is in
M: there is no ply counter and no repetition key k, and the en passant square stays as it
is after every double step. engine.js keeps it only when a capture is actually
possible, because there the square is part of the repetition key (9.2.3.1). This core counts no
repetitions, so the difference never reaches the move list.
engine_onlyMoveGenerator.js—
The full engine
LUX's engine — the full form of engine_4x, 2,387 bytes
Chess LUX
is FideLite's sibling project: a complete game page built on engine_4x, with game
set-up, clocks and Chess960, in a single 9,553-byte HTML file. Its playable form is
LUX in the special level. The file's engine line is
2,387 bytes; the source below is that line itself, and every part that is not in
engine_4x.js is painted.
The line is written hand in hand with the interface. Its header also holds a few of the
interface's variables (Y, z, dg, m,
r, Q); A, which plays a move, and the repetition count live
in the interface, and so do Ac, the automatic-3R/50 setting, and Ag, the
Armageddon setting. So
builds/special/engine_lux.js is not an engine to run a game on its own; it is LUX's
engine set apart for reading. Its move generation is complete, though: the
test suite runs it as its fifth engine (node test.js --engine=5), and the perft
counts match engine_4x's exactly. Perft never reaches the lock detector, so this says
nothing about the wider detector; its coverage was measured on CHA-Solver's
games.
builds/special/engine_lux.js—
Where the 743 bytes go
Item
Function
Bytes
The wider lock detector — layer by layer below
V
+529
Castling by moving the king onto its own rook (Chess960)
G +78, U +57, L +23, M +7, C and its table y +4
+169
A 9-ply dead-position search for each side after every move; automatic 3R/50
Z
+42
Armageddon: the dead-position question after every move asked only for White's chances of mate ([8*!Ag,0]); the code is still DP
Z
+4
In the search, asking the lock detector only in the two plies after a capture or a pawn move (n+e<10); automatic 3R/50
H
+14
300 plies and 400,000 nodes when a flag falls
F
+8
The interface supplies the offer bit; the automatic endings are asked before agreement and claims
D
+3
T's distance filter instead of the 5×5 box: the castling rook can stand six files away
O
−11
Playing the move and counting repetitions are in the interface; the interface's variables in the header
A, header
−15
Total — engine_4x.js 1,644 → builds/special/engine_lux.js 2,387
+743
The lock detector, layer by layer
LUX's V is 529 bytes longer than engine_4x's, and the difference splits
into three layers. The same layers can be counted in engine.js's spelling too:
Layer
V in engine.js
V in engine_4x and LUX
Today's detector: kings and pawns
405
432
+ bishops, pawns a king can take, checks that cannot mate, a king that can never move was in the engine, taken out
+333 → 738
+334 → 766
+ boxed-in knights, rooks and queens written for LUX only
+169 → 907
+168 → 934
+ the king-can-step-out shortcut
—
+27 → 961
The whole engine
1,527 → 1,860 → 2,029
Today's detector. The wall may move: a pawn may advance along its file, as long as no path
leads to a capture, to the last rank or to a check (the conditions of a lock). A
king that can take a pawn counts as life, and so does an advancing pawn that could give check; with a
single piece other than kings and pawns on the board, the scan does not even start.
The first layer. LUX's detector takes four of these into the dead position.
Bishops: a bishop may roam behind the wall, as long as it can never take a pawn or stand on a
square a pawn could take. Pawns a king can take: a king reaching a pawn is not, by itself, a
sign of life; the pawn leaves the wall and the detector looks at whether anything opens behind it —
the scan runs once more for each king. Checks that cannot mate: a check an advancing pawn or a
bishop could give does not break the lock on its own; it counts only where the checked king could be
left without an escape square. So a lock can hold for one side and not for the other: on the same
board one side may still be able to mate. A king that can never move: it counts as part of the
wall, and it freezes the enemy pawn in front of it.
This layer stood in engine.js for a while: in the 0x88 generation the engine went up
to 1,860 bytes (engine.js as it was then ↗️),
engine_4x to 1,978; then the layer was taken out and both engines came down to today's
size. The reasons are the ones under the extension on hold: the return per byte
and the engine's only proved property. LUX keeps it.
The second layer: boxed-in knights, rooks and queens. A single knight, rook or queen on the
board switches the detector off; LUX lets such a piece stay when it is boxed in. Every square a boxed
piece could step to holds one of its own fixed men: the knight squares for a knight, the four
orthogonal neighbours for a rook, all eight neighbours for a queen. A fixed man is either a
permanently blocked pawn the enemy king cannot take, or another boxed piece. A boxed piece also stops
the pawn in front of it; if the enemy king or a bishop can reach it, it counts as “fallen” and stops
being a wall; if a pawn can take it, there is no certificate at all. In standard chess it hardly ever
happens — in the record's 204,060 games there is exactly one:
1n6/2Bp4/p1pPp1k1/PpP1Pp1p/1P3P1P/2K5/8/8 w - - 7 53
Black's knight on b8 is boxed in by its pawns on a6, c6 and d7. The white bishop can take it, but
taking it opens nothing, and Black cannot mate by any series of moves: the correct result is a draw.
LUX gives it from the detector, without a search. This layer was never written for
engine.js; it was written for LUX, because in Chess960 rooks, knights and queens start on
other squares, and a piece left behind its own pawns becomes far more likely.
How many bytes would it cost in engine.js? Carrying LUX's boxed-piece code over to
engine.js's spelling — 600 rounds instead of a fixed point, | instead of a
short circuit — takes V from 738 to 907: 169 bytes; the engine would be 2,029 bytes,
502 more than today. This intermediate build was never published. It was run side by side with LUX's detector: on every one of the 204,060 positions in the Arbiter's data file it gives the same verdict, for both sides.
The third layer: a king that can step out. Before the detector runs its full analysis for a
king, it asks whether that king can simply step to a square no pawn holds or attacks; if it can, that
king's case is settled at once. It changes no verdict, it only saves time. Like the
n+e<10 gate in the search, it pays for its bytes only in a long search: the gate was
measured for engine_4x too and did not pay off in a 9-ply, 2,000-node search
(this round). LUX's searches are far longer, so it keeps both.
L, castling's only guard — the long rook on the b-file
In LUX a castle is played by moving the king onto its own rook, and G's king branch
answers that move without touching the board: is the right still held, are the squares the king
and rook pass through empty, is any square on the king's path attacked. That last question is put
to the board as it stands, with the rook still on its square. In Chess960 there is exactly one
situation where that is not enough.
Put the long rook on b1 and an enemy rook or queen on a1. Castling long takes the king to c1 and
the rook to d1, and b1 empties; the piece on a1 now sees c1, and the castle itself puts the king in
check. When G asks, the rook still stands on b1, the a1 line is closed, c1 does not
look attacked, and G allows the castle. What rules it illegal is L: as
with every move, it plays the move on a copy of the board with U and asks
T() about the king. Measured: with Kd1 and Rb1 and a black rook or queen on a1,
G(3,1) says yes and L(3,1) says no; with a1 empty, or with a bishop or a
knight there, both say yes.
This is the only gap. Across all 960 arrangements, castling to both sides, with nothing on the
board but the white king and both rooks at home and the black king on a file with no rook, a single
enemy rook, queen, bishop, knight or pawn was placed on every empty square: 552,960 positions.
G and L part ways in 468 of them, all of one shape: castling long, the
rook on b1, an enemy rook or queen on a1. b1 is the one square between a1 and c1, and the line opens
only when it empties. On the short side the sweep found no difference at all. 252 arrangements have
their long rook on the b-file; in 234 of them the gap is open from the first move, and in the other
18 the short rook stands on d1, so castling long becomes possible only once that rook has moved. At
the start a1 holds one of your own pieces; the danger arises when it leaves and an enemy rook or
queen takes its place.
The rule: castling has to pass through L's legality trial. Letting a castle skip
that trial for bytes or speed and settle for G's verdict brings the gap back. The old
Chess960 engine, with castling hard-coded per skeleton, did exactly that:
it pushed the castling move onto the list straight after its own static tests, without sending it
through the make–test–unmake filter every other move went through. There the gap was closed by a
separate a1 guard written for these 252 arrangements, +26 bytes; routing castling through the
general filter would have cost +59. LUX needs no guard, because castling already goes through
L like every other move — the clicked move and every move query alike. The test
suite's perft runs are on the standard board and never reach this position; this check was made
separately.
Alternatives
Roads tried and eliminated
The engine's board representation today is 0x88: a sparse,
square-centric array sixteen columns wide, with eight empty cells between one rank and the next.
The previous generation was a flat array of 64 — no padding, no 0x88, no bitboards — and when
this section was first written, that structure was described as “what was left over”.
Everything below was either written or measured.
This section is a record: what was tried, and why it did not hold. The numbers do not go
stale, because each decision belongs to the measurement of its day; most of them were measured
on the previous-generation engine and are left that way. But “eliminated” does not always mean
“unused”. The biggest example is the board itself: the padded
board was eliminated, with reasons, in the previous generation, and the new engine is built
on it. The piece alphabet is the same story: no candidate won on the
old engine, the search was reopened on 0x88, and today's alphabet came out of it. Eliminating
an idea in one engine does not finish it: the byte difference depends on the engine and on
the file.
Euclidean distance — the first design, collapsed on two squares
The first idea was to define movement by absolute distance: the Pythagorean distance
from one square to another on a discrete plane. A small simulation was written that drew an
empty 8×8 board and measured every distance from a chosen square, and at first glance the
formulas looked flawless — the knight always √5, the king between 1 and √2, the rook the
integers {1…7}, the bishop the multiples of √2 {1√2…7√2}, the queen the union of the last
two.
The knight and king really are clean. In an eight-square space the only shape giving
d²=5 is (1,2); the knight owns √5 outright. The shapes giving d²≤2 are (0,1), (1,0), (1,1) —
the king has no rival either.
The rook and bishop are not. In the whole distance space there are exactly two
collisions:
Distance
Shape
Colliding shape
5
(0,5) — rook, five squares
(3,4) — no piece's move
5√2
(5,5) — bishop, five squares
(1,7) — no piece's move
The 3-4-5 triangle impersonates the rook's fifth square, and (1,7) the bishop's fifth.
The queen inherits both. Distance alone cannot define a legal move.
The second problem is even more serious: distance has no direction. The square root
swallows the sign, and the pawn is the board's only one-way piece. No patch could have saved
the Euclidean approach for the pawn.
The solution was to throw away the square root: keep the horizontal and vertical
differences apart. The previous generation's h and v come from
here, and that one step solved both problems at once — the collision, because (3,4) and (0,5) are
now two different pairs; and the direction, because f<i^b[i]&1 carried a
signed comparison. The residue of the abandoned approach was plain to see there:
h*v==2 is the knight, h|v<2 the king, h==v the bishop,
h*v==0 the rook. The distance went, the shape stayed. The letter-board
engine_string.js still carries this shape test today; the new engine let go of
the shape as well and reads the move from the index difference — the
next heading.
The simulation from that day is still around, if you want to click a square and see where
it breaks:
Board_Delta_Coordinate.html
— pick a square, every square matching one of a piece's distances lights up, and the
collisions come out hatched, visible at a glance.
The padded board — eliminated first, adopted later
Once distance had turned into shape, the next question was: could the shape test go too?
Reading legality from the raw index difference between i and f is
not possible on a flat 64, because the difference is ambiguous at the edge: +1
means one to the right, but on the h-file it wraps to the a-file of the rank above, and the
diagonals' +7 / +9 and the knight's +6/+10/+15/+17 do
the same. Removing the ambiguity requires adding |i%8-f%8|, which is just
h again; so on a flat board the difference road falls back into the shape test.
The difference only becomes unambiguous on a padded board: 0x88's empty columns or the
sentinel border of a 10×12 mailbox absorb the wraparound.
The previous generation stopped here and eliminated the padding. The reasoning was
this: the free edge test that padding sells finds a buyer in a move generator that produces
its targets with f = i + offset. This engine does not generate, it
scans: G is handed the source and the target square together, and the
target is a square that already exists. On top of that, two costs were foreseen: with the
array grown to 128 cells, the target scan would have to filter half of them out, and the
initial literal would double. The shape test built on the horizontal and vertical differences
was thought to be the cheapest road.
The difference road was tried on the flat board first. In L1's
light build, with the same interface and the same template, two 8×8 files were built: one with
h / v, the other computing the 0x88 difference from the flat indices
(X=a(F+F-h)) and choosing the direction from that. The first was 1,105 bytes, the
second 1,121. The cost of computing the difference ate the gain.
Once the board really was widened to sixteen columns, the difference came for free:
d=f-i. The direction is chosen in one expression,
x=d%15?d%16?d%17?1:17:16:15; the knight is d*d%765%128==68, the
king d==s. Math.abs, i%8 and i>>3
left the move geometry altogether. The previous generation's G was 247 bytes and leaned
on an a=Math.abs, alias (11 bytes) — two of the three a( calls were in
G. The 0x88 G is 236 bytes. The switch was made first in
L1's light build, then in L2 and
L3, and last in the engine itself.
Neither of the two foreseen costs materialised. The target scan never sees the
holes: b is a sparse array — values are written only to the 64 real squares, and
the 56 cells in between stay holes that were never assigned — and map,
some and every never run their callback on a hole. No extra test
filtering out half the array was written. Nor did the literal double: the same 64-digit
string is spread out with j*2-j%8, and the setup went from 70 bytes to 83 (+13); written today as a for…in loop it is 80 (+10).
As a side gain, the square colour is i%15%2+1 instead of
i*9/8%2+1, because 16 ≡ 1 (mod 15).
Padding's edge test found a buyer too — not in a generator, but in the walk. For the
sliding pieces the last branch of the chain is 1: any difference that does not
lie on a straight line counts as a horizontal step. S walks that way and, never
reaching the target, stops at the first hole at the latest. That requires the walk's condition to be b[i]<1:
undefined<1 is false and stops the walk, while !undefined is true
and lets it carry on. The previous generation carried the one-byte-shorter !b[i]
here; in the new engine that shortening is a bug. Pitfalls records
it.
One of the previous generation's reasons still holds: blocking is a question independent
of the representation, and a sliding piece's path is still tested by walking. 0x88 did not
remove S; it removed only h and v. Nor does the engine
generate offsets now — it still scans; the padding is there not for the edge but to make the
difference unambiguous. The structure that remains: 0x88 + G's difference test +
S's walk.
The second simulation from those days is still around, if you want to click a square and
see why the index difference misleads on a flat board:
Board_Delta_Index.html
— pick a square and the index difference to every target is written on it. That the same
difference means two different moves at opposite ends of the board, and where the differences
that spill over an edge land, are both visible at a glance. Switching between the flat 8×8,
0x88 and 10×12 keeps the same piece on the same square, so a difference that leaks onto the
wrong square on the flat board can be watched being absorbed by the padding, side by
side.
The board's width — why sixteen
Once padding was accepted, the next question was the width; sixteen was not the only
option. Boards 10, 12, 16 and 17 columns wide were generated from the same
L1 light template, each was tested with perft and browser scenarios —
the closest ones also with random games against an independent referee — and measured byte
for byte. The best spelling of every slot that
depends on the board — the initial mapping, the square of the pawn taken en passant, the
square colour, the slider test, flipping the display, the direction chain, the knight test,
the double-step and last-rank constants — was searched for separately on each board. The numbers
were measured with the tight interface of the day; the site's interface adds the same 24
bytes to every file and the order does not change.
Board
Best layout
Bytes
vs 16×8
16×8
normal
909
—
17×8
rank-reversed
912
+3
10×8
rank-reversed
913
+4
16×12
rank-reversed
919
+10
12×10
rank-reversed
921
+12
The real finding was in flipping the display. The board is always drawn from the
point of view of the side to move, and the template has to turn display order into the real
square. At width 10 in the normal layout that took 23 characters
(u=w+7-w%10*2,t?u=77-u:u); with the ranks stored from 8 down to 1 it fell to 10
(u=t?w:77-w) and charged nothing anywhere else. The estimate for 10×8 had been
926–928; the normal layout measured 926, the rank-reversed one 913. At width 16, on the other
hand, the XOR is already 10 characters (u=w^7<<t/2); the reversed layout
gets to the same length there with u=w^119>>t.
Compared slot by slot with 16×8, width 10 wins in the direction chain
(x%9?x%10?x%11?1:11:10:9, −2), in the knight test
(x*x%257%40==24, −1) and in the double-step and last-rank constants (a byte
each); it loses a byte each in the row start, the square colour and the slider test. Two slots
decide the difference: the initial mapping (j*5-j%8>>2, +3) and the square
of the pawn taken en passant (30+f%20, +3). At 16 both are a single operation:
j*2-j%8 and f^16. Net +4.
The odd widths broke the record on paper, but they are invalid. 11×8 came to 908
bytes, 13×8 to 909. With an odd width the two diagonal steps (W−1 and W+1) are both even, and
their common multiple falls inside the board: at 11, 60 is both the (5,+5) and the (6,−6)
diagonal. Because the chain picks a single direction from the difference, the other move is
lost — on 11×8 perft gave nine errors and Bc6xh1 was never generated in random games; on 13×8
the same problem took Bh2-b8 away. At 17 the common multiple falls outside the board, and 17×8
is valid: 912 bytes. There the odd width shortens two slots — square colour w&1
(−3), slider test P>>d%2 — but the mapping and en passant are still
expensive. Widths 12, 14, 15 and 19 upward were not built, with estimates of 913 or more; 9
has both the same diagonal problem and an ambiguous knight.
As the rules grew, the order shifted a little. In L2, 17×8
caught up with 16×8 by cutting the square colour in dead-position detection to
i%2+1: both 1,540. In L3 they tied as well: 2,325. 10×8 is
four bytes behind at both levels. The tie went to 16×8, because only there does the 6.9
search visit moves in the same order as the previous engine, a1 to h8, and spend exactly the
same budget. On the rank-reversed boards the verdict did not change in any test, but the budget
ran out at different places: in 2,707 positions of CHA-Solver's list of 201,060 games up to July 2026.
Toledo's JavaScript chess uses a 10×12 board of 120 cells; that is the natural choice for
an engine that generates moves from a direction table. Here the engine does not generate, it
verifies from the difference; padding rows bring nothing but the cost of shifting the index.
That is why the extra rows of 16×12 and 12×10 gained nothing.
Holding the board as a number — two attempts, two axes
This section's thesis rests on two words: square-centric and array. Each of the two alternatives
tried rejects one of them.
Bitboards give up square-centricity: the information is spread across 64-bit words
split by piece type, each bit saying “is there a white pawn on this square?”. Set operations
become cheap — you get the attacks of all white pawns in a single mask. In exchange, “what is
on square 12?” gets expensive; you have to test each word separately. A packed
integer, by contrast, preserves square-centricity and only changes the container: the
same 64 digits, inside a single number rather than an array. The data model is identical,
the access pattern different.
Both are forced to the same place: BigInt. 64 squares × 4 bits is 256
bits, far beyond Number's 53-bit integer precision. So both roads pay JavaScript's BigInt tax — an n suffix on every literal, an explicit conversion in every
mixed expression, and a TypeError the moment you mix BigInt and
Number with +.
Where the cost accumulates is measurable. engine.js touches the board with
square brackets in thirteen places — nine reads, four assignments — and walks it a further six
times with indexOf, some, map and every. As
an array, b[i] is four characters; packed, the same read wants a shift and a mask,
roughly 25. That is a difference of over 180 bytes on the reads alone, and the walks have no direct equivalent — all six turn into hand
loops. On top of that, bitboards add attack tables.
What makes these models cheap in C or assembly is 64-bit arithmetic being free at the
processor level. JavaScript offers no such ground; far from gaining, both attempts added
hundreds of bytes. Worse, both would have changed the engine's entire skeleton and returned
a pile of hard-to-manage algorithmic unknowns in exchange — the wrong direction of trade in
a project that trades speed for size.
The irony is that BigInt stayed in the engine, but in a completely
different job. The starting board is built with 10n**40n-10n**32n: the
expression produces a 40-digit number — eight 9s, then thirty-two
0s — that is, the
white pawn rank and the four empty middle ranks. 20 characters in the source, 40 in the string it produces. A 20-byte gain. BigInt was eliminated as a board
representation and kept as a literal compressor — and in the blocked
scan: V holds pawns and kings in 64-bit BigInt sets. The model that is
expensive for the whole board is cheap inside a single question.
Two squares to a character — in the source, a byte is not memory
Once the board is numbers, the next step suggests itself: every square is
four bits, so two squares fit in a byte, and the 64-square starting board
should come down to 32 bytes. That is a true sentence about memory. What is being shrunk,
though, is not memory but source text, where a byte means one ASCII character — and two squares do
not fit in one.
The arithmetic is simple. A square can take thirteen values: empty and the twelve pieces. A pair
of squares takes 13 × 13 = 169. UTF-8's single-byte range is 0–127, 128 characters in all, and
fewer still once the quote, the backslash, the line break and the control characters HTML objects
to are taken out. Some pairs inevitably spill above 127, and UTF-8 writes each of those in two
bytes. Because White's codes are Black's plus 8, the pairs that spill are mostly the ones holding a
white piece: in the starting position all eight pairs of White's back rank and pawns land on
two-byte characters.
The real compression has already been done. The 10n**40n-10n**32n above writes the
40 middle squares in 20 characters: half a character per square, exactly the density pairing
promises — and with no decoder of its own. What pairing could still gain is on the remaining 24
squares.
I built it on today's engine.js and measured it. Encoding each pair as
16a+b+32 turns the empty pairs into spaces, which come free with
padEnd(24)'s default fill; the string goes from 46 bytes to 41, −5. But the decoder
that opens each character into two squares is expensive:
s.charCodeAt(j>>1)-32>>4-j%2*4&15 in place of today's '0x'+s[j]-0,
and the loop that runs it 64 times, +27. In the shortest spelling I found, the engine went from
1,527 bytes to 1,549: +22. The board and the holes between the ranks came out identical;
sanity, the CPW positions and the tricky suite passed. Even if every pair fitted in a single byte,
the string would only come down to 32 bytes and the engine would still be 13 bytes longer. In the
previous generation, on the flat 64 board, the same attempt came to +16.
The lesson is the piece alphabet search's “what is being shrunk?”
question: a four-bit code describes the board's size in memory, not the program's. In the source,
what counts is characters.
Building the board around the detector — bitboards, a second time
The attempt above weighed bitboards for the whole engine. This second one asked a narrower
question, and asked it in the previous generation; back then the blocked-position scan was
l and the insufficient-material check I and Im, and today
both live inside V. The most expensive part of the rule layer
is the blocked-position scan, and that scan already works in 64-bit sets: it opens with
a bridge that reads the board array and turns it into sets. Keep the board as sets from the
start and the bridge goes. Even if the other rules grew, could a cheaper l pay
for them?
It was measured three ways. Hybrid: the board stays an array, the sets are rebuilt
beside it on every move, and l and Im read from them. The engine
came out some 15 bytes longer than it was then, and rebuilding the sets on every move also made it
about a third slower. Full conversion: the board is sets from the start, and
G, V, M and L are written set-style. The
rule core nearly doubled; the geometry G settles in a single expression fell
apart into step tables, ray loops and a “what is on this square” helper. The
ceiling: if the board already were sets, what would the dead-position side gain? Close
to nothing.
What zeroes the ceiling is the insufficient-material check I. In an array a
bishop's square colour falls out of the square's index for free,
i*9/8%2+1 on the flat 64, i%15%2+1 on 0x88; with sets the same fact
needs a 64-bit colour mask. What
l gains, I gives back. The dead-position side produces no
budget to spend on the other rules.
The experiment's real finding turned up elsewhere: the gain comes from the bridge itself,
not from the representation. Rewriting the bridge as a generic loop — every piece into its
own set, the ones needed taken by destructuring — works while the board stays an array; in the
previous generation the engine got about ten bytes shorter that way, and the scan got faster.
The bishop extension showed the same. In the new engine the bridge is
exactly this: V walks the board with a single b.map, pawns and kings
go into sets, the other pieces into counters, and the material branch and the blocked scan share
the one pass. The step from 0x88 to sets is free too: since b.map skips the holes,
only the 64 real squares are visited, a1 to h8 in order, and doubling the bit on each square
(j+=j) gives the 8×8 bit layout directly.
Spare values on the board — three attempts
Every square of the board is a single hex digit, and not all sixteen values are used: in
the previous generation 1, 14 and 15 never appeared on
the board, and in today's alphabet type 7 (7 and 15) is free. Whether
those free values could be put to work was tried three times in the previous generation; all
three were eliminated.
Moving the en passant square onto the board. In the engine e is a
separate global. Marking the skipped square on the board itself with a free code instead — a
“ghost” — was tried. The idea is clean: the position becomes the single source of truth, a
field drops out of the repetition key, a letter is freed, and while a ghost is on the board the
blocked-position scan falls silent by itself. What loses fits in one sentence: while
e was a variable, the location of the ep square was free; buried in the board, it
has to be searched for on every move. The chain that clears the old ghost, the line that forces
the scan again and the guards outweighed the scattered small gains: net +5 bytes in the engine
with counters (+2 with an indexOf alias), +2 in the build that only knows mate and
stalemate, and perft 25% slower. It was eliminated not for a bug but for its price — perft
counts matched exactly on seven positions. A second design, writing the mark onto the pawn
itself rather than the skipped square, came out better, but it lost as well. A detailed account
of the ghost encoding, and the 996-byte build that rests on it, live in a repository of their
own: chessinbytes.
Classifying empty squares by threat. Could the free values hold a two-bit threat map
over the empty squares, and shorten king safety and the castling test? No, for three separate
reasons. The first is fatal: the map's flag describes the position before the move, whereas
the king's own body blocks the ray — the rule that a king may not retreat along the ray of the
piece checking it collapses in the cache. Measured: on 0.2% of candidate king moves the map
permits a move it should not. The engine gets the same thing right for free, through the
make-test-unmake of the legality trial. Second: the king's own square is occupied, so the
squares the check test and castling actually need are not the squares the map covers. Third:
once an empty square is no longer 0, the emptiness tests break — eight separate
tests in the previous generation, 16 bytes before a line of map maintenance is even written.
On 0x88 this is even more final: the walk carries on over an empty square and stops at a hole
with b[i]<1; writing any other value to an empty square would break that
distinction.
Moving the castling rights onto the board. The same idea applied to
c. In the previous generation the wall came earlier: there was no free value giving
>>1 = 2, the rook's branch. The remaining route would be to mark the rook
squares, but the function that clips the rights is so tight that the string itself acts as the
table — squares falling outside its range return zero for free through
undefined → NaN → 0. Today's C is built the same way:
'20003001'[i%112]<<i/56. Moving it onto the board would cost exactly that free
behaviour.
A signed variable for pawn direction — tried, gained nothing
Keeping a ±1 direction variable per colour and writing the pawn's move with that
multiplier was tried in the previous generation. It gained nothing. The reason is not that the
chosen representation is cheaper but that it is more versatile: the colour information
sits as a single value, and the engine and its drivers use it in many roles at once. In the
previous generation t was 0 or 1 and served as a truth value
(p%2==g), an XOR operand (t^=1), an addend (V(t)?t+1:3
gives the mate code, 10+2*k+g the result code), an index ('WB'[t]) and a
multiplier (t*49+7, the render flip). A ±1 could do only the last of these, and a
signed direction is meaningful for one piece only. The pawn carried no separate variable:
f<i^b[i]&1 asked whether the destination index is lower than the source and
XORed that with the colour bit — direction and colour in one expression.
In the new engine t is 8 or 0, and the same versatility carries on: a mask
(p&8^t), an XOR operand (t^=8), an addend (2+t is the
king's code, 1+t the pawn's), a subtrahend (b[i]-t gives the piece
type), a shift (w^7<<t/2, the render flip). The pawn's direction still needs no
variable of its own: d<d*t is true only for a positive d when
t is 8, and only for a negative one when it is 0; the colour's value picks the sign
of the difference. The m^ in front of it turns the direction round for the attack
question.
The piece alphabet — is there a better set of hex codes?
This search was run on the previous-generation engine, on the flat 64 board; “today's” in
the text means that day's alphabet and engine. Today's alphabet came out of the
second search at the end of this heading.
The engine's previous generation held the board as FEN letters. Moving from letters to hex
numbers saved over a hundred bytes: a piece's type, colour and range were now read with bit
operations instead of a chain of comparisons (the board). A natural question
followed: is today's layout the best form of this four-bit alphabet? Would the engine be shorter
if the codes were arranged differently?
In today's alphabet every piece type is a pair of codes: bishop 2/3, rook 4/5, queen 6/7, pawn
8/9, king a/b, knight c/d. Odd is White, 0 is the empty square; 1, e and f never appear on the
board. But never appearing is not the same as having no job. The blocked scan's old form used the
bitboards of codes 1 and e as accumulators that start from zero, and the ghost en passant and
castling rights each lay claim to a free code as well. So the sixteen codes have sixteen distinct
roles: twelve pieces, the empty square and three free codes.
The search narrowed this space step by step. Some of the narrowing rules are axioms: they come
from how the engine is written, and giving one up would mean rewriting the engine. The others are
not axioms but starting points: assumptions made so the search could begin somewhere cheap and
readable. The axioms come first.
The first axiom is the definition itself: every square holds a single four-bit code, and the
sixteen roles are spread over the sixteen codes. That is 16! = 20,922,789,888,000 layouts.
The second is the empty square being 0; the space falls to 15!, 1.3 trillion. The third concerns
the sliders: since all three move with one expression, the code must carry a diagonal bit and an
orthogonal bit, the bishop only the one, the rook only the other, the queen both. Of all the ways
to place the six sliders on fifteen codes, only 20,544 comply, and the space falls to 7.5
billion.
The axioms end there; all the remaining narrowing comes from starting points. Treating the
three free codes as identical cut the space to a sixth. The real break came with the assumption
that the colour is a single bit and that both colours of a type share one pattern: the remaining
three bits of the code became just seven patterns to spread over six types, and billions became
eight thousand. The colour bit at one end, the diagonal bit on the type's lowest bit, the colour bit
lowest and the two direction bits next to each other brought what was left down to 384.
Step
Narrowing
Kind
Layouts left
0
every square is a four-bit code: sixteen roles, sixteen codes
axiom
20,922,789,888,000
1
empty square is 0
axiom
1,307,674,368,000
2
sliders move by a diagonal and an orthogonal bit: queen = bishop ∪ rook
axiom
7,455,006,720
3
the three free codes are identical
starting point
1,242,501,120
4
colour is a single bit; both colours of a type share one pattern
starting point
8,064
5
colour bit at one end
starting point
4,032
6
diagonal bit on the type's lowest bit
starting point
1,440
7
colour bit lowest
starting point
720
8
the two direction bits next to each other
starting point
384
The remaining 384 layouts are 192 pairs whose colours are the reverse of each other; both members
of a pair give the same code. For one of each pair, the shortest form of every code-dependent
expression was searched by brute force: G's dispatch, the material test's bishop test and weight,
M's pawn and castling tests, the 6.9 search's pawn test and the blocked scan's mapping.
The best candidates were then built as real engine files.
The power of pruning. Among the 192 layouts scanned, a single rule cuts the space to a
quarter or an eighth. Keeping the board trick (the white pawn must be 9, so the pawn is type 4) leaves
48 layouts; bringing the material weight down to a single XOR (king and knight must be a one-bit
pair) leaves 32; the first round's single-mask gate for the blocked scan, 24. By the first round's calculation 48
layouts save bytes, 11 save 8 or more, and 21 save bytes while keeping the board. The worst is +18:
bishop 5, rook 2, queen 7, pawn 3, king 6, knight 1; it hits none of the mechanisms.
The axioms. These are rules the search did not question; they come from the engine
itself.
A four-bit code. The board string holds one hex digit per square. A square can be in one
of thirteen states: twelve pieces or empty. That is log₂13 ≈ 3.7 bits of information, and a
four-bit code is the shortest fixed-length code that carries it; the three codes left over are the
free slots.
What is being shrunk? That count is about the size of the board in memory; what is being
shrunk is the size of the program. Its theoretical frame is not Shannon but Kolmogorov complexity,
which asks for the shortest program that produces a given output. Kolmogorov complexity is
uncomputable: there is no general way to prove that a program is the shortest. So the method is
to narrow the space with rules and measure the remaining candidates one by one. What is sought is
not a proof but the best measured result.
Empty is 0. In JavaScript, 0 is the only falsy integer. Every empty-square query in the
engine is a truthiness test: !p, !b[i], p&&,
T|b[f], P|b[f]. Any alphabet that gives the empty square another value
turns each of them into a comparison. The string trick that builds the board also produces the
empty squares as 0.
Sliders and short-range pieces. All three sliders move with a single expression:
(h*v?h==v:2)&P. On a diagonal move it gives the result of h==v, on an
orthogonal move the constant 2, and then takes & with the piece's type. So one bit
of the type means “moves diagonally” and one means “moves orthogonally”: the bishop has only the
diagonal bit, the rook only the orthogonal bit, the queen both. King, knight and pawn are short-range,
and G's dispatch separates them with tests of their own. This split, and queen = bishop
∪ rook, are axioms, so that the slider expression keeps moving all three pieces at once. Which bit is
diagonal and which orthogonal is not an axiom; it is in the table below.
Side information. Every piece has to tell which side it belongs to. How it tells it, with a
bit or with a sign, is free.
Legal positions only. A state that never occurs is free. Because a pawn never stands on the
first or eighth rank, the double-step test can be y%6<2 instead of
y%5==1: the new test selects ranks 1, 2, 7 and 8, but a pawn can only be on two of
them.
Fixed geometry. a1 = 0, 64 linear squares. This one was an assumption: padded boards
(0x88, 10×12) were thought to need an off-board code and to compete for the free slots; at the
time they had been eliminated separately. When 0x88 was adopted later,
no such code was needed: the cells in between stay holes that were never assigned, and
undefined by itself means “off the board”.
The starting points. These are what really narrowed the funnel, and none of them is a
necessity. Each was set so the search could start somewhere, and each was tested afterwards:
Starting point
Why the search started there
Where it stands
the three free codes are identical
their jobs depend on the alphabet; easier to study one by one
their roles were studied separately: the ghost, castling rights, the blocked scan's zeros. Asking for all of them at once takes the states to 18, and four bits are not enough; with five bits, 32!/14! ≈ 3·10²⁴
colour is a single bit
the colour test and the type read are single operations: p&1, p>>1
not required: the side can also be read from the sign of the number. The signed alphabet was built and measured (below)
both colours of a type share one pattern
the type is read without looking at the colour
a mirrored layout read through a threshold (p>c?K-p:p) makes the type read expensive. The only free mirror is the one around zero, the sign
colour bit at one end
a bit in the middle makes both the colour test and the type read two operations
eliminated by inference: the two middle positions, half of the 8,064 layouts
diagonal bit on the type's lowest bit
h==v only gives 0 or 1 and goes straight into &P
the other four role assignments lengthen the expression by about 3 bytes (computed)
colour bit lowest
compatible with today's code
the top bit was not tried; it brings as many layouts again. With the pawn as type 1, the board trick survives
the two direction bits next to each other
today's expression (h*v?h==v:2)&P
not required: with the orthogonal bit at 4 and the free bit in the middle, the expression is (h*v?h==v:4)&P, the same length. The 7 slider placements this adds were not scanned. The one placement both forms share (bishop 1, rook 6, queen 7) is that of the “rook and queen on top” alphabet
the free bit is the same for all three sliders
the account's reading that “the eights digit is range”
not applied in the scan: the free bit is free per piece. The “rook and queen on top” alphabet falls outside that strict tree
For the bishop, the mask matters, not the side. The rule “colour is a single bit” has two
reasons: the colour test and the type read should stay single operations. For the bishop, one of them
is weak. In the material tests, neither the number of bishops nor their side changes the result; the
only thing that does is the colour of the squares they stand on. Bishops all on one colour, however
many and whichever side they belong to, cannot build a mate. The engine reads bishops exactly that
way: I(2) scans both sides at once and sends both sides' bishops to the same mask,
m. The bishop part of Im compares the opponent's mask with its own,
!(B&~m); the side only serves there to keep the two masks apart.
That observation gave rise to a family: the mirrored layout, which separates the sides by a
threshold instead of a bit. The codes are laid out symmetrically on either side of a seam, the side
is read with a single comparison (p>6), and a piece's other colour is
13-p. An example layout:
Code
1
2
3
4
5
6
7
8
9
10
11
12
Piece
king
pawn
knight
queen
rook
bishop
bishop
rook
queen
knight
pawn
king
In this layout 1–6 are White and 7–12 Black, and the tests that ask about both sides at once
reduce to nested symmetric ranges: the bishop is the two codes at the seam (6, 7), the sliders are
4–9, “king or pawn” the blocks at either end. The bishop part of the material tests and the blocked
scan's gate are exactly that kind of question. But the cost lands on the engine's busiest code:
since types are no longer a repeating pattern, G's dispatch and the slider expression
pay for the type read with something like p>6?13-p:p. The family was closed in the
second round, for two reasons: the bishop's mask is already read from the square's colour, not from
the code, and the mirror around zero, the sign, is the only mirror that leaves the type read free
(a(p)). That mirror was measured below as the signed alphabet.
Why so little to gain? Moving from the letter board to numbers had changed every piece test.
In the numeric engine, few places touch the piece code: the board string, G's
dispatch, V's king lookup, M's pawn, castling, promotion and opposite-colour
pawn expressions, the material tests, the 6.9 search's pawn test and promotion loop, and the blocked
scan's index and gate. Together they come to about 200 bytes, 70 of them the board string. Move
geometry, the blocked scan's flood fill and the 6.9 search never read a piece code. Even the best
alphabet can only shorten part of this small surface, and it pays some of that back in the
board.
A fair comparison. Every alphabet-independent improvement went to today's engine first; only
then were the candidates compared, against that same base. All the differences below come from the
alphabet alone.
The candidates. Each candidate was built on that base, with its own best blocked-scan gate. The
alphabets are:
Alphabet
Bishop
Rook
Queen
Pawn
King
Knight
today's
2/3
4/5
6/7
8/9
a/b
c/d
sliders on top, pawn at the bottom
a/b
c/d
e/f
2/3
4/5
6/7
knight and pawn swap places
2/3
4/5
6/7
c/d
a/b
8/9
king and knight one type up
2/3
4/5
6/7
8/9
c/d
e/f
rook and queen on top
2/3
c/d
e/f
8/9
4/5
6/7
signed: side in the sign of the number
±1
±2
±3
±6
±5
±4
In the core, the engine on its own, the results against today's alphabet:
Alphabet
Change
Where the gain comes from
What it costs
sliders on top
−6
pawn test p<4, king lookup 4+s, opposite-colour pawn 5-p, the cheapest blocked-scan gate p>5?1:p
board string +5, G's dispatch +3
knight and pawn
−4
pawn test p>11, a one-XOR weight p>>1^5, blocked-scan gate p>9
blocked-scan gate +1: kings and pawns no longer adjacent
rook and queen on top
+2
weight, king lookup
G's dispatch +3; no single-mask gate is possible
The largest fair gain in the core is 6 bytes. The real question was whether that gain spreads to
every build, since every build on this site uses the same alphabet:
Alphabet
Core
L3 builds
L2 builds
L1 builds
sliders on top
−6
−4…−9
−4…−9
0…+5
knight and pawn
−4
−4…−7
−5…−8
−1…+2
signed
−4
not built
not built
L1 −7 (computed)
king and knight up
−3
−1…−3
−1…−3
0…+2
rook and queen on top
+2
+2…+4
+2…+4
+2…+4
The core and L1 columns were measured. The L3 and L2 ranges were derived from the first round's
measured build values, corrected by the new difference of the blocked-scan gate; in the core this correction
matches the measurement exactly for every candidate. L2_light carries its own material
and blocked-scan chain, so the engine's gains do not reach it; it stays outside the ranges.
Why does L1 pay? L1 has no material tests, no 6.9 search and no blocked scan. Most of each
candidate's gain comes from exactly those places. What remains for L1 is the cost: the board,
G's dispatch and the driver tables. The only places a gain could come from are the king
lookup and the pawn tests.
The signed alphabet. In the second round the rules themselves were put on trial, and one of
them gave way: a piece's side is mandatory information, but it does not have to be one of the
piece's bits. It can be read from the sign of the number; then all four bits go to the type: one of
the sixteen patterns is empty, six are pieces, nine are free. The string holds only the magnitude of the
type, and the sign comes from where the square is:
today b=[...`5d37b3d5${10n**40n-10n**32n}888888884c26a2c4`].map(u=>'0x'+u-0) 70 bytes
signed b=[...`24135142${(10n**40n+1n)*66666666n}24135142`].map((u,i)=>i>31?-u:+u) 74 bytes
(10n**40n+1n)*66666666n produces both pawn ranks and the 32 empty squares between them
with one multiplication; that is only possible when both colours share the digit. The side to move
becomes t = ±1, and p*t reduces “is this my piece” to a single comparison;
with t as 1/0 the core would be 13 bytes longer. The bit tricks pay the price: bitwise
operations do not hold for negative numbers, 6&4 is 4 but -6&4 is
0. Every operation that reads the type is rewritten with a(p) or a square: pawn test
p*p>25, castling recognition p*p>9, bishop test
p*p<2. One of the 4 bytes in the core comes from freeing the mate code with
~t; today's alphabet could take the same byte with V(t)?~t:3, but did not,
so that the 1–15 order of the result codes would stay intact.
t = ±1 has one more side benefit: ~t turns it into true or false for
free, ~1 is −2 and ~-1 is 0. The result codes, which only need to differ
from one another, were freed as well: W# −1, B# 1, WT −10, BT 10, WR −11, BR 11; the sign of a
verdict is the sign of the losing side. Aliases for the board were tried too, and all of them lost:
an eight-digit string occurs only twice, so defining an alias costs more than the repetition saves
(+1 to +10). Empty squares can also come out as −0 during set-up; it is falsy and ==0
holds, but Object.is or 1/p tells it apart from 0.
Closed questions. A second design for the ghost en passant, writing the
mark onto the pawn itself instead of onto the skipped square, turned out better but still loses:
about +8 in L1, +10 in L2, +4 in L3. The halfmove counter's reset condition P|b[f] reads
two different squares, and no layout can reduce that to one. S, which checks that the
squares in between are empty in castling and the double step, does not know the piece code; both
are independent of the alphabet. The material tests read a bishop from the colour of its square, not
from its code, so a separate code per bishop is unnecessary. The 6.9 search's promotion loop has to
keep the order queen, rook, bishop, knight: if the order changes, verdicts at the search's budget
limit can flip. Promotion pieces having consecutive codes gains nothing in the core; only in the
drivers, 2 bytes in the blind builds' letter table. The white pawn being 9 is specific to today's board trick;
10n**40n-10n**32n can only produce 9s and 0s. Embedding castling rights in the board was
not tried: the bit families have only three free codes, not enough for the ghost and for rooks and
kings that hold a right, while the signed alphabet's nine free patterns would suffice. Every mark
placed on the board has one more cost: the repetition key b+t+e+c contains the board
itself, so if the ghost or a castling right goes onto the board, repetition detection changes with
it.
Some ideas were eliminated without building a file, by calculating from the structure of the
code:
Idea
Change
folding the king into the slider branch: king = “a queen with range 1”
+13 / +16
swapping bishop and rook: the diagonal bit leaves the bottom
at least +6
merging the material test's three lookups into one scan
+4…+16
embedding the bishop's square colour in its code: a separate code for the light-squared bishop
+21, the real cost is promotion
recording the types the material test scans and passing them to the second test
+8
Some lost when measured:
Attempt
Change
knight as type 4 in L2 and L3 (pawn 5, king 6)
+10 in the core
king as type 7 in L1
0…+2
knight as type 4, an L1-only trial
−2 in L1_numerical only; +3…+10 in L2 and L3
Still open. If the board's +5 could be paid for more cheaply than with today's trick,
“sliders on top” would reach −11 and “knight and pawn” −9. The untried roads are known. A single
division only works for the divisors of 9 (1, 3, 9); other digits need a multiplication as well. The
parser '0x'+u-0 could be replaced by another decoding, but since the empty squares must
stay 0, a fixed shift does not work directly. If the leading “1” that 10n**32n produces
could be made to land on a meaningful code, a form like dddddddd${10n**32n} might be
possible. With the colour on the top bit and the pawn as type 1, the white pawn is 9 again and
today's trick works unchanged. What was not scanned is on record too: the layouts with the colour on
the top bit, the form with the direction bits apart, the packed builds, and engine_4x,
where only “king and knight up” was measured (−8 in the first round). The families of expressions
can be widened as well: an expression form nobody searched is a gain nobody finds.
How it was verified. Every candidate was built as a real engine file. Perft matched the
published values for the starting position, Kiwipete and CPW positions 3–6 at depths 3 and 4. Then
each candidate was played side by side with today's engine: piece codes, side to move, promotion and
result codes were translated into each other, and at every half-move the board, side to move, en
passant square, castling rights, counter and repetition count, every legal move list, the material
tests, the blocked scan, the draw claim, the flag-fall and resignation verdicts and the result after the
move were compared. The inputs were random games, endgames with pawns close to promotion, positions
with the counter near the 75-move limit and locked pawn walls. The test's sensitivity was checked
by breaking the engine on purpose: with the material weight or the 6.9 budget broken, the
differences were caught at once.
The decision. No alphabet was adopted. The largest fair gain in the core is 6 bytes; the
candidate that changes least saves 4. Against that, the board strip in the hero, the code tables,
the account of the board, the promotion API, the test suite and every build would change, with
re-verification and repacking. And the gain does not spread to every build: L1 pays.
The candidates are kept for a record attempt one day, or for a major revision in which the site is
rewritten from scratch: on that day the one that saves most is “sliders on top”, the one that
changes least is “knight and pawn”.
The second search — on 0x88, adopted this time
That major revision came, but the new alphabet was not picked from those candidates. When the board
moved to 0x88, most of the expressions the alphabet leaned on changed — the
(h*v?h==v:2)&P that moved the sliders was gone — and the search was opened again
from scratch, this time in L1's light build. The layout changed too: the
colour in the top bit (white = type + 8), the type in the three bits below. The old layout with
the colour in the bottom bit was ruled out by a fixed +7 bytes: extracting the type in two
places, the last rank, the promotion code and the own-piece test all grew.
The list of axioms got shorter. The empty square is 0: the walk carries on over an
empty square with b[i]<1 and stops at a hole. Codes are 1–15: the starting
string reads one hex digit per square. The turn t is the colour's value itself, 8
or 0: shortcuts such as b[i]-t, +~t and 2+t rest on it.
The pawn's type is 1, because a white pawn of 9 is what the board trick requires:
10n**40n-10n**32n produces only 9s and 0s. With these rules the funnel went from
16!/3! ≈ 3.49 trillion assignments down to 120 (bishop, rook, queen) triples, with the best
placement of king and knight fixed for each.
In the first rounds the best was HB8, at 910 bytes. Then the search showed that an axiom it
had set itself was too narrow: “queen, rook and bishop decide with a single shift.” When the
slider test was searched as a free expression for each triple, two alphabets came down to
909:
Alphabet
Pawn
King
Queen
Knight
Rook
Bishop
Free type
Slider test
Bytes
H5
1
2
3
4
5
6
7
P+P*4%x
909
H4
1
2
5
6
3
4
7
P/x*9*9
909
HB8
1
3
7
4
2
5
6
P>>x%3
910
Both leave type 7 free, so the glyph string is one character shorter; with the king at 2,
the castling constant is a single digit (d-8). H5 was chosen because its slider
test is pure integer arithmetic. It is today's alphabet: White's first rank is
dcebaecd. There is nothing lower in this template: 908 would need a six-character
slider test in an alphabet with the king at 2 and type 7 free, and none turned up in any of the
forms searched.
The lesson ties back to the old search's debate about axioms. An axiom that states not a
necessity but one particular shape of code can eliminate the winner, and it happened twice:
the “one diagonal bit, one orthogonal bit” rule inherited from the previous generation would
have eliminated HB8; the second search's own “single shift” rule eliminated H4 and H5.
The five nearest alphabets were built in L2 and
L3 as well. The light builds of that day, in bytes with the site's
interface:
Level
H5
H4
HB8
H2
H1
H3
L1
933
933
934
934
935
935
L2
1,540
1,540
1,540
1,541
1,544
1,543
L3
2,325
2,328
2,325
2,326
2,329
2,330
At no level does anything beat H5. HB8 catches up in L2: the king
and pawn codes are 1, 3, 9 and 11, all four divide 99, so the “neither king nor pawn” test comes
down to 99%p. H4 falls behind in L3: in the material counter
the knight has to land on the zeroth shift — the “a single knight on the other side” test
4%m relies on it — and with the knight at 6 the shift has to be paid for. In H5,
4<<p*8 puts the knight there by itself, because the shift wraps at 32. H5
passed full perft, the browser scenarios, and 1,969,237 legal moves compared with an independent
referee over 300 random games, without a single difference.
a8=0 indexing — chosen for the render, abandoned for the engine
The board once started from the eighth rank: Black at the top, a8=0,
b8=1, … h1=63. The reason was the render — in that layout,
drawing the array from start to finish gives you a board directly from White's point of view, with no cost for the first flip.
But what this engine was after was not brevity alone; being intelligible and universal
was a priority too. And not every build has a board — one is blindfold, one numeric. A
concern about rendering does not bind all seven files.
Measured, the scales came out even anyway. On the engine side a8=0 costs
exactly zero; among the drivers it saves zero in some files and a byte or two in others. But
the compressible block of the starting board shifts position in that layout, and the numeric
compression takes back just as much. What is won in the render is lost in the literal.
One justification remains, and it is free: a1=0 is standard chess indexing.
Because rank and file both count upward in White's
direction, every expression in the engine reads naturally across all 64 squares.
The engine's universality was preferred at a cost of zero bytes. The claim is about the
engine, not the render.
On 0x88 the question was asked again. The rank-reversed 16×8 — ranks stored from 8
down to 1, that is, a board starting at a8 — came out byte for byte equal to the plain 16×8 in
the light builds of L1, L2 and
L3: w^119>>t instead of w^7<<t/2 to flip
the display, the same length. What broke the tie was the 6.9
search: on the board counted from a1 the moves are visited in the same order as the
previous engine, and the budget runs out in the same place. a1=0 stayed.
Left out of the engine, taken in the letter board. Since the difference is zero
bytes, the decision can go either way per file. Today the builds that count the board from a8
are the letter-board previous generation: the
prompt_string family and
L2_string_base; prompt_string_a1 is the same
engine's twin counting from a1. There you can see how little a reversed index — which changes the
whole board representation — touches the code: the pawn's direction test becomes
f>i instead of f<i; apart from that, only the blocked scan's name binding
changes in the rule body (below). The interface side is itemised in the letter-board section.
The blocked scan on a flipped board. The letter-board builds that count the board
from a8 — engine_string.js and the prompt_string of
L2 and L3 — carry the blocked scan as well. The
saturation loop and the six questions at the exit are the same as in engine.js;
the only difference is the name of the bit counter, y rather than j,
because in the letter engine j is the helper that turns a letter into upper case.
The flipped board costs nothing but the binding: since the loop tells pawns apart by direction
rather than colour (W are the ones walking toward increasing indices), on a board
counted from a8 W and B, _ and f are bound
to the opposite colours — the letters are looked up in '-pPkKbB', Black first,
while the twin counting from a1 uses '-PpKkBb' — and the side switch in the first
term is written the other way round: !(s?W:B). The other differences come from
the letter board. The bitboards are built in a single array and handed out with
[,,W,B,_,f,a]; a bishop's square colour is i*9/8%2+1 on the flat 64.
_ and a, the empty square and Math.abs in the letter
engine, are parameters there, so the V there writes only S as a
global. With no en passant e is '-', so e-8 is
NaN, which becomes zero in a bitwise operation; the gate works with the same
spelling. L2's prompt_string carries the same loop and
exit in a shorter body: it asks about both sides at once and sums the material in a single
number.
The n<2 window — undoing a proven shortcut
For a long time the blocked-position scan sat behind a gate:
~e|n>1?0:. If an en passant square existed, or the ply counter was above
one, the scan never started. In the L3 family the gate's second half carried a
flag as well — ~e|n>1&Y?0: — because the
6.9 helpmate search cannot trust the counter inside its own nodes and
had to force the gate open with l(0).
The gate was lossless, and that can be derived rather than tested. In the
n≥2 region the only move available is a quiet king move: both pawn moves and
captures reset the counter. A quiet king move cannot change what the scan returns, because
the scan reads the king's region and a king cannot leave its own connected
component. Check cannot slip past either: in this scope only a pawn can give check, a pawn
move resets the counter, and the king's reply stays within n≤1. And the
window's second slot was exactly what caught the ruling an en passant square had silenced
one ply earlier. Four cases, all four inside the window — so the gate never once missed a
real verdict.
It was dropped for reasons other than correctness. The gate was a shortcut, and what the
shortcut bought did not show at human speed: with any piece but kings and pawns on the board
the scan never starts, so the saving fell only in pure pawn endings, and only below
half a millisecond a move. What the gate cost, by contrast, was visible everywhere it had to
be paid: a condition in twenty files, a parameter in L3, an argument at each of
two call sites, and a mechanism that had to be written exactly right. Had it read
&!Y instead of &Y, the 6.9 search would have quietly lost
its blocked-position scan at depth — a trap none of the twenty builds fell into, but one every
new build could fall into again.
With the gate gone there was nothing left to force: the Y parameter dropped,
and both call sites became l().
The gate's first half stays. ~e is not a shortcut but a condition that
changes the verdict: if en passant is available then a pawn move is available, so
there is no lock — and the scan should not have to discover that for itself. This story took
place in the previous generation; today the scan is V's blocked
half, and since “no en passant” is the value 8, the same condition is written
e-8: !(o|m|e-8||g(99,…)).
The known cost collects in one place, and it is not worth hiding: bulk validation. Half a
millisecond that is invisible in a browser becomes part of an hour over a million games on a
server. Anyone running it there should put the gate back; this page keeps how it was written
and why it was sound.
Item
Bytes
L2 family, six files × −4
−24
L3 family, twelve plain files × −10
−120
Total across the plain files
−144
Cost per move in a pure pawn ending
+0.45 ms
Cost per move with a piece on the board
+0.004 ms
In engine_4x the gate came back, but in another place and in another form.
The fast engine's Z gates the dead-position test with (n+e<10|_)&&
(where the 117 bytes go). The gate no longer sits inside the scan but in
Z: the 6.9 search calls V directly, so there is nothing to force, and anyone
asking for a verdict outside the game opens the gate with Z(1). The 3-byte-shorter
n<2 was tried in its place and dropped: the two conditions are not equivalent. With no
en passant square, e is 8, so n+e<10 is exactly n<2; with a
square, e is at least 32 and the sum never falls below 10. So n+e<10 means
n<2and no en passant. The difference never reaches the verdict: while en passant
is available, V's blocked half stops at e-8, and its material half never
calls a position dead with pawns on both sides. Where it does show is the work: with
n<2, Z would call V twice for nothing on every ply with an open
en passant capture — scanning the board and building the sets only to learn the answer at
e-8.
Blocked positions with bishops — the extension on hold
The blocked-position scan — today V's blocked half, in the
previous generation l — covers only pawns and kings. There are fully dead, blocked
positions with bishops on the board, and they fall outside that scope. The gap has been measured:
in CHA-Solver's list of 204,060 games — every one a draw by construction — 3,638 of the 3,676
the engine cannot prove have a bishop on the board. In all of them the search runs into the
depth limit; the budget runs out in none. An extension to close the gap was designed and
measured in the previous generation: it took 782 bytes more, so the return per byte fell by a
factor of twenty-four. It also cost speed. The real obstacle
is the guarantee: a bishop adds a third unknown to the question and so puts the engine's
only proved property — every position it calls dead really is dead — at risk. The extension
is shelved, not lost: rewritten in the 0x88 generation, it stood in the engine for a while
(+333 bytes) and was then taken out; today it lives on in LUX. Its layers and their bytes are in
LUX's engine.
Parsing
The driver's three parsers
The engine offers six calls and all of them speak in square indices:
for L(i,f) and A(i,f,p), i and f have to be
0x88 indices — 16 × rank + file. The user, meanwhile, gives a click, a
letter or a digit. The conversion between them is the driver's business.
There are three parsers — clicking, UCI and numeric — and each is a single line. What they
share: none of them validates. An invalid input does not take an error branch; it
turns into a pair of squares for which L returns false, and is dropped there silently
— even a square that lands off the board, because L's filter turns the holes
away. The parser's job is to convert correct input; what filters out incorrect input is the
engine itself.
What the user types is in the notation section. Here is the code
instead, because all three of these lines carry golf technique; the
result codes that come back are on the Builds tab. The examples come from
the L3 builds; the prompt_string family is from
the previous generation and carries its own parser along with
the letter board.
Clicking — a state machine
In the build you play by clicking there is no text to parse. In
default each of the sixty-four squares carries a click handler that
passes its own board square, and the whole selection logic sits in a single
expression:
K = u => z || p.hidden && (
L(i,u) ? (Y = u, X ? E(X) : p.hidden = 0)
: b[u] + ~t >> 3 || F(i = Y = u))
The screen square is turned into a board square in the render. Because the board
turns to face the player to move after every move, a cell on screen is not always the same
square. The render writes each cell with D=w^7<<t/2 and hands the click
handler D, not w. w is the square's 0x88 index;
7<<t/2 is 7<<4, that is 0x70, on White's move
and 7 on Black's. Since in 0x88 the two hex digits are the rank and the file, the
first flips the ranks — the eighth rank on top — and the second the files — the board seen
from Black's side. One XOR, two different rotations; the u that reaches the
handler is already a board square.
There is no selection flag. With nothing selected i is -1,
and L(-1,u) is always false: b[-1] is undefined, the
type is zero, no geometry passes. So the first click falls into the selection branch by
itself; i carries both the selected square and the question “is anything
selected”.
The selection is made with i = Y = u, both at once. The reason is the
render: the green outline marks Y, so it shows the selected square at the moment
of selection and the destination square after the move. One variable, two meanings, both
correct.
There is no error branch. The guard of the selection branch is L's own
filter: b[u]+~t>>3 is true for an empty square and for an enemy piece, and
zero for one's own. If it is true, || stops there and nothing happens; if zero,
the selection moves to that square. Three outcomes — play the move, move the selection,
ignore — all fall out of a single ternary chain.
The legal targets come out of the render. The squares the selected piece can reach
turn gold, but no separate mechanism does that: the render asks L(i,D) for every
cell. Since L asks about a single move, the call necessarily sits inside the loop
over sixty-four squares; with nothing selected all of them are false and no square is
painted. The render is rebuilt every 99 milliseconds along with the clock, but writes the
board to the screen only if its string has changed: m!=(m=…).
Promotion suspends the move. If the move is legal, the decision lies in
X: U wrote it a moment ago, in L(i,u)'s trial move. If
it is non-zero the move is not a promotion, and E(X) plays it; if zero, the
picker opens and the move waits there. Y is already written; the picker's buttons
write the piece type to y and complete the move — the U of these
builds reads the promotion piece not from a parameter but from y. Suspending
needs no extra state variable but it does need the board locked — which is what the
p.hidden && guard at the head of the expression is for.
Claims and offers after a move. The move step of default
contains o&=t?1:8: in this build the offer bits are 8 for White and 1 for
Black (o^=t||1), and after the move t has already been flipped. The
mask keeps the mover's bit and drops the opponent's pending offer, because making a move is
declining it. If the game has not ended by itself and a request bit is still lit,
o&&J() weighs the claim: threefold repetition or the 50-move rule.
light trims the same machine a little further. With no
target painting and no green outline, the selection is just i=u; Y
is written on every click, inside L(i,Y=u). i starts not at -1 but
at 0, that is a1 — but since the rook there cannot move on the first move, this too means
“nothing selected”. After a move i is not reset; it stays on the origin square,
which is now empty, so L again returns false. The promotion picker is not a
hidden element either but a string: while p is non-empty it is written as the
board table's caption, the z||p|| guard locks the board, and the buttons clear it
with p=''.
UCI — reading two characters in one call
In the UCI builds a square is two characters: a file letter and a rank digit. The whole
conversion is one line.
J = V => (V = K.charCodeAt(V) + 8 * K[++V] - 105) * 2 - V%8
Four tricks stacked on top of each other.
Mixed types.K.charCodeAt(V) returns a number, K[++V] a
one-character string. As an operand of 8*, the string is coerced to a
number — no separate parseInt is needed, and the multiplier is already the
weight of the rank on the plain board. One operation doing two jobs.
A side effect.++V increments the parameter in place, so a single call
reads both characters. The caller supplies only the start of the pair: J(0) the
origin square, J(2) the destination. Once the reading is done the same letter is
assigned again, and now holds not a character position but the square's plain index.
The constant.105 is no arbitrary number: the code of
'a' is 97, and the first rank contributes 8*1. Their sum lands a1
on zero; inside the parentheses is the plain board's index, 8 × rank + file.
The step to 0x88.*2-V%8 turns the plain index into the 0x88 index —
the same as the j*2-j%8 in the expression that builds
the board. The parser works on the plain board with its familiar arithmetic and carries the
result over to the engine's layout in a single expression.
Because the input passes through toLowerCase() first, E2E4 and
e2e4 give the same square. Case-insensitivity is not a separate rule but the
by-product of one call.
Numeric — two digits, two hex digits
The numeric build has no J, because it does not need one. The user already
types the square as the engine's index, and the conversion finishes inline.
i = K[0] << 4 | K[1]
Y = K[2] << 4 | K[3]
The two digits are the two hex digits of the 0x88 index: rank first, then file,
both from 0 to 7. <<4 shifts the rank into the high digit, |
puts the file into the low one; for e2, '1'<<4|'4', that is 20. Both the shift
and the bitwise OR coerce their operands to numbers by themselves; no separate conversion
is written for one-character strings. A file digit such as 8 or 9 lands on a hole, and
L turns it away.
There is nothing else to shift, because the number typed is the engine's own index; the
numeric builds of L1 and L2 use the same
layout.
Leave the promotion letter out and you get a queen — and no check does that.
The text parsers pass the promotion character through an object lookup
({r:5,b:6,n:4}[K[4]]) or a string index ('654'[K[4]]); on a
character they do not recognise, both return undefined. And
undefined passed as an argument triggers the default parameter in
JavaScript, and U's default is p=3: queen. So the behaviour comes not
from a check but from a rule of the language, and it costs no byte in any of the text parsers. In
the clicking builds the promotion is always chosen from the picker.
The other direction — writing the result
The engine returns a number: a code from 1 to 15. What gets written on
screen is the driver's business, and here there are two routes.
The numeric build does not translate at all — it prints the digit itself; in an interface
that stays numeric from end to end, turning the code into letters would be inconsistent.
light writes nothing at all: the result is
read from the board and the clocks. Every other build —
default and the text drivers — never produces a number in the first
place: they rewrite the verdict in lines of their own, and the two-letter code comes straight
out. Three lines from prompt:
The shape is the engine's own; only the literals that come back change. Not a single
branch is rewritten, and the order of precedence stays exactly as it was.
For flag fall and resignation the two letters are built by concatenation. F is
the letter the driver holds: 'T' for a flag fall, 'R' for a
resignation. If it is impossible for the opponent to mate, an 'M' goes after it —
TM or RM. Otherwise the winner's letter goes in front: the side whose flag fell or who
resigned is the side to move, so the winner is its opponent, and 'WB'[t/8]
picks it with a single index — on White's move t/8 is 1, that is
'B'. WT, BT, WR, BR: four outcomes, one expression.
While the game continues the value returned is 0; z stays false
and the loop goes on. No branch is spent on the “still going” state.
The two encodings now line up slot for slot. Draw by agreement is 9 in the engine
too, and DA in the lettered builds; in the result table each cell's
number and abbreviation name the same outcome. It was not so in the previous generation: the
engine kept draw by agreement at 15, while the UCI builds translated the code into letters
with two parallel strings — ' WBS75D53D' and ' ##M5RP0RA', read
with the same index — and to fit that ten-character table, draw by agreement was 9 there. In
the new generation neither the translation table nor the split remains.
Three loop shapes
Who calls the parser also varies from build to build, and that choice decides directly how
the clock is kept.
Loop
Who calls it
Clock
Blocking modal dialog
The driver loop, halted while awaiting a move
Measured and deducted once the dialog closes
Event key or click
The browser, when the user acts
A separate timer, running continuously
Stream terminal
The read stream, as lines arrive
Measured on the move
In the blocking shape the clock does not really stop, it never runs — a timer cannot
fire while a modal dialog is open. Instead the gap is measured and deducted once the dialog
closes. The outcome is the same, but this is why the numeric build's clock keeps
milliseconds: since the measurement is a plain time difference, converting it to seconds
would buy nothing.
All three make the same call to the same engine. What changes is only when the call
arrives — and because the engine never reads the clock, that choice
never touches the rule layer.
The driver boundary
The engine's rule layer — geometry G, threat T, castling
C, verdict V, the search H, and U and
M, which apply a move — carries the same logic in all nine
L3 drivers. What changes is the top layer, and two small edges of the
engine.
The top layer. Only prompt and
prompt_blindfold call A as it stands —
A(i,f,promotion), with a lettered Z and D. The other
seven inline A's steps into a line of their own: they call M, count
the new position and ask for the verdict on the same line. The clock increment sits there
too: the clicking builds write u[t]+=5 just before M flips the side
to move; the text drivers add it to the side they saved with g=t before the
move — u[g]+=5, or G[g]+=5 in the prompt
family; numerical has no increment.
Where the clocks and the counts are written. The engine opens no separate object for
the repetition table; it writes the counts onto G's own properties
(G[k]), and the prompt family hangs the clocks there too
(G[0], G[8]). The clicking builds and the
input family write both onto the element on screen — the board's
table or its <pre>, u; the nonbrowser
family hangs the clocks on the function that reads a line (also u), and
numerical's clocks live in a plain object. The clock keys are 0 and 8,
the position keys long strings; the two never meet.
The two edges. The two kinds of driver cut the engine at two different places,
because their input is trustworthy to different degrees. In the clicking builds a selection
can only ever land on one's own piece; promotion, on the other hand, comes from a picker, so
U reads the promotion piece not from a parameter but from y
(y+t), and H writes y=p before each try. In the text
drivers typed input can name any square; so L gains an own-piece check
(~b[i]&8^t&&), and since it now filters the origin square itself,
O's colour filter is dropped.
prompt_string and prompt_string_a1 are from
the previous generation; they hold the board as a string of letters and spell the steps out
in their own loop.
Castling ledger
L1's castling ledger — touched squares instead of rights
L2 and L3 keep the castling rights in a four-bit mask:
c=15 starts with all four rights open, and as moves are played the C table
says which bit to drop. L1 does not; it has neither a mask of rights nor a
C table. It keeps a ledger instead: every move played marks its origin and
destination squares. The ledger is not even a variable of its own — the marks are written onto
G's own properties, the very place where L3 keeps its
repetition table; L1 counts no repetitions, so that place is free. In the
two clicking builds (default and light) the board's
table, u, does the same job.
All castling asks is that the king's square and the rook's square have never been touched in the
game; the same ledger also answers the pawn's double step. The two forms part ways in five
places:
When a move is actually played, G[i]=G[Y]=1 marks two squares. In the castling
branch, G[i]|G[r=f+=s-!~s]|T()|T(i+s)?0:S() gathers four questions into a single condition:
has the king's current square or the computed rook square ever been touched, is the king in check
right now, is the square it passes over attacked. If any of them holds, the answer is 0. The rook square is worked out exactly as in
L2 and L3: s-!~s is 1 on the kingside
and -2 on the queenside — one to the right of the king's destination on the kingside, two to the
left on the queenside. Since f is now the rook square, the S() that
follows walks from the king to the rook and confirms that the squares in between are empty. Both
ways of losing a right come down to one mark: if the king or the rook has moved, its square is
marked; if the rook was captured in its own corner, the corner is marked too, because that is
where the capturing piece landed.
Trial moves leave the ledger clean. L's legality trial calls nothing but
U; U works on a copy of the board, and L puts the old one
back with b=q on its way out. The mark is set not in U but in the
driver's move line — where the move is really played. In L3 the mask is
updated in M rather than U for the same reason: L never calls
M, which is why it saves nothing but the board before its trial.
The same ledger answers the double step.L2 and
L3 ask about a pawn's double step by rank: (i%96<24) is true
on the second and seventh ranks among those a pawn can stand on; a double step in the wrong
direction falls off the board anyway. L1 asks the ledger instead:
!G[i] — if the square the pawn stands on has never been touched, the pawn is on its
starting square. In a game played from the starting position the two are equivalent: a white pawn
cannot arrive on the second rank later, so the pawn there has always been there, and another piece
could touch that square only by capturing it; the square of an advanced pawn was marked on the
move it arrived. The same holds for Black on the seventh rank. The saving is four bytes:
!G[i] is five characters, (i%96<24) nine — the latter needs its
parentheses because of the <.
The ledger never asks three things: whether its own rook really stands on the rook square,
whether the king is on e1 or e8, and whether a pawn about to double-step really stands on its
starting rank. In a game played from the starting position all three hold by themselves — an
untouched corner holds a rook, an untouched king square holds the king, an untouched pawn square
holds a pawn that has never moved. When the position is set up from a FEN, the loader has to
guarantee them; the next section covers how.
Why L2 and L3 need the mask
This shortcut is possible only at a level that does not count repetitions. Castling rights are
part of the repetition key (9.2.3.2), and the key has to be canonical: two positions with the
same rights must be written the same way. The ledger is not; it carries the game's history. Even
a2–a3 marks a2 and a3, and squares with nothing to do with castling pile up in it. Two positions
with the same pieces on the same squares and the same rights would be counted apart if they had
been reached by different roads. The mask holds the four rights and nothing else; since
C returns zero for every irrelevant square, those never get written, and the mask
enters the key k=b+e+t+c as a single number.
The en passant square shows the same split. L1 writes it after every
double step — e=U(…) returns the square in between on a double step — and never asks
whether the capture is legal; an illegal capture is refused by L anyway. In
L2 and L3, M keeps the square only if a
pawn of the side to move can really capture onto it (b.some(…L(j,e))?e:8), because
e is part of the repetition key too (9.2.3.1). Both rights carry two forms for the
same reason: rule correctness does not change, only what repetition counting needs.
What it costs
The difference is small. In the new generation the four places add up like this: the
C table is 29 bytes with its comma and absent from L1
altogether; the mark on a move is 3 bytes shorter and the double-step test 4; the castling test, on
the other hand, is 4 bytes longer. That makes 32 bytes. The mask's starting value needs an
assignment of its own, too: the 15, in L3's header is three
bytes more. As algorithms the two weigh the same: one clips four bits, the other marks two
squares.
L1 took the ledger in the previous generation,
in a single change ↗️,
and the file went from 1,567 bytes to 1,541 — 26 bytes. Back then all of the saving came
from the C table (28 bytes with its comma); the mark on a move was 3 bytes shorter,
the castling test grew by 5, and the double step was not yet asked of the ledger.
The two bots at the L1 level, L1_1250elo and
ToledosOpponentbyMe, use the ledger too. At every node the search saves
the old marks of the two squares the move touches, marks them, and puts the old marks back on the
way out, so the ledger never has to be cloned. Measured against the mask it costs no speed, and
the search returns exactly the same values. Both run on the 0x88 board, on the core of
engine_4x.
FEN
Setting up a position from FEN — L1
The L1 engine holds a position in three variables and a ledger: the board
b, the side to move t, the en passant square e, and the
castling ledger — G's properties. There is no loader that reads
a FEN; the engine trusts all of these and checks nothing about them. Turning a FEN into them is the
driver's job. What follows is how to do it, and above all what to watch for with castling and en
passant.
L1 has two board formats. The six builds other than the bot hold the board
in numbers, in the 0x88 layout: default,
light, input, input_blindfold,
numerical and nonbrowser.
prompt_string is from the previous generation and holds it in letters. The
rules are the same in both; only the way pieces and squares are written differs. The numeric format
comes first, and the string build's differences are at the end.
The variables
FEN field
In L1
Numeric builds
String build
Piece placement
b
0x88: 64 numbers, a1=0 … h8=119; the 56 slots in between are holes
64 letters, a8=0 … h1=63
Side to move
t
w → 8, b → 0
w → 1, b → 0
Castling rights
G[…]
The touched-squares ledger; u[…] in default and light
The same ledger, with mirrored square indices
En passant
e
Index of the skipped square, or 8
Index of the skipped square, or '-'
Halfmove clock, move number
—
L1 has no counter; not read
The same
The result holder z is set back to zero as well. light has no
variable called z; there the assignment is harmless, merely unnecessary.
The side to move must be 8 or 0.t is not a flag but a colour bit: the codes
of the white pieces carry the 8, and everywhere the engine compares the side to move against the
same bit in a piece — p&8^t, ~b[i]&8^t,
b.indexOf(2+t) when looking for the king, p|t on promotion. Write
t=1 or t=true and 2+t comes out as 3, so the engine looks for
the black queen instead of the king. The engine itself always produces 8 or 0 through
t^=8; a FEN loader should write one of them too.
The board. FEN writes the ranks from 8 down to 1, while the numeric b starts at
a1. A square's index is 16 × (rank − 1) + file, with files a=0 … h=7; between any two ranks
lie eight holes. A hole and an empty square are not the same thing. The holes must stay slots
the array never writes, while empty squares must be 0: when sliding, the engine asks
for an empty square with b[i]<1, and undefined<1 is false; a square
left unwritten instead of 0 stops a sliding piece like a wall, and
b.every and b.some never see it at all. That is why the loader first zeroes
the sixty-four squares with the engine's own formula (b[j*2-j%8]=0) and only then writes
the pieces. The piece codes are those of the engine's board representation: 1
pawn, 2 king, 3 queen, 4 knight, 5 rook, 6 bishop, 8 more for White; an empty square is 0. Indices
often needed: a1=0, e1=4, f1=5, h1=7, a8=112, e8=116, h8=119.
Castling
A right is valid only if three conditions hold at once: the FEN lists it (K,
Q, k, q), the king is on e1 (e8 for Black), and its own rook
is in that corner (K → h1, Q → a1, k → h8, q →
a8). The loader starts by emptying the ledger, marks the corner of every invalid right, and also
marks the king square of a colour left with no valid right at all. That way the only untouched
squares are the king and rook squares that really serve castling. Leaving out a doubtful right is
always safe: at worst the right is lost.
The pawns are marked too.L1 asks the ledger about the double step
as well (!G[i]), so the square of every pawn that is not on its starting rank has to be
marked — anything off the second rank for White and off the seventh for Black. In a game played from
the start, those squares would already have been marked on the move the pawn arrived; a FEN has no
such history.
What goes wrong with a wrong entry follows from the three things the
ledger never asks:
Entry
Result
A right that exists is left out
Harmless; the right is simply lost
Right listed, king on e1, corner empty
Rookless castling: the king moves two squares and no rook follows
Right listed, king on e1, another piece in the corner
That piece is moved to the square the king crosses (h1 → f1, a1 → d1) — even an enemy piece
Right listed, king not on e1/e8
False castling: the king moves two squares; if the square computed as the “rook square” is on the board, whatever unrelated piece stands there is moved to the square the king crosses (with the king on d1, the knight on g1 goes to e1); if it lands on a hole, the board breaks
Pawn off its starting rank, square unmarked
The double step opens up: a white pawn on e3 can go to e5
An example of false castling that lands on a hole: the white king on f1, K listed but
left unmarked, g1 and h1 empty. The engine takes f1–h1 as castling, and the “rook square” comes out as
8 — a hole in 0x88. Moving the “piece” from there to g1, U finds its type to be
undefined-8, that is NaN; since X drops to zero the move
counts as a promotion, a white queen — the text builds' default promotion piece — is written to g1, and the hole becomes 0 — a
sixty-fifth square on the board. Because d is NaN as well, it counts as
false and the recursion that moves the rook runs once more: it writes this new empty square to h1,
on top of the king. The king is wiped off the board.
In L2 and L3 false castling does not arise,
because C turns only the corner squares into a right — it returns zero for 8 and for g1
alike. But they do not check whether the rook is in its corner either, so rookless castling is
possible there too.
En passant
e is the square the pawn skipped, not the square it stands on: if Black played
d7–d5, e is d6 (index 83). The square is written only if all of the following hold,
otherwise e=8: with White to move the square is on the 6th rank, with Black to move on
the 3rd; the square itself is empty; an enemy pawn stands one step behind it (for d6, a black pawn on
d5); and the square the pawn came from is empty (for d6, d7). 8 is a hole, so it equals no square on
the board, and it is what the engine itself writes for “no en passant”. Any other number off the
board would do as well — default starts with -1.
L1 does not also require the capture to be legal. With no repetition count,
a square left written changes no ruling, and an illegal capture is refused by L anyway.
In L2 and L3 it is different: since e is
part of the repetition key, the capture must also be genuinely legal (9.2.3.1).
The engine trusts e completely: it allows the pawn's diagonal move to e
and, on the capture, clears the square one step behind without condition —
b[e-f|P-1?i:f^16]=0. The square one step behind is f^16: since in 0x88 the
rank sits in the high hex digit, the 16 bit turns the sixth rank into the fifth and the third into
the fourth; one XOR for both colours. The behaviour is the same in both board formats:
Entry (white pawn on d5, e = e6)
Result
A knight on e5 instead of a pawn
d5xe6 counts as en passant and the knight on e5 is removed
e6 not empty (a black bishop), a knight on e5
dxe6 takes the bishop and removes the knight on e5 too: two pieces in one move
A wrong e can only do harm on the first move: e is recomputed on every
move, and the value that came from the FEN drops away.
General validity. The engine assumes the position is legal. Beyond castling and en passant,
the loader has to guarantee: exactly one king of each colour; no pawn on the first or eighth rank;
the side not to move not in check. The engine only ever asks about the king of the side to move; if
the other king is in check it can be captured, and the engine's search for the king breaks.
The string build — prompt_string
The rules are those of the numeric builds; the differences are in the writing.
The board is in FEN's own order.b starts at a8 and runs in the order a FEN is
written: a8=0, h8=7, a7=8 … a1=56, h1=63. There is no need to reverse the ranks; the letters of the FEN
go in as they are, and each digit expands into that many '-'. Uppercase is White,
lowercase Black, an empty square '-'. There are no holes. A square's index is
(8 − rank) × 8 + file; the ones often needed are a8=0, e8=4, h8=7, a1=56, e1=60, f1=61,
h1=63.
Castling. Here too the ledger lives in G's properties; the same model with
mirrored indices: K → e1=60 and h1=63, Q → e1=60 and a1=56, k →
e8=4 and h8=7, q → e8=4 and a8=0. The validity conditions and the marking rule are exactly
the same. The pawns need no marks: this build still asks about the double step by rank
(y%6<2). The result of a wrong entry differs as well: White's false castling runs off
the board here. With the king on f1 (61) the “rook square” comes out as 64; asking for the type of
the piece there tries to uppercase undefined, a TypeError is thrown and the
loop stops — the move blows up already in L's trial.
En passant. When there is none, e is '-', not 8. The
conditions are the same, but with the indices mirrored the directions reverse: with White to move the
square is on row 2 (e>>3 == 2), the square behind it is e+8 and the
square the pawn came from e-8; with Black to move it is on row 5 and the signs swap. Here
d6's index is 19.
The side to move. In this build it is 1 and 0, not 8 and 0: it looks for the king with
'kK'[t], so t being a number is mandatory here too.
Examples
Every position below was loaded into both input and
prompt_string with the loaders that follow. The castling moves and en passant
captures on offer were checked by asking each build's own L; both builds gave the same
answer.
FEN
Situation
Numeric entry
String entry
What the engine offers
r3k2r/pppq1ppp/2nbbn2/3pp3/3PP3/2NBBN2/PPPQ1PPP/R3K2R w KQkq - 0 10
In the last two rows the loader also marks the corners of the invalid rights. With the king squares
already marked, those make no difference to the result; the table shows only the marks that decide.
The pawn marks in the numeric entry, for their part, shut the double step off for the advanced
pawns.
The loaders
The two loaders used for the examples. They write L1's globals directly.
First the numeric builds:
loadFEN=fen=>{
const [pos,side,rights,ep]=fen.split(' '), K={P:9,p:1,K:10,k:2,Q:11,q:3,N:12,n:4,R:13,r:5,B:14,b:6};
b=[]; for(let j=0;j<64;j++) b[j*2-j%8]=0;
pos.split('/').forEach((row,r)=>{let x=(7-r)*16;for(const ch of row)ch>'0'&&ch<='8'?x+=+ch:b[x++]=K[ch]});
t=side=='w'?8:0; z=0; Object.keys(G).forEach(k=>delete G[k]);
// a right is valid only if the FEN lists it, the king is on e1/e8 and its own rook is in that corner
const R=[['K',4,7,10,13],['Q',4,0,10,13],['k',116,119,2,5],['q',116,112,2,5]]
.map(([h,s,r,kk,rr])=>({h,s,r,ok:rights.includes(h)&&b[s]==kk&&b[r]==rr}));
for(const x of R) if(!x.ok) G[x.r]=1; // corner of an invalid right
for(const w of [8,0]){ if(!R.some(x=>x.ok&&(x.s==4)==!!w)) G[b.indexOf(2+w)]=1 } // king of a colour with no right left
b.forEach((p,j)=>p%8==1&&j>>4!=(p>8?1:6)&&(G[j]=1)); // a pawn off its starting rank// en passant: square on the right rank and empty, enemy pawn behind it, the pawn's origin empty
e=8; if(ep&&ep!='-'){const s=ep.charCodeAt(0)-97+(ep[1]-1)*16, d=t?-16:16;
if(!b[s]&&s>>4==(t?5:2)&&b[s+d]==(t?1:9)&&!b[s-d]) e=s}
}
In the clicking builds three things change. The ledger lives not in G but in
the board's table, u: the loader writes u instead of G; when
clearing, Object.keys still finds only the marks, because an element's own enumerable
properties are just the ones added to it later. The selection is cleared with
i=Y=-1 — in light above all, since there the initial selection
rests on a1 and on the rook there being unable to move. And light's render
flips the side to move on every call unless a promotion is pending: there t is written reversed and F()
is called, and the render brings the turn round to the right side.
The same rules in prompt_string's board format. With the board in FEN order,
the ranks need no reversing:
loadFEN=fen=>{
const [pos,side,rights,ep]=fen.split(' ');
b=pos.split('/').flatMap(row=>[...row].flatMap(ch=>ch>'0'&&ch<='8'?Array(+ch).fill('-'):ch));
t=side=='w'?1:0; z=0; Object.keys(G).forEach(k=>delete G[k]);
// the same validity rule with mirrored indices: e1=60, h1=63, a1=56, e8=4, h8=7, a8=0
const R=[['K',60,63,'K','R'],['Q',60,56,'K','R'],['k',4,7,'k','r'],['q',4,0,'k','r']]
.map(([h,s,r,kk,rr])=>({h,s,r,ok:rights.includes(h)&&b[s]==kk&&b[r]==rr}));
for(const x of R) if(!x.ok) G[x.r]=1; // corner of an invalid right
for(const w of [1,0]){ if(!R.some(x=>x.ok&&(x.s==60)==!!w)) G[b.indexOf(w?'K':'k')]=1 } // king of a colour with no right left// en passant: directions mirrored; '-' when there is none
e='-'; if(ep&&ep!='-'){const s=ep.charCodeAt(0)-97+(8-ep[1])*8, d=t?8:-8;
if(b[s]=='-'&&(s>>3)==(t?2:5)&&b[s+d]==(t?'p':'P')&&b[s-d]=='-') e=s}
}
Neither loader checks the general validity conditions; meeting them is up to whoever enters the
FEN. An interface that takes a FEN at the start and puts it through a full check exists in
Chess LUX.
Packing
The packed file
The playable files that carry the full arbiter range from 1.7 KB to 2.7 KB(1 KB = 1024 bytes), depending on their interface and on old or
modern markup.
There is one packed build: L3_packed, in the file FideLite.html, at 2,423 bytes. It is self-extracting RegPack
output — the file is nothing but a UTF-8 byte order mark and a single
<script>, with not one character of markup in front of it. The script's body is
an unpacking loop whose last step is eval(_). Put an assignment in place of
eval(_) and the file hands back its own source:
vm.runInContext(scriptBody.replace(/eval\(_\)$/, '__out=_'), c);
// c.__out → the packed source
What comes back is default itself: its script is byte for byte
identical to L3.html's. The one difference is where the markup lives. The HTML that
stands statically before the <script> in the plain file has moved inside the
script in the pack, into a single document.write call — just before
R=u[…], that is, before the code first touches u. The packed source is
2,679 characters, the plain file 2,710. The 31 characters in between add up like this:
<!DOCTYPE html> (15) and <meta charset=utf-8> (20) are gone, a
</p> (4) closing the promotion paragraph has come in, and the 17 characters of
the document.write`…`, wrapper cancel out the 17 of the plain file's
<script> tags.
A source of its own
In the new generation the pack has no separate source. What goes into RegPack is
default's script plus a single document.write that prints its
markup; every change that reaches the rule layer passes into the pack by itself, and nothing is
left to carry over or to measure separately. The naming and the signature are those of the plain
source as well.
Moving the markup into the script pays on its own. A pack that leaves the same markup
outside the script, static as in L3.html, comes to 2,489 bytes at RegPack's
cheapest setting: 387 bytes of static markup, 17 bytes of tags and 2,085 bytes of packed
script. The shipped pack stands at 2,423, 66 bytes shorter. Of those, 28 bytes come from
dropping the DOCTYPE and the meta — 35 bytes go, and the UTF-8 byte order mark (3) and the
</p> (4) come in. The remaining 38 bytes
are the packer's doing: the markup is now part of the text it sees, and its repetitions —
<p id=, onclick=E(, background: — are compressed together
with the script's. The same file with its static markup stripped of DOCTYPE and meta would come
to 2,454 bytes.
In the previous generation it was the other way round. The packed source was both
longer than the plain one and separate from it: the plain source was tuned for bytes, the packed
one for repetition — 3,266 bytes instead of 2,713. The difference fell under four headings:
aliases expanded, functions inlined, operators widened and obsolete markup. Producing both from
the same file did not work, because golf deletes every repetition, and RegPack feeds on exactly
that.
So the route had been turned round: the packed source was kept as it was — aliases expanded,
functions inlined, operators widened, naming — and every change that reached the rule layer of
the plain source was carried over into it one at a time. Each one's effect on the pack was
measured on its own:
Change
main
The pawn push through S()
−7
Castling through S() and !V(t)
−6
l: N rewritten
−1
l: w|=p
−2
l: the V*v gate
−8
I: square colour
−1
I's filter and the Im swap
−4
The en passant trick
−3
G: the g parameter
−2
x=T|b[f]not taken
0
All togetherbody
2,682 → 2,648
File
2,702 → 2,668
The rows are each change on its own (RegPack 5.0.1, 90 settings); the last two rows are
where all of them together land with 5.0.4 and 350 settings. In a
leave-one-out repack, taking any single change back out never made the pack smaller.
x=T|b[f] saves a byte in the plain 1x and is neutral here — not taken.
The measurement is calibrated: unpack the previous pack and repack it with its
own settings, and the published file comes back byte for byte — so the whole difference
comes from the source and the settings.
What stayed: the naming and the signature; the structure of the packed
source (Math.abs and indexOf expanded, Z inlined, colour
read with %2). What changed: the source you got back when you unpacked that
file was of the same generation as the plain build in its rule layer — the same moves,
the same verdicts, the same en passant squares and repetition keys.
l's new scan. After the table, one more change reached
the rule layer: an l that gathers the pieces by code into an array of bitboards
(h) in a single pass and builds the material gate as !(f^j^W^B^V^v)
instead of !w. It was carried over the same way, with two letters changed: in the
pack the bitboard of empty squares is d rather than j, and the
parameter of the callback that builds h is p rather than
_; so _ does not enter the source and stays in the token pool.
l's other local name is as in the earlier pack: E for Z.
The result with 5.0.4 and 350 settings: the body goes from 2,648 to 2,632; the file
from 2,668 to 2,652.
That generation's last step: the October shortenings. Eight more changes reached the rule layer
afterwards, and all of them were carried over the same way: the blocked scan's mapping
h[p>>2^2?!p*6:p-6], which sorts the pieces into sets, with its !q
gate; the 6.9 search's p&6 test; the castling rook's square
f+=k>0||-2; C=P=W in the starting masks; the square colour
i*9/8%2+1; the material weight p>9?p>11:9; the double-step test
y%6<2; and the node budget that counts every promotion piece on its own. The new
gate also dropped the old d from the destructuring. Each one's effect on the pack
was measured by taking it back out with the others in place: the blocked-scan gate −14, p&6 −3, castling
−2, C=P=W −2, the square colour and the weight a byte each; the double
step and the budget are neutral. None of them makes the pack larger. The result with 5.0.4 and
350 settings: the body goes from 2,632 to 2,615; the file from 2,652 to 2,635.
The previous generation's pack stayed at that size.
Aliases and inlined functions
In the new pack this heading has nothing under it: no alias is expanded, no function is
inlined, no operator widened; colour is read with &8, as in the engine. What
stays a function is exactly what is a function in default. The table
below and the measurements after it belong to the previous generation's pack; the alias rule,
though, is a rule of token economy rather than of that pack, and it still holds.
File
Alias expanded
Function inlined
Kept as a function
L3_packed.htmlprevious generation
a=Math.abs3 sitesN='indexOf'5 sitesN='innerText' — the one alias not expanded
ZIm
GVLCMIHlAFSd
H and l stayed functions; both were recursive, so inlining them was
not an option. The check test had the structure from after its merge with the attack
scan: both jobs sat in a single V, which stood as a function with eight call
sites.
The gain the packer once found — that the old check wrapper, the l of the
day, was a single call to the attack test and could therefore be inlined — was later
promoted into the source: the wrapper was deleted and folded into the default of
V's second parameter, and the letter l came free for the blocked-position
scan.
The same pattern shows up twice more in the speed section. The packer
and the fast variant are two separate searches for what belongs in the source; and what
either of them finds comes down, sooner or later. The new generation is the last link in that
chain: once everything the pack had found came down into the source, a separate packed source
was no longer needed.
Operators were widened wherever only the truth value was used: && went
from 28 to 39 and || from 15 to 19. In the engine, colour extraction was written
%2 rather than &1. Naming was preserved —
reassignVars off, and the globals on the same letters as in the plain source.
That expanding an alias paid was specific to Math.abs; it is not a general
rule. Going the other way landed in the same place — re-aliasing Math.abs cost 3
bytes, expanding l 11, expanding C 21, aliasing
b.indexOf 15.
The reason is token economy. A hand-written alias is a free abbreviation: it is already
short in the source and it takes nothing from the packer's one-character token pool.
Expand it and a long piece of text enters the source, the packer spends a token
compressing that text, and the token it spends comes out of some other pattern.
Math.abs was the exception because it occurred in only three places and its
expanded form merged with the text beside it — Math.abs( — into a longer
shared pattern than it would have made alone.
The rule: expand an alias only when its expanded form merges with neighbouring text
into a longer shared pattern. Aliases standing for a plain value used many times in
different contexts are already optimal, and touching them loses every time.
Three things the source must have
Line breaks inside literals must be escaped to \n. RegPack strips
the input with the pattern [\r\n]+\s* and does not check whether the break sits
inside a literal. Skip this and nothing throws — the output stays valid JS; only the render
of the board-drawing builds slips. default's source has no line breaks
at all — the board is split with <tr>s; the rule comes into play if a build
that lays out its render with line breaks, such as input or
nonbrowser, is packed.
A meaningful space inside a template literal must be written as \x20 —
when a line break precedes it. The same cleanup used to eat this space in the
previous generation's dom source: onclick=S(id) id=${w} →
onclick=S(id)id=${w}. The unquoted onclick value became
S(id)id=0 and swallowed the id attribute; not one of the sixty-four
squares kept an id, this[w] came back undefined, and the board was
never drawn at all. Under Node the tests never saw it, because document.write
there is a stub — it broke only in a browser. The fix was to write the space into the source
as an escape: <th width=54 onclick=S(id)\x20id=${w}>. RegPack does not count
an escape sequence as whitespace. It lengthens the source by three characters, costs the
packed file a few bytes, and leaves the generated HTML identical to the plain build's; whether
reassignVars is on or off makes no difference. The new pack has no such space: its
source has no line breaks at all, so no space in its markup — the attribute separators
included, as well as the two EM SPACEs between </label> and
<button — has a line break in front of it, and all of them come through the
cleanup unharmed. The EM SPACE is not exempt from the rule: the pattern's \s
covers U+2003 too.
Only JS may go into the packer. Pack raw HTML and the recovered source is no longer
JS that eval can run. There are two ways to deal with the markup: either it stays
outside the pack, static before the <script> — as in
DOM_911 and DOM_minimum — or it is turned into JS
and taken inside: L3_packed prints it with a
document.write.
Free letters
The four tables on the Engine tab say which letters are used. This one
says the reverse: the letters that never appear as identifiers in each file. The source of the
plain files was parsed and every single-letter identifier collected — the ids in
the markup as well, since named access makes them globals too; what follows is what was left.
File
Free letters
Count
engine.js
lwzEJKY
7
engine_4x.js
lwzEJKY
7
numerical
lDFJ
4
prompt
l
1
input
lZ
2
input_blindfold
lDZ
3
default
lZ
2
The count falls as you move from the engine without a front end to the drivers:
neither engine has an interface, and each has seven letters free; the drivers keep between one
and four, the fewest in prompt. Exactly one letter is free in all seven of
the files measured: l.
Being free as an identifier is not enough on its own, and the reason lies in the
packer. RegPack wants a letter for the unpacking loop's wrapper,
but it also picks its token characters from characters that occur nowhere in the
source. So there are two separate tests: the letter must be free as an identifier,
and must not appear in the file as a character at all. The two are not the same
thing.
Letter
Free in how many files
As a character, in the files where it is free
l
7
In all of them — slice, every, replace …
Z
3
In none of them — default, input, input_blindfold
J
3
In none of them — engine.js, engine_4x.js, numerical
D
2
In both — new Date
wzEKY
2
In none of them — engine.js, engine_4x.js
No letter passes both tests in all seven files. l is free as an identifier
everywhere, but it is everywhere as a character too: good for the wrapper, useless as a token.
The widest of the letters that pass both are Z and J, at
three files each: Z in the three drivers that draw a board in the browser —
default, input, input_blindfold —,
J in the two engines and numerical; there they appear neither as an
identifier nor as a character.
The pack sees this too: the token class of L3_packed, built from
default, contains Z.
In the previous generation that letter was J: it passed both tests in all seven
files. In the new generation J has moved into the drivers — the claim function in
default, the UCI parser in the text drivers — and is left free only in
the two engines and in numerical.
Token characters
RegPack picks its tokens from characters that never occur in the source. Control
characters are nearly free, since they fit into a single \u0001-\u001f range
inside the regexp class; the printable ones are counted individually. Every new character
entering the source removes a candidate from the pool, and every character leaving it adds
one.
The new pack's pool holds four printable candidates, and RegPack uses three of them.
" stays out, because it is the quote of the packed string. \ is
in: default's source has no escape sequences at all, so the
backslash stays in the pool — even though, needing an escape of its own inside the regexp class,
it costs two bytes and is the dearest candidate.
In the previous generation, dom held the live example of the other direction.
The \x20 written into the template literal had put a
backslash into the source, and \ dropped out of the pool that moment. A
three-character fix does not stop at bytes: it takes a letter out of the token alphabet too. In
the same generation, moving the render's parameter and the font family off _ in
default had made _ a token; the new
default uses _ for both.
File
Printables absent from the source
Token class
FideLite.html
" @ Z \
[\u0001-\u001fZ@\\]three of the four
L3_packed.htmlprevious generation
" @ J Z _
[\u0001-\u001f _ Z J @]four of the five
Which settings the packing uses
For all five packs, RegPack 5.0.1 and 5.0.4 give the identical file.
reassignVars is off: leave it on and RegPack picks the names itself; on
L3_packed's source that gains nothing, and the recovered source is no longer
default's text. In the previous generation the signature letters were the
first to fall. withMath and wrapInSetInterval are off too —
with(Math) makes the body 10 bytes longer — and useES6 is on. The only thing that varies is the crush tuple, and each file wants its
own: the values below reproduce that file byte for byte.
File
gain
length
copies
tiebreaker
FideLite.html
1.5
0.5
0.25
−1
DOM_911.html
0.5
1
0
−1
DOM_minimum.html
0
1
0.5
−1
Lichess-eqv_min.html
0.5
2
1
0
Chesscom-eqv_min.html
0.5
1
0.5
0
The first three columns are the weights of a single score — gain × saving + length × pattern length +
copies × copy count — so scaling all three by the same factor leaves the ranking unchanged:
0 / 1 / 0.5 and 0 / 2 / 1 give the same file. The tuple is really a ratio
plus a tie rule. Lowest.html has left the list: with the new engine it became a
plain, unpacked file. DOM_911 has taken the place of
DOM_996.
For the L3 pack, 2,187 combinations of the gain, length, copies and tiebreaker
factors were swept, then a finer grid around the best result. The shortest body is 2,403 bytes,
at 1.5 / 0.5 / 0.25 / −1, the setting chosen for L3_packed. RegPack 5.0.4 does not
go below 2,403 either, and at that setting the two versions give the identical body. The
previous pack's setting (1 / 0 / 0.25 / −1) reproduces the previous file byte for byte, and on
this source gives 2,413: 10 bytes behind.
File
5.0.1 best
5.0.4 best
Setting
FideLite.html
2,403
2,403
1.5 / 0.5 / 0.25 / −1
The figures are the packed body; the file adds the <script> tags (17
bytes) and the UTF-8 byte order mark (3 bytes): 2,403 + 17 + 3 = 2,423. The version does not
change the shortest result.
The cleanest illustration of why the tuple changes from file to file comes from the previous
generation: the engine-side p%2 → p&1 substitution. In the plain
builds it was free: the same length, and a measured 3.6% of speed. In the packed builds it
was expensive, because p%2 occurred eight to fourteen times in those sources
and RegPack compressed it into a single token; converting one occurrence dropped that token.
Measured: +3 in L3_packed, +2 in DOM_996,
+10 in Lichess-equivalent_minimum, +9 in
Chesscom-equivalent_minimum. No crush tuple recovered them — 360
combinations were tried per file — so throughout that generation the packed builds read colour
with %2. The same effect ran the other way once: in
DOM_minimum, turning the driver's b[f]%2^t into
b[f]&1^tsaved a byte — but only once the crush tuple moved from
1,0,1,0 to 0.5,0,1,0. The new engine reads colour with
&8 everywhere, and the problem went away by itself.
The two short circuits, in the en passant square and in the search's return, had also come out
byte-neutral on the packed side in that generation, so consistency with the plain builds
was free and both were taken.
What gets checked after packing
Packing makes the source unreadable, so there is no judging the output by eye. In the new
pack most of the checks come down to a single comparison, because the recovered source has a
plain counterpart:
Unpacking. A capture was put in place of eval(_), the source extracted
and repacked with the setting in the table: the body is byte for byte identical, with
5.0.1 and 5.0.4 alike.
Identity with the plain build. Take the document.write expression out of
the recovered source and what remains is byte for byte identical to
L3.html's script. The rule layer, the driver, the clock and the render are
default itself; no separate equivalence test is needed.
Signature.c=un=15,e=y=t=8 is in place in the header; it reads
c·un·e·y·t.
Generated markup. What document.write prints differs from
L3.html's static markup in three places: no <!DOCTYPE html>, no
<meta charset=utf-8> — the UTF-8 byte order mark does its job — and the
promotion paragraph is closed with </p>, because of
quirks mode.
Browser. In Chromium the document mode is BackCompat; no error or warning
on the console.
The previous generation's pack had a source of its own, so there equivalence had to be proved
separately. These were the checks run on that pack:
Unpacking. A capture was put in place of eval(_), the recovered
source extracted and compared against the input handed to the packer:
byte for byte identical.
Signature. All six of the c U N e Y t embedded in the source were in
place; not one of its letters had shifted.
perft. The seven CPW positions to depth four
(perft: the standard correctness test that counts every move sequence
from a position), all passed.
Rule layer. The old and new packed sources side by side: move lists (54 games,
8,269 positions), the castling stress test (241,920 G calls), l,
I and Im (4,000 positions) and the H search — budget
spent included, for both sides in 2,401 positions: 0 differences.
Lock-step equivalence. The new pack and the previous one were run through the same
sequence of inputs and clicks, and at every step b, e,
c, t, n, $, o,
z — plus the clocks, the selection, the
hidden state of the promotion picker and the status line — were compared.
5,969 steps over 38 games: 0 divergences.
Generated markup.default's document.write output was
identical to the previous pack's, down to the space inside
<th width=54 onclick=S(id) id=0>.
Anyone repacking should run three of these without fail: the signature,
the generated markup and the comparison of the recovered source with the plain
default. A smaller size on its own says nothing — a file that has lost
its signature also comes out a few bytes shorter.
The concessions — obsolete techniques and quirks mode
The engine side is modern: BigInt literals, template literals, default parameters.
Everything that has aged sits on the packed side and in the two Toledo files in
builds/special/ — and there too the reason is one word: bytes. HTML's abandoned presentational attributes are shorter than their CSS
equivalents, and browsers are not free to drop them: the specification still defines how they
are to be processed, because a large part of the web depends on them. Being obsolete does not
mean not working; it means the validator complains.
No obsolete element or attribute is left on the L1–L3
ladder. The plain builds never had any; those that draw a board write their markup statically
and leave the sizing to CSS. In the previous generation all of the weight sat in a single file,
L3_packed; the new pack prints default's modern markup as it
is, and all that remains are the packer's own tools: document.write,
with, eval. Obsolete elements and attributes now live only in
builds/special/. An obsolete technique is not a preference but something reached for
only when there is no room left — and the moment room appeared, first the plain build and
now the pack dropped all of them.
The obsolete ones
What
Where
Status
Modern equivalent
<center>
DOM_911's static prefix and the cells it writes; both Toledo files
Obsolete element
text-align:center
bgcolor
the cells DOM_911 writes; both Toledo files
Obsolete attribute
background
cellspacing
both Toledo files
Obsolete attribute
border-spacing:0
width, height
the cells DOM_911 writes (width=22 height=24); ToledosOpponentbyMe
Obsolete attribute
width, height in CSS
.bgColor
ToledosOpponentbyMe
Obsolete DOM property
style.background
document.write()
writing all the markup in L3_packed; Toledos-ES6-optimized
Deprecated
Static markup + innerHTML
with
packed only, RegPack's unpacker
Forbidden in strict mode
none
In the previous generation's pack all five of the first five rows were in
L3_packed — <center>, <body bgcolor=#ccc>,
<table cellspacing=0>, <th width=54>,
p.bgColor='#c91' — and the shortest modern equivalents would have cost
18 bytes in the source, before packing: <center> +10, bgcolor
+2, cellspacing +3, width +2, .bgColor +1. That did not
carry through linearly: a change both disturbs the repeat sequences the packer sees and,
for every new character entering the source, costs a slot in the token
pool. The cheapest measured packing of the plain L3.html of the day was
2,808 bytes2,373 of packed script + 418 of static markup + 17 of tags;
the shipped pack, made from its own source, sat at 2,635.
In the new pack that cost is zero, because the modern markup is already inside. The one
concession left is the DOCTYPE: the 15 bytes of <!DOCTYPE html> have to stand
outside the pack, before the <script> — a DOCTYPE printed by
document.write does not change the mode — and they land directly on the file: a pack
that opens in standards mode would come to 2,452 bytes. Dropping the </p> that
would then be unnecessary does not help: even at RegPack's cheapest setting the pack grows by
4 bytes (2,456).
document.write, with and eval
document.write() is the most contentious of the three. MDN marks it
deprecated outright and the specification itself carries a warning box: the method can
change the document's state while the parser is running, can produce a DOM that does not
correspond to the source, and erases the document if called after the page has loaded. The
intervention Chrome has applied since 2016 is narrow in scope: only for a user on a slow
connection, in a top-level document, does it decline to execute
parser-blocking <script> elements injected via
document.write(). FideLite.html writes markup rather than script,
and does it during parsing, so it falls outside the intervention. It does still block the
parser. The call is a piece of golf in itself: document.write`…` is a tagged
template. The method receives a one-element array, and the array, turned into a string, gives
back its content; two backticks instead of parentheses and quotes. Today's
L3.html does not call document.write at all — its skeleton markup is
static; the board, the promotion buttons, the two clocks and the status line are written
through innerHTML.
with is present in the packed file, but it is not code we wrote:
RegPack's unpacking loop uses the with(_.split(…)) pattern. The practical
consequence is that, since with is a syntax error in strict mode, the packed
files cannot be loaded as ES modules and cannot have 'use strict' placed at the
top. The plain builds carry no such restriction.
eval is not obsolete, but it is the last step of the unpacking loop. Its
consequence is a compatibility boundary: under a Content Security Policy without
unsafe-eval, the packed builds will not run. Rather than assume this, the
launcher on this page tests it — because the iframe is loaded from the same origin it
inherits the same policy, so the page's own test answers for the iframe too.
Quirks mode
The L1–L3 ladder holds 22 HTML files, and they fall into two
groups. Three of them — L1.html, L2.html and L3.html —
start with <!DOCTYPE html>, open in standards mode and pass the W3C Nu Html
Checker without a single error or warning: these are the modern-DOM builds that colour the
pieces and the target squares. The other nineteen carry no DOCTYPE, so all of them open in quirks
mode. That is not an oversight but a choice made for bytes; the validator reports errors for
the DOCTYPE, the <title> and unclosed or misplaced elements, and on the
packed form of L3 it adds hundreds more for the control characters in the
packed string. The ladder contains no obsolete elements or attributes; in the previous
generation they appeared only in L3_packed, and only in the markup it
generated at run time. For the three engine files — engine.js,
engine_4x.js and engine_string.js — and the four terminal builds
(.cjs), neither a DOCTYPE nor a rendering mode means anything. The files in builds/special/ are outside this count: most are
projects in their own right, the files of six separate repositories on
GitHub, and each
repository's README tells the details. The packed form, L3_packed, is
covered on this page, in the packing section.
One common confusion is worth separating out here: obsolete presentational attributes do
not belong to quirks mode.bgcolor, width and
cellspacing are attributes the specification defines as presentational hints,
and they behave identically in standards mode. Adding <!DOCTYPE html>
fixes not one row of the table above; the two are independent matters.
The first of the two classic quirks behaviours people reach for is the box
model. It is widely believed that the old IE model still applies in quirks; in current
browsers a table cell's width is content-box in both modes. Measured: with
<th width=54> and the default 1px padding the cell comes out 56 pixels
wide — 54 of content plus one pixel on each side, exactly the number standards mode would
give. Height is where quirks really differs: a cell's height includes its
padding. The light builds ran into exactly this. With height:22
leaving 20 pixels for content, a font whose piece glyphs are taller than 20 (such as the
Windows symbol font) made the ranks holding pieces grow, and the board stretched and shrank a
little with every move. The fix is height:24: 24 with the padding, 22 of content
— the old standards-mode size, at no extra byte. The second behaviour is tables not
inheriting the font from the body; that one is moot: the standards files give the cells a
rule of their own, and the light builds give the body no font at all, so there
is no different value to inherit.
The files that genuinely depend on quirks are the three light
builds. They lean on two quirks behaviours: lengths written without px
(width:22;height:24) and the height rule above; add a DOCTYPE and both break.
Parsing differs too: in standards mode a <table> closes an open
<p> by itself, in quirks it does not. That difference calls for an explicit
</p> in two files. In L3_light it is the clock paragraph,
<p id=h></p>: left open, the board falls inside it and is erased every
time the clock is written. In L3_packed it is the promotion paragraph,
<p id=p hidden></p>: left open, the board stays inside a hidden paragraph
and never shows, and is erased when the promotion buttons are written. The same markup in
L3.html, in standards mode, needs none of these four characters. Beyond that,
L3_packed shows no visible difference between the two modes: every length in its CSS
carries a unit, and the cells take their font from a rule of their own. Nor do the other quirks
files: the numerical and prompt families carry no
markup besides the script, and the input family has no CSS.
Build by build: mode, validator and legacy constructs
Extend the count above to every file, builds/special/ included, and this is the
table that comes out. The error and warning column gives the counts from the W3C Nu Html
Checker 26.9.27 with CSS checking on.
4–12 / 1 — the same, plus an unclosed table, button or style; in L3_light also placement, selector and unitless-length errors for the <style> in the body
none
modern
builds/, packed
L3_packed
quirks
253 / 8 — all but two of them control characters in the packed string
89–175 / 4–9 — almost all of them control characters in the packed string
a static <center> in DOM_911; in the cells it writes: <center>, bgcolor, width, height
with, eval
Toledo
Toledos-ES6-optimized
quirks
8 / 1 — <center>, unitless CSS values, a second <body>
<center>, bgcolor; cellspacing in the table it writes
document.write, setTimeout with a string
Toledo's opponent
ToledosOpponentbyMe
quirks
10 / 3 — <center>, bgcolor, cellspacing, a <style> in the body, unclosed elements
<center>, bgcolor, cellspacing; width on the cells it writes
.bgColor
What the validator counts is formal: none of the 36 files throws in Chromium or drops a
warning into the console. The document mode follows from the DOCTYPE; document.compatMode
matches it exactly in the 31 files where it can be read; in the other five the dialog reopens
every time it is closed, so the page stays locked and it cannot be read. The
hundreds of errors in the packed files come from the control characters RegPack uses as
tokens; HTML forbids those code points, and the browser reads them anyway. Obsolete elements
and attributes appear only in builds/special/, and mostly where the validator cannot
see them: in the markup the pack or document.write generates at run time. In Toledo
the validator stops after the second <body>, so it never reaches
bgcolor. Two things genuinely concern the environment: with is a syntax
error in strict mode, and eval and setTimeout with a string are
blocked under a content security policy that does not allow unsafe-eval.
Wrongly assumed obsolete
There are also usages frequently flagged in audits that are in fact current; they are
listed here so the question does not come up again.
innerHTML, setInterval, charCodeAt. None
of the three is obsolete. codePointAt is not a replacement for
charCodeAt but a method added beside it; in code that never deals with
surrogate pairs, charCodeAt is the right choice.
E.key. What is obsolete in keyboard events is keyCode
and which; neither occurs in any driver. That is not a coincidence but a price
paid: writing onkeyup=E=>E.which!=13 is 3 bytes per file shorter
than writing E.key!='Enter'. Because the hook exists only in the
input family, two files per level, 6 bytes per level were left on the table — prompt
and numerical take input through a blocking dialog and so need no event hook at
all.
Named access. An element id becoming a global variable is defined in
the specification, though explicitly noted as being “for compatibility”. It will not be
removed, because removing it would break countless pages. Here it is a deliberate lever: in
default the board is u, the status line x, the
promotion picker p, the clocks g and h, the draw radio
A; in the input family, u and x.
innerHTML and innerText. Both were born in 1997 as IE4
proprietary extensions and were standardised later — innerText after eighteen
years outside the standard, in 2016. Today both are current; neither is obsolete.
textContent, standard from the start, is the one option left unused, because it
is 2 bytes more expensive. Audits flag innerHTML reflexively, and rightly so:
it parses markup, so untrusted text reaching it means XSS. Here nothing reaches it. The only
driver that prints the user's own typing back to the screen is
input_blindfold, and that one already uses the non-parsing
innerText. default takes only clicks; no input text enters
input's render template either.
DOM0 event assignment. The onclick= attributes and the
onkeyup= assignment are addEventListener's predecessor, but not
obsolete. Their only limitation is allowing a single listener — a second assignment
overwrites the first.
The bytes that stay
There are three places in the plain builds' markup that look like surplus at first glance,
and all three were measured. Two of them save bytes but break the standards-mode builds' clean
pass through the validator. The third saves nothing at all, because the file stops working.
</script>: 9 bytes, but the script never runs. The script is the
file's last element, so its end tag looks unnecessary: the browser closes whatever is still
open at the end of the file anyway. It does close it, but it does not run it. When the HTML
parser reaches the end of the file while inside a script, it marks that element as
already started, and a script marked that way is never executed. Measured: with the last
9 bytes removed from L3.html and from the pack, Chromium shows an empty page; not
a single square of the board is drawn, and the console shows no error. The previous project's
notes listed this shortcut as “verified, works”; that was wrong. So 9 of the 17 bytes in the
pack's pair of tags are not up for negotiation.
<meta charset=utf8>: 1 byte, and the validator reports an
error. The Encoding Standard accepts utf8 as a valid label for UTF-8; Chromium
reads the page as UTF-8 and the pieces are drawn correctly. But HTML allows only
utf-8 in the charset attribute of meta, and the Nu Html
Checker counts this as an error.
</label>: 8 bytes, and the validator reports three errors and one
warning. Removed, it lets the ⚐ button fall inside ½'s label. Resigning still works: when
the clicked target is an interactive element inside the label, HTML cancels passing the click
on to the radio button, and ⚐ still resigns. But two independent controls become one element,
and the two EM SPACEs between them now belong to the label as well: a click in the gap opens a
draw offer. Measured; in today's file a click on the same spot does nothing. The validator's
objection comes from the same place: a label may not hold more than one labelable
element.
Pitfalls
Pitfalls and things to watch for
Every line of golfed code is a concession, and most of the concessions do not show. What
follows are the places anyone changing the engine or the drivers needs to know about: spots that
break silently when touched, or that are misread at first glance. All of them concern today's
0x88 engine and its drivers; every result shown was measured on the engine itself.
The engine's fragile spots
The b[i]<1 in S must not be shortened to !b[i].
The ray walker reads S=_=>(i+=s)==f||b[i]<1&&S(). !b[i] is a
byte shorter, and the previous generation took that shortcut; there it opened two narrow loopholes. On
0x88 it breaks the move rule itself, because exactly this difference is what holds the edge of the
board: a hole is undefined, undefined<1 is false and the walk stops
there; !undefined is true and the walk carries on through the hole into the next rank.
Measured: in the shortened engine the rook on h1 can go to forty-nine squares, reaching from
a2 to h8; normally it has nine.
The holes must stay holes. The board is a sparse array of length 120; the sixty-four
squares hold a code, and the 56 slots in between are never written. Two things hang on that. The
first is the wall above: in a driver that builds the board with Array(120).fill(0)
or reads it back from JSON, the holes become 0 or null, both
pass <1, and the rook on h1 can again reach a2 — and the holes themselves. The
second is sneakier: copying the board with [...b] or Array.from(b) turns
the holes into real undefined elements. The ray still stops, since the value is the
same; but map, some and every now visit those slots too.
V's bit position moves on one place per visit, so the ranks slide and the blocked half
collapses: in all three of three blocked positions V(0)&V(8) dropped from 1 to 0 —
the dead position is no longer recognised. The b.slice() that U uses
keeps the holes; that is the safe copy. Six calls to write a driver also covers
how the board is to be set up.
No other castling question may come between L and A. The
castling rook's square travels not as a parameter but through the global r:
G's castling branch writes it (r=f+=s-!~s), and U's recursion
reads it (U(r,Q)). Ask about another castling move in between and r
belongs to that move. Measured: with both castlings open, an engine that asks about e1–c1, then
about e1–g1, and then plays e1–c1 takes the king to c1 but moves the rook from h1 to d1; the rook
on a1 stays put. The same channel exists for promotion: in L's trial,
U writes to the global X whether the move is a promotion, and
default and H read it right after (X?E(X):…,
X?[3]:[3,5,6,4]). The rule is the same for both: for the move about to be played,
L must be the last L called. A driver that validates every move first and
then plays them in order — a PGN reader, say — walks straight into this.
U must change the board by copying it. Its first act is
(b=b.slice())[f]=…. A U that drops the copy to go faster and changes the
array in place defeats L's undo: as its backup L keeps only a reference
to the array (q=b) and writes b=q on the way out; since the reference
points to the same array, the trial move stays on the board. Measured: with an in-place
U, asking whether a1–a2 is legal moves the rook to a2. H's restore,
[b,e,n,t,c,k]=s, rests on the same assumption.
The double-step test also lets the first and last ranks through.G's pawn
branch recognises the starting rank with (i%96<24), which lets the first and eighth
ranks through alongside the second and seventh. In a legal game a pawn never stands on those two
ranks, so the difference never shows. But set up a position with a pawn on its first or last rank,
and a white pawn on the 1st rank or a black pawn on the 8th can advance two squares if the way is
clear — measured: a1–a3 and a8–a6 are legal — and that move can open an en passant square too. A
double step in the wrong direction falls off the board, so that is no problem. Such a position is
outside the engine's assumption of a legal position anyway: making sure no pawn stands on the first
or eighth rank is up to whoever sets the position up. L1's ledger asks
about the double step by square rather than by rank, so it does not carry this hole; its
counterpart in the FEN is the pawn marks.
V's blocked half was built only for positions that can occur in a game. The
rest of the engine — move generation, mate and stalemate, the counters, V's material
half — is independent of the number of pieces. As long as each side has one king, no pawn stands
on the first or last rank, the castling rights and en passant square are consistent with the
position and the side not to move is not in check, it works correctly at any occupancy up to 64
squares. The blocked half is different. The scan's advance, reach and saturation geometry does not
count material either, but its correctness was built for structures that can arise in play. In
artificial positions set up from a FEN that no game could ever reach — more than 32 pieces, say,
or more than eight pawns on one side — it cannot be guaranteed to work. That does not mean there is
a known wrong ruling; it only says such positions are out of scope.
H inherits V's limits. The 6.9 search itself is independent of
the number of pieces, and cautious: it does not call a draw when the budget or the depth runs out,
and it does not say “impossible” without seeing every branch close. But at every node it asks the
gate first — $[k]<=n||V(I)||O(…) — and if the gate says mate is impossible, that
branch closes there. So in artificial positions that no game could reach, H's ruling
can be no more reliable than V's.
H should only be called from inside F. Before the search,
F sets three things: $=[I=s] both empties the search table and records the side
in question, and N=2e3 refills the budget. A driver that calls H directly
inherits the previous search's table — answers given to another side's question — and whatever
is left of its budget. If that is zero, N-- returns zero on the first try, no branch
opens, and in every position the gate does not close the answer becomes “mate is possible”: a win
where a draw was due. After a search that has spent its budget, N is left at −1;
from then on N-- keeps returning negative numbers, which count as true, so the
budget never runs out and only the depth limits the search. Measured: in the two-knight position
with the counter at 146, F gives up after 2,001 tries. If H(9) is
called straight after and inherits the table as well, the root position is already recorded
there, so it answers “draw” at the first gate, in a single node, without searching anything.
If only the table is emptied, the budget left at −1 makes it unlimited, and it visits 3,654
nodes and proves the draw.
V leaks six globals. It gives birth to most of its names in its parameter
list, and those never get out; but W, B, _, f,
S and a are written as globals, and V is called from inside
Z after every move and from inside H at every node of the search. None of
today's drivers uses these six letters as a global of its own — checked with acorn — but a new
driver that did would have its value overwritten on every move. The same leak has a second face:
_ and f are the kings' bitboards, and V sets them while
scanning the board. With no king on the board they are never set; on the first call
V throws _ is not defined — measured, on a kingless board with pawns only — and
on later calls it works with the previous position's kings. In play there are always two kings;
but a driver or test harness that sets a position up by writing straight into b can
run into it.
The repetition table sits on G. The counts are not in an object of their
own but in the geometry function's own properties (G[k]); L1's
castling ledger lives there too. Wrapping G — to count or time its calls — or
redefining it silently wipes the table: the new function's properties are empty. Whoever wraps it
has to copy the old function's enumerable keys over to the new one.
The engine's two edges have to be carried over together. The clicking and the text
drivers cut the engine at two different places: in the clicking
builds L does not check the origin square and O filters out the
opponent's pieces itself (p&8^t||); in the text drivers L carries the
own-piece check and O's filter is dropped. A mix that takes L from one and
O from the other counts the opponent's moves as moves of the side to move. Measured:
with an unfiltered O next to the clicking L, Z() returns 0
instead of 3 in a stalemate — the game is taken to be still going. The same holds for promotion: the
clicking U reads the promotion piece from y, and H writes
y=p before each try; the text drivers' U takes it from a parameter.
b[i]
b[i]<1 (today)
!b[i] (shortened)
0 — an empty square
true → carry on
true → carry on
a piece code such as 5
false → stop
false → stop
undefined — a hole
false → stop
true → carry on
null — a hole come back from JSON
true → carry on
true → carry on
Places that are misread at first glance
D() with no argument leaves the offer bits alone. The offer step is
(o^=2-s/8)>2; with no s, 2-undefined/8 is NaN,
and o^=NaN is o^0, that is o. The upshot is “evaluate only,
without disturbing the bits”: it asks about the two pending offers, the claims (threefold
repetition, 50 moves) and Z, and changes nothing. None of today's drivers uses this form;
default carries its own J for the claim after a move.
In A's single expression the left-hand side runs first.R=G[M(i,f,p)]=-~G[k] looks as if it reads k twice. But an assignment
evaluates its target first: M plays the move and, as its last act, builds and returns
the new key, so the G[k] on the right already reads the new position. -~x
is the same length as x+1 but gives 1 on undefined, so the
same expression also creates the table entry the first time a position is seen.
The ply counter (the 50-move rule's counter) is reset in a single expression.n=!b[x]*b[i]&6&&n+1 contains no comparison and no branch. On a capture
!b[x] is zero and so is the product; on a pawn move b[i] is 1 or 9, and
neither has the bits of &6. The precedence is *, &,
&&: if either gives zero, n is reset, otherwise it goes up by
one.
O(_=>0) means “no legal move”.O(h) asks h about
every legal move of the side to move and returns true if all of them pass. With an h
that always returns false, a single legal move turns the result false; for it to come out true there
must be no legal move at all. That is exactly Z's mate-and-stalemate branch.
U clears the same square twice on most moves.b[i]=b[e-f|P-1?i:f^16]=0 looks as if it were written for en passant, and it is: when a
pawn lands on e, the second index is the captured pawn's square, f^16. But on
every other move both indices are i, and the assignment empties the origin square twice.
That harmless repetition is the price of not writing a separate en passant line.
The side parameter on resignation — the shortcut the drivers take
In the engine the call is F(s,r): s is the side that flagged or
resigned, r zero for the clock and one for resignation. Two parameters, and
s is independent of t.
Every shipped driver shortens that form. The default,
input and nonbrowser families write
s=g=>($=[I=t],N=2e3,H(9))?g+'M':'WB'[t/8]+g (nonbrowser_blindfold
calls the same helper D) — there is no side parameter at all,
and the side to move stands in for it. The prompt family builds the
same expression inline, and numerical produces its number codes with
$=[I=t] as well. No driver carries the engine's two-parameter form unchanged any
more.
The shortcut is correct in hot-seat play. In a game on a single screen the only person who
can act is the side to move: they are the one who presses the resign button, and the one who types
r into the input box. On flag fall it is correct unavoidably — a clock only runs on
your own turn, so the side that flags is always the side to move.
It is not correct in an online application. There both players are at the table at once
and the side that is not to move can resign at any moment. t then points at the wrong
player, and the error is not quiet: it produces the exact opposite result. Measured:
Case
Engine F(s,1)
Driver I=t
White to move, White resigns
RM
RM ✓
Black to move, Black resigns
WR
WR ✓
Black to move, White resigns
RM
WR✗
White to move, Black resigns
WR
RM✗
The position: White Ke1 + Qd1, Black a lone Ke8. White can mate, Black cannot. In the last two
rows the driver does not merely get it wrong, it inverts the ruling: it awards a win where a
draw was due, and a draw where a win was due.
The engine itself does not make this mistake. F(s,r) poses the question with
I=s, and H works through I from end to end; its leaf test
t^I|!T() takes the side to move into account as well. I measured the same position
with White to move and with Black to move — the engine's ruling is identical in both.
The rule for anyone porting the engine: unless your resign channel is tied to the side to
move, do not shorten F; use the engine's two-parameter form as it stands.
State variables vary by build
The state table describes the engine in general. Five things change from
driver to driver, and they are the first to break when code is carried from one driver to another.
The side's code is the same everywhere — t is 8 for White, 0 for Black, and the clocks
are keyed by that value too — but the place, the unit and the bit layout are not. The two builds
without a player layer also part ways on the claim thresholds: L2 ends the
game only on fivefold repetition and 75 moves, which come by themselves, while
L2_aybars_2500 also ends it on threefold repetition and 50 moves — with the
reasoning in the Indicators section.
What
In the engine
In the drivers
The clocks
None — the engine keeps no clock
default, light, the input family: the element on screen, u, in seconds, u[0]=u[8]=600. The prompt family: G[0], G[8], seconds. The nonbrowser family: the line-reading function u, seconds. numerical: a plain object, u={0:9E5,8:9E5}, milliseconds, no increment
The repetition table
G[k]
default, light, the input family: u[k], on the same object as the clocks. prompt, numerical, nonbrowser: G[k], as in the engine
o — the offer bits
D: o^=2-s/8 — White 1, Black 2, both 3
The prompt family as in the engine. default, light, input, nonbrowser, numerical: o^=t||1 or o^=g||1 — White 8, Black 1, both 9; after a move, o&=t?1:8 in the clicking builds and o=0 inside the move in the text drivers
The clicking builds: the promotion piece — the picker and H write it, U reads it. numerical and the blindfold builds: the text of the last move. Elsewhere: signature only
The two layouts of the offer bits must not be mixed. The engine's D tells
that both sides have offered by >2; carried into a driver that uses the 8/1 layout,
a single White offer (8) counts as a draw by agreement. The reverse holds too: in the 1/2 layout
>8 never arises, and a draw offer is never accepted.
The flag test depends on the unit too. The drivers look for the flag on the clock of the
side to move, but with different thresholds: numerical uses
u[t]<1, the prompt family G[t]<=0, and the
rest the negation of >0. This is not a matter of style.
numerical keeps its clock in whole milliseconds, so the value is either zero
and below or at least one; there is nothing in between, and <1 is both a byte shorter
and safe. The others keep fractional seconds; had <1 been written there, a player
with 0.9 seconds left would lose on time before the flag fell. So <1 is
only correct on a whole-number clock; change the unit one day and the flag starts falling early,
silently.
The promotion panel is a third way in
It is easy to assume the board in default has two entrances: clicking a
square and pressing a button. There is a third, and it hides well. The four buttons of the
promotion panel are wired straight to the move function in the markup —
<button onclick=E(y=3)> — so they never pass through K, which handles
the square clicks. No guard placed on K can stop them.
Here is the hole that opens. When a pawn move to the last rank is chosen, the panel appears and
the game waits for the promotion choice. In that window the game can end by another route:
the flag falls, resignation is pressed, or a draw offer is accepted with the radio already set. Then
the player clicks the queen. With E unguarded the move would be written to the board
and z overwritten with the new verdict, on top of the finished game's result;
the result would vanish from the status line and play would carry on.
The guard is E=P=>z||…, 3 bytes. Resignation and the draw go through
E as well, so the same 3 bytes hold them too: pressing the buttons again after the game
is over does not change the result.
The panel's second gift is worse. Were it possible to click another piece while the panel is
open, the selection would move there but the panel would stay up; press the queen afterwards and
E would play a move whose i and Y are the same square — and
because U writes b[f]=… before b[i]=0, the piece would
evaporate. What stops that is the p.hidden&& at the head of K:
while the panel is open, square clicks are not processed at all.
The clicking builds of L1 and L2 close the same
hole differently, and more cheaply. Instead of p.hidden=0 they write
p.hidden=z--: with z at zero, z-- returns zero and leaves
z at -1, so opening the panel marks the game “over” at the same
instant and K=u=>z||(…) locks itself out. It costs nothing, because those builds have
neither clock nor buttons — nothing but the move function ever sets z, and that
function has no z|| guard, so the panel's button still works. Which is why only the
second half of this trap was reachable there. The same trick would not work in
L3: E's own guard would lock the panel too, and the
z||F(j()) loop that drives the clock would stop.
The BOM and the invisible spaces
The files that carry non-ASCII glyphs do not all declare their encoding the same way. The plain
board-drawing builds — L1.html, L2.html, L3.html and the two
with a bot — open with <!DOCTYPE html> and carry
<meta charset=utf-8>; they need no BOM and have none. The full-width letters of
the prompt family are not in the file at all but generated at run time with
fromCharCode(…+65248); like the input and
numerical families, the file itself is pure ASCII. The packed
FideLite.html, on the other hand, carries glyphs but neither a DOCTYPE nor a meta:
the BOM at the head of the file is the only thing declaring the encoding, and that is why it is
needed. Remove it and a correct reading is left to the browser's guesswork. The two Toledo files
under builds/special/ are in the same position. The light builds
write the pieces as ɥ… entities; L1_light and
L2_light are therefore pure ASCII, while in L3_light only ½
and ⚐ remain and the file declares no encoding at all — it relies on detection;
Chromium detects UTF-8 correctly for a local file.
The second trap is subtler. In the render string the character for an empty square is not an
ordinary space but a non-breaking space: the first character of
' ♟♚♛♞♜♝'. L1.html, L2.html, L3.html and the
pack hold the character itself — the escape (\xA0) is four bytes, the character two.
In builds/special/, L1_1250elo holds the character too, while
L2_aybars_2500, L2_base and
L2_string_base still write the escape. Convert it to 0x20 and it becomes
collapsible whitespace in the markup, the cell counts as empty and the ranks without pieces
collapse. Measured: in L3.html the board drops from 448 pixels to 232, and the four
empty ranks of the starting position from 56 pixels to 2. An editor that “cleans up an unnecessary
character” does this all by itself, and there is no longer an escape protecting the plain
builds.
The same trap sits in two more places; in both, the gap is opened not by CSS but by an
invisible space character. The first is between ½ and ⚐ in L3.html and in the
pack: two EM SPACEs (U+2003). CSS does not collapse this character either,
and each one is as wide as the font size: together they take 44 pixels, where the space and the
margin-left:36px they replace took 41.5. The plain file gains 12 bytes from it, the
pack 11. In the three special builds, where the type is larger, the mix was chosen per file:
in Lichess-equivalent an EM SPACE and a four-per-em space
(U+2005, 45 pixels, identical), in Chesscom-equivalent an EM
SPACE and a six-per-em space (U+2006, 46.7 pixels instead of 46), in
LUX a single EM SPACE (54 pixels instead of 55.5). They gain 12, 12 and
15 bytes.
The second is the status line. In L1.html, L2.html,
L3.html and in L2_aybars_2500, L2_base,
L2_string_base, Lichess-equivalent and
Chesscom-equivalent, each gap between the coordinate, C!,
M= and R= is an EM SPACE followed by a four-per-em space
(U+2005). The word-spacing:1em that was removed added one em to an
ordinary space; in this font an ordinary space is a quarter em, so the pair gives the same
width — 27.5 pixels in L3.html. The CSS loses 17 bytes and each gap brings back 5:
L2.html and L3.html, with three gaps, gain 2 bytes, and
L1.html and the two equivalents, with one, gain 12; the pack gains 3.
L2_aybars_2500's line used to begin with a space, which CSS swallowed at
the start of the line anyway; an EM SPACE is not swallowed, so that space was taken out of the
string.
Convert these characters to ordinary spaces and the adjacent ones collapse into one.
Measured: the gap at ⚐ drops from 44 pixels to 5.5, the gaps in the status line from 27.5 to
5.5.
The signature
The signature itself is explained on the Engine tab: read in order, the
names assigned in the header spell c·un·e·y·t. This is the maintenance side: which files
carry it, and what must not be touched.
b=[c=un=15,e=y=t=8,n=o=0]
// c un e y t → c·un·e·y·t
The files that carry it: engine.js, engine_4x.js,
engine_onlyMoveGenerator.js, the nine L3 drivers and the pack,
every L1 and L2 build except the letter-board ones, and
in builds/special/ the plain Lichess and Chess.com equivalents, LUX
and the two L1-level bots (L1_1250elo,
ToledosOpponentbyMe). Those that do not: the
previous generation with the letter board — the prompt_string family and
engine_string.js, whose declarations follow the order of the FEN
fields rather than the signature — plus the other files in builds/special/.
Two letters are there only for the signature: un is never read anywhere (3 bytes),
and the engine never reads y (2 bytes). In L1 the same goes for
c: the castling ledger lives in G, there is no castling mask, and the value
in c=un=e=y=t=8 is never read. y, on the other hand, is a live letter in the
drivers: the promotion piece in the clicking builds, the last move in numerical
and the blindfold builds. Its starting value matters in none of them, because it is always written
before it is read; a driver can take y for its own use and the signature still
reads.
The signature belongs to the text of the declaration, not to run time. Anything that changes the
order of the chain breaks it silently: an e=t=y=8 written in search of a byte, a
minifier that reworks the names or the order of the assignments. Dropping un= and
y= gains five bytes and loses the signature; not one test would notice.
Shadowed letters
The places where the same letter is two different things are listed below, and today none of
them is a bug. Most are parameters: they stay inside their own function and never touch the global
outside. The rest are the engine and a driver using the same letter at different times. The two are
never alive at once — by the time one sets its value, the other has long finished its work.
Letter
On one side
On the other
A
The engine's function that applies a move; in V, first a material tally, then the squares the white pawns can reach
The draw radio in default and light, id=A
u
One rank's shift in V, 256n
The board element in default, light and the input family — the clocks and the repetition counts live on it too; the line-reading function in nonbrowser; the clock object in numerical; K's parameter
x
The step in G, the king's square in T, the destination and then the check result in L, the destination in M, O and H
The status-line element in default; the input box in the input family; the read buffer in nonbrowser
p
The piece code in callbacks, the promotion piece in U, the promotion tried in H
The promotion-picker element in default; the picker's string in light; the cache of the moved piece in the blindfold builds
P
The piece type in G and U
The move function's parameter in default and light; the = suffix in input_blindfold
g
V's saturation loop
The lower clock element in default; the side that moved in the text drivers, g=t; s's parameter
h
O's callback
The upper clock element in default and light; the destination square in prompt_blindfold
m
The attack-mode flag in G, material in V
The string of the last board drawn in default
s
The signed step in G, the colour and then a bitboard in V, the saved state in H, the side in D and F
The helper that builds the flag-fall and resignation result in the default, input and nonbrowser families
j
The square index in callbacks; one square's bit in V
The function that runs the clock in default, light and the input family; the read buffer in nonbrowser_blindfold
i
The square parameter in C, G, U, L, M, V — in most of them the origin square
The selected or typed origin square in the drivers, a global
k
The position key
The order of the two clocks in default's render, (E,k)=>
w
The timestamp that runs the clock, w=new Date
The cell index in the same driver's render, (p,w)=>
F
The engine's flag-fall and resignation verdict, F(s,r)
The render in default, light, input, nonbrowser; the flag or resignation letter in prompt, 'T' or 'R'
D
The engine's draw function, D(s)
The board square the render computes in the board-drawing drivers, D=w^7<<t/2; the lettered D in prompt; the helper that builds the flag-fall and resignation result in nonbrowser_blindfold
Z
The engine's verdict
The function that draws the clocks in light; the lettered Z in prompt
E
The move function in default and light
The clock element in the render, (E,k)=>; the keyboard event in the input family; a timestamp in prompt; the = suffix in prompt_blindfold and nonbrowser_blindfold; the flag-or-resignation bit in numerical
J
The claim function in default
The piece glyph in light; the UCI parser in the text drivers
K
The square-click handler in default and light
The raw input text in the text drivers
y
A signature letter; the engine never reads it
The promotion piece in the clicking builds; the last move in numerical and the blindfold builds
r
The castling rook's square written by G, a global
The resignation flag of F(s,r) — inside F's body only; the H and U it calls see the global
_fS
In G: the ray walk's unused parameter, the destination square, the ray walker
The globals V leaks: the white and black kings' bitboards, one of the saturation sets
coHNn
The castling mask, the offer bits, the 6.9 search, the node budget, the 50-move counter
V's locals: the wrap mask, the side's own material, the sideways spread, a king step, the rounds left — in Names
Most of the table sits on the boundary between engine and driver, and most of it arises in the
clicking builds, because they are the ones that carry markup: u, x,
p, g, h and A are ids, and named access
makes them globals. The densest shadowing inside the engine is in V: ten names are born
in its parameter list, and most of them sit on top of some other job in the engine.
Named access brings a trap of its own. default does not carry the engine's
A; it applies the move in a line of its own, which is why the name A is left
to the draw radio button. A change that adds the engine's A to default
overwrites that name: the assignment creates the window's own property and shadows the element.
Measured: after A=(i,f,p)=>… is assigned, A.checked=true is written onto
the function and the radio button is not selected. The same goes for u, x,
p, g and h; the engine uses them only as parameters, so today
they do not collide.
For globals, safety depends on timing. y is written by both the picker and
H, and read by U; X is written by U and read by
default's click handler and by H; r is written by
G and read by U. As long as the reader runs right after the writer, there is
no collision. That makes this list not an exemption but a constraint: anyone adding a global
to a new driver should check this table first.
Renaming
Changing a letter is not as mechanical as it looks; a blind search-and-replace breaks things in
three places at once.
String literals. Places that look like letters but are not identifiers: the file labels
'a b c d e f g h' and 'h g f e d c b a' in input and
nonbrowser; the 'C!' indicator in default,
input, nonbrowser and prompt, and
'D?' in the last three;
the result codes — the W and B in 'WB'[t/8] are the same letters
as the globals V leaks —; the 'T' timeout code and the ${…}s
seconds suffix in the prompt, input and
nonbrowser families.
The id= in the markup. Every letter that is written into the markup has to be
renamed there too; if it is not, the named-access binding breaks and the global stays
undefined.
Shadowed letters. The same letter may be doing two separate jobs — the
shadowing table says which letter is what, and where.
The right method is to parse the source with acorn and change only the identifiers, doing the
id= migration separately by hand. That way the literal and template fragments are never
seen at all. But an Identifier node is wider than an identifier: object keys and dotted
member names are Identifiers too. The b in the text drivers' promotion table
{r:5,b:6,n:4} is not the board b but the bishop's letter in UCI; change
b in every Identifier and that changes too, and a promotion to a bishop
silently turns into a queen. The same goes for r and n. Non-computed member
names and object keys have to be left out.
After a change
The perft runs all look to an external measure: the measure is FIDE itself,
and the engine's history is irrelevant. Once the source is touched, though, the question changes —
not whether the rule is right, but whether this change broke something. The measure is now the
previous build. Five checks serve that:
Byte neutrality. After a rename, the file size must not change.
Structural equivalence. Parse with acorn and compare the node structure; the only
difference should be identifier names, and no Literal and no object key should
change.
Internal differential. Load the old and new builds in separate vm contexts and
play random games; compare b c e t n o k R and L(i,f) for every pair of
squares at every ply.
Render verification. In jsdom, the text, colour and outline of all sixty-four
cells, the status line and both clocks.
Reversibility. When unpacked, the packed output must give back its source byte for byte,
and that source must match the plain default apart from the
document.write line (What gets checked after packing).
Perft is not on this list: if move generation was not touched it is not needed, and if it was,
all of the runs above are started over anyway.
An example showed why this matters: a one-byte shortening that removed castling's short circuit
slipped past both the internal differential and the CPW positions, and was caught by “Castling
Prevented” in the tricky suite at depth 2. Random games do not produce rare positions. The harness's
loader taught a lesson too. It ran the engine statement by statement and counted the ones that threw
as interface lines; as a result, an engine with a syntax error in its H passed sanity
3/3. Interface lines only throw while running; a statement that cannot be parsed is never an
interface line. The loader now stops at such a statement and says which definition is broken.
Simulations
Simulations I used while coding
While writing the engine I used many programs at every stage; of those, I am sharing the
three I think are most useful for chess programming in particular. The first two are about
how the board and movement are defined: Board_Delta_Coordinate tries defining a move by coordinate difference (distance, and h and v),
and Board_Delta_Index tries defining it by index delta on boards of different sizes. The third, Chess960_Skeletons, may not be needed at all,
depending on how an engine is built; where it is needed it does its job, and even where it is
not, it helps build an intuition about castling. All three are standalone HTML files; they do
not use the engine and are not part of the claim. The links open in a new tab.
Board_Delta_Coordinate — moves defined by coordinate difference
A simulation that puts two ways of defining a move by the coordinate difference between two
squares side by side: collapsing the difference into a single distance, or keeping its
horizontal and vertical parts apart. In distance mode, clicking a square on the 8×8 board
writes out the Euclidean distance from it to every other square, both as a decimal and in
radical form, like √5 or 2√2. Rook, bishop, queen, knight and king come as ready-made groups;
each group carries its own distance values and, for comparison, the piece's real movement
rule, and you can build new ones.
Squares where the distance matches and the piece really can go are filled with colour;
squares where the distance matches but the piece cannot go are hatched. That is how the 3-4-5
triangle impersonating the rook's fifth square, and (1,7) impersonating the bishop's fifth,
show at a glance. Why the distance approach was dropped is in
Roads tried and eliminated.
The h / v mode reads the board by splitting it into a horizontal and a vertical
axis. I used the engine this way for a long time, and the string builds, which hold the board
as letters, still do. It is cheap in bytes, and most of those bytes go into writing the
definitions rather than the tests: Math.abs, i%8 and
i>>3 turn up again and again, to get the file and the rank and from them
the relative horizontal and vertical coordinates. Each square shows two numbers: h, the
horizontal difference, and v, the vertical difference, both absolute. Squares on
a line also show d = h|v, the number of steps. On a line either one of the two is
zero or both are equal, so a single OR is enough.
Once the definitions are in place, the tests shrink to a few bytes. Instead of distances,
groups carry a condition on h and v: the knight is h*v==2, the king
d<2, the bishop h==v, the rook h*v==0. The queen gets
no definition of its own; any square that passes the rook's test or the bishop's is a queen
move. You can write your own conditions too. By geometric necessity, none of these tests lands
on an invalid square, whatever the size of the board. h and v are real file and rank
differences, so nothing wraps around the edge. The index delta is another matter, and it is
the subject of the next simulation. That is also why the 3-4-5 triangle and (1,7), hatched in
distance mode, do not light up here.
A simulation for testing moves defined by index delta on boards of different sizes. Every
square carries its own index; click one and it becomes the reference, the squares your
deltas land on light up, and each target shows the delta that reached it. Four board types
come ready: a flat 8×8, 0x88 (16×8), a 10×12 mailbox and 16×12, with sizes written columns × rows
as in the literature. Each opens with the knight, king, bishop, rook, queen and pawn deltas
for that board and frames the real 8×8 inside it on its own. Switching between types keeps
the same piece on the same square, so the four boards can be compared one to one.
Every target is marked on its own: squares the delta meant to reach are filled with
colour, squares reached by wrapping around the edge onto the wrong real square are hatched,
and deltas that fall into the padding and are rejected are crossed out. On a flat 8×8 you
can see directly how +1 wraps from the h-file to the a-file of the next rank,
and where +7 / +9 and the knight's deltas land at the edge. On
0x88 the same deltas fall into the right half and fail the i & 0x88 test;
the guard lives in the bits of the index itself. On 10×12 they hit the sentinel border;
there the guard is not a bit but the value in the cell. 16×12 joins the two: the deltas are
0x88's, the guard is the 10×12 sentinel border. The table below lists every step
with its index, square and result, and the rows that come out wrong are red. In free mode
you set rows, columns and deltas yourself and can frame your own padding with the area
selection. Your settings are kept in the browser. FideLite now uses the 0x88 layout from
these, but without the i & 0x88 test, in a shorter form: the off-board cells
are simply left as holes in the array, and a sliding piece stops when it hits one. What this
layout buys the engine is on the “The engine itself” tab.
In Chess960 the pieces on the first and eighth ranks are arranged differently, but under
two rules: the bishops must stand on squares of opposite colour, and the king must stand
between the two rooks. These two rules make 960 setups possible. From the point of view of
chess programming, though, the number that matters is 56.
The only rule that sets Chess960 apart from the code of standard chess is castling. The
pieces move the same way; legality, mate, stalemate, repetition and the counters are the
same. The starting setup is not a rule but data. Castling does change: the king and rook
always end on their standard-chess squares (the king on the g- or c-file, the rook on the f-
or d-file), but the path conditions come to depend on the files the king and rook start on.
Because castling looks only at the files of these three pieces, and the king always stands
between the rooks, the 960 setups reduce for castling to C(8,3) = 56 skeletons.
Chess960 can now be played in LUX, among the Special builds: at the
start you type the position number (0–959) or roll the die; its rules are in
section 10 of the Rules tab. Because castling was hard-coded in earlier
versions, they gathered into 56 skeletons: I generated the 56 skeletons one by one with a
formula and obtained all 960 setups from them. In an algorithm with a
generous budget, free of Golfstack's byte limit, a single generic version covering them all
could be written; even so, I think whoever writes it will run into the number 56
somewhere.
The page shows each skeleton on a card: an eight-square strip, the skeleton written as
-R--K-R-, and every Scharnagl number built on it. The orthodox start, #518
(RNBQKBNR), is marked.
A chess puzzle and a chess variant. Neither is part of the claim, and neither uses the
engine; each is a single standalone HTML file. They are not embedded here — the links open
in a new tab.
ParrotPuzzle — a puzzle against a parrot
A single-player chess puzzle. You play White; your opponent is a parrot. The first game is
a free warm-up: checkmate Black, and take your time. Nobody tells you how the parrot plays, though
it is fairly obvious :) Still, working that out is the first step of the puzzle, so it is not
written here either. The real challenge starts with the missions.
Once the warm-up is over, ten missions open. They vary in difficulty, each one makes you
look at the game from a different angle, and some are genuinely hard. They are all against
the same parrot; what
changes is the goal and the move limit: mate in four, missions built on castling and en
passant, stalemating the parrot, even getting the parrot to mate you. Every failed attempt
unlocks one more hint. Once a mission is solved it explains why the solution works and can
play it back on the board; for a few of them an exhaustive search shows there is nothing
shorter. The interface is in English and Turkish, and pieces move by dragging or
tapping.
A chess variant. The pieces move as you know them, with one difference: you may leave your
own king in check, and the game ends not on mate but when a king is taken. Nobody tells you
that you are in check, and the board does not show it; spotting it is up to you. There is no
stalemate, no insufficient material and no dead position; a game is drawn only by the 50-move
rule or threefold repetition.
Your opponent is an owl: it plays standard chess and has never heard of this variant. Once
the first game is over, seven missions open, and as in ParrotPuzzle every failed attempt
unlocks one more hint. There is a two-player mode too, and a presentation walks through the
variant's edge cases step by step. The interface is in English and Turkish.
This section has nothing to do with FideLite. The first two are programs you can use
today; the last three are features I have not yet seen on the large platforms. What the five
share is that each belongs inside the chess sites themselves, not in a separate tool or
browser extension.
CHA-Solver — the right ruling when a flag falls
Miguel Ambrona's open-source solver. Given a position and the side expected to win, it
either finds a helpmate line ending in that side's mate or proves that no such line exists.
That is exactly the question FIDE 6.9 asks: the player
whose time runs out loses, but if the opponent cannot mate by any series of legal moves the
game is drawn. Resignation (5.1.2) asks the same question, and a dead position (5.2.2) asks it
for both sides at once. The algorithm was published in a peer-reviewed paper at FUN 2022, and
there is a web page where you can enter a position and see the verdict. The image below
shows how it works.
Platforms, and almost every chess engine, leave this rule out of their algorithms; at flag
fall they settle for looking at a fixed list of pieces. Yet outside that list, in blocked
positions and in positions that lead by force to a draw, a considerable number of wrongly
decided games have been found. Ambrona offered the tool to Lichess ↗️ as a
solution; it was not integrated, and today it still stands on its own.
To my mind the problem is not only the games decided wrongly. In a blocked position the
game is in fact over, but with no algorithm to notice it, the players keep playing and their
time is spent for nothing. I have no doubt that detecting this on every move can be optimised
so that it causes no friction at all on a modern platform. For platforms that can run
Stockfish at its highest levels, the processor budget it takes would not even be noticeable to
a human. It belongs on every platform: a wrong ruling at flag fall is not taken back once the
game is closed. The detail is on this site under Flag fall, resignation and the
possibility of mate.
Just as Google Lens reads the text in a photo, Chessvision.ai reads the board in a
screenshot, a book page or a video frame: it turns the position into a FEN and opens it on an
analysis board. It comes as a browser extension, a phone
app and a reader for PDF books; instead of setting a position up square by square, one click
carries it onto the board. The image below shows how it works.
This should not be a separate extension but a button built into every platform's analysis
board: paste the image, get the position. One detail should not be skipped: an image carries
only where the pieces stand. The side to move, castling rights, the en passant square and the
counters are not in the picture; a good tool should ask the user for them rather than
guess.
For anyone who follows chess on social media and YouTube this is especially practical.
Almost none of those pages give the position as a FEN, and in many apps, YouTube and
Instagram among them, the text cannot even be copied. Even where a FEN is written out, going
straight from the image to the FEN does in one step what would otherwise take several, such as
running Google Lens on the text first. For someone who wants to solve puzzles one after
another, the difference is bigger still. It should definitely be added.
On the large platforms the board can only be turned 180° today: you look from White's side
or from Black's. A sideways view, 90° or 270°, does not exist. Yet apart from the pawn and
castling, no rule of movement depends on direction: the knight draws the same L every way,
and the rook and bishop go the same way in all four directions. So a mating pattern can form
with the same shape on any of the board's four edges; all that changes is the angle we see it
from. The image below shows this.
Rotation is not a change of rules but a way of looking. It changes neither the machine nor
the rules, so it places no demand on engines at all. What it eases is human intuition: mate
patterns and some positional shapes are mostly learned in one orientation, and the eye is
slow to recognise them on another edge. A pattern built on a side edge, like Anastasia's
mate, looks like a familiar back-rank mate once the board is turned 90°. That is why I find
it useful, and I think many people would choose it.
Move markers shaped by the piece
When a piece is selected, the platforms mark the squares it can go to with the same dot for
every piece. A dot says where a piece can go, not how it gets there. Yet that difference is the
rule itself: a bishop's or a rook's path can be blocked, a knight's cannot, because the knight
jumps. The three boards below, in Chess.com's colours, show how the difference looks once the
marker carries the piece's character.
The bishop reaches along its diagonals with a spear, the rook along its lines with a thick,
trapezoid-tipped arrow; every square on the way carries a small bump. An arrow that meets one of
its own pieces ends on the last empty square; one that reaches an opponent's piece stops at the
ring around it. The knight leaves a hoof print on every square it can jump to, and the queen
carries the rook's arrows and the bishop's together. A dot only points at a place; a spear, an
arrow and a hoof print picture the move itself, and speak far more to imagination and
intuition. Like rotation, this is not a change of rules but a way of looking.
Bishop — spear
Rook — trapezoid-tipped arrow
Knight — hoof print
A theme of your own
The platforms offer a wide choice: piece sets, board colours, 3D views, themes for special
days. But they are all ready-made, themes the platform picks and hands out. Using pieces or a
board you drew yourself is possible today only through browser extensions; the number of
extensions written for exactly this shows the demand is real. Players should be able to put
their own creativity to work: make a theme of their own, use it, and share it with others on
the forums. With built-in support the theme would stay with the account and look the same in
the browser and on the phone.
The image below is not a screen theme but a real, physical chess set made on a Lord of the
Rings concept. It makes the same point, though: the more creatively the board and pieces are
interpreted, the more beautiful, artistic and fantastic the work that can come of it. None of
this touches the rules of chess or how the game works; it only adds colour and depth.
There is an accessibility side too: someone with colour blindness, low vision or a need
for high contrast could build pieces and square colours they can read comfortably. Because a
theme applies only on its owner's screen, it does not touch the fairness of the game either;
the opponent always sees the look they chose.