The world's smallest chess game that enforces every official rule.
In front ends ranging from 2.7 KB down to 1,815 bytes.
It runs in the browser and is written in JavaScript; no libraries, no install, no server.
The single HTML file you download opens on almost any device.
The entire starting position: 64 hexadecimal
(base 16, written with 0–9 and a–f) digits, a1 through
h8 in order. Each digit is one square, each colour one piece type. The engine's
board representation is nothing more than this.
“Perfection is reached not when there is nothing left to
add, but when there is nothing left to remove.”Antoine de Saint-Exupéry
a84♜56
b8c♞57
c82♝58
d86♛59
e8a♚60
f82♝61
g8c♞62
h84♜63
a78♟48
b78♟49
c78♟50
d78♟51
e78♟52
f78♟53
g78♟54
h78♟55
a6040
b6041
c6042
d6043
e6044
f6045
g6046
h6047
a5032
b5033
c5034
d5035
e5036
f5037
g5038
h5039
a4024
b4025
c4026
d4027
e4028
f4029
g4030
h4031
a3016
b3017
c3018
d3019
e3020
f3021
g3022
h3023
a29♟8
b29♟9
c29♟10
d29♟11
e29♟12
f29♟13
g29♟14
h29♟15
a15♜0
b1d♞1
c13♝2
d17♛3
e1b♚4
f13♝5
g1d♞6
h15♜7
Every square carries four things: its name, the piece's
hexadecimal code, its glyph (the piece's Unicode symbol — ♞, ♛),
and the index the engine uses.
The index starts at zero on a1, rises left to right, wraps from
h1 to the a-file of the next rank up, and ends at 63 on h8. What that layout buys the engine
is on the engine itself tab.
In the L3 numerical build the indices are shifted up by
one to sit closer to player intuition, so each is one more than the internal index:
a1 = 01, e4 = 29, h8 = 64.
Golfstack
The code itself, its worth, and the craft of Golfstack
A complete, tournament-grade chess arbiter packed into a single HTML file of
2.7 KB (the measure of how much room a file takes on disk;
1 KB = 1024 bytes). Built on the Golfstack idea, the project is an engineering exercise that pushes
code optimisation to its limit: the most function in the least space.
Two players share one device, and the engine delivers a fully featured game of chess
without reaching for a single external library.
Nothing is missing on the rules side. The whole FIDE book is implemented. You can find
how each article behaves, one by one, on the
Official rules of play tab. Even in the thorny scenarios where
resignation and flag fall meet insufficient material, the ruling follows 5.1.2 and 6.9
exactly. On the blocked-position branch of 5.2.2 every ruling the engine makes is correct:
a position it calls dead really is dead. Measured coverage is 93%; the remaining 7% are edge cases involving a bishop. Anything it does not recognise, it plays on — so
the margin of error always falls on the safe side.
Technical features
An interface close to a modern site. In the L3(the build you play by clicking the board) build the board is
clickable: the selected square and its legal targets are marked, a promotion picker opens,
the check indicator sits on the status line and a pending draw offer highlights the draw button. After each move the board
flips to the perspective of the player to move — that flip is in the L3 input and
L3 prompt builds too. The piece glyphs, clock symbol and indicators in
L3 all come from Unicode, with no image file, font download or CDN request.
The other builds draw the board in ASCII or full-width characters; the blindfold build and
L3 numerical draw nothing at all.
Fischer time control and a readable clock. The time control is 10+5: ten
minutes, plus a five-second increment added to that player's clock on every move. Both clocks run on screen in
MINUTES : SECONDS with their emoji, and the increment visibly lands on each move. The moment
a flag falls, the result follows by itself.
Move generation. The engine does nothing odd where chess logic is concerned: an
illegal move never passes, a legal one is never refused, and no result code fires in the
wrong position. The claim rests on measurement —
the engine passes
the standard chess-programming test suites without error to reasonable
depths: the CPW positions, van Kervinck's tricky list, and the 6,838-position Vajolet
corpus.
Rule accuracy and game endings. When a game ends it does not just say “draw”; it
names whichever of the fifteen results actually occurred. The codes
are two characters yet mnemonic — you can tell at a glance which letter stands for what — and all
fifteen map one-to-one onto articles in the FIDE handbook. Stalemate, 3-fold repetition,
a dead position: never left ambiguous.
The hard half of the draw. Beyond ordinary move generation an arbiter has two
questions to answer: can mate still be reached in this position at all? and
when the flag fell, or when one side resigned, was the opponent in a position to
mate? Each has a cheap half and a hard half. The cheap half is counting material:
is there enough left on the board to mate with. That is the common one, and the engine
does it too.
The hard half does not come from counting pieces. A position can be dead with
material to spare: the pawns have locked each other, nothing can move, and no mating
sequence exists even with both sides cooperating. At flag fall the real question is not
“does the opponent hold enough material” but “can a mating position be constructed on this
board” — 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. The engine implements both halves.
And the hard half costs less than you would think. A
blocked position needs no move search; it falls out of the static
picture of the board — the squares the pawns and kings can reach are grown until none of
them grows any further, and if none does, the position is dead. Measured coverage is
93%, and every case it misses involves a bishop; the engine never calls a position
dead by mistake, it only fails to see some. In other words nearly every blocked position
that actually occurs can be caught for very little
(another 1 KB would bring in almost all the bishop cases as well,
taking 93% to 99.94%; I decided to draw the line here for now).
Robustness. Neither a bug nor invalid input nor deliberate abuse can derail the
engine. In L3 bad input never arises in the first place: moves are made by
clicking and only legal squares are marked. In the text-input builds the engine rejects
anything it does not accept and simply asks the same question again — including empty,
nonsensical or intentionally malformed input. The game never locks up and the state is never
corrupted; where the notation is valid but the promotion letter is not, the piece promotes to
a queen automatically.
A modular structure. The parts are not fused together: the rule layer stands on
one side, what the interface does on the other. Take out the draw offer, the clock or the
resign button if you want to — everything else carries on working, and the same goes for
automatic draw limits like 5-fold repetition and the 75-move rule. The starting position —
from a FEN, if you like — the time control, even the threshold of the repetition counter can
all be set. Code golf chess rarely draws these lines: check is not detected by a test of its own, move
generation and evaluation run through each other, and you can no longer tell where the rules
end and playing the game 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 — as
far as the byte budget allowed, every variable and function name was chosen to echo its
English counterpart. Real care went into this, down to the distinction between upper and
lower case: readability was preserved wherever Golfstack permitted it. What actually happens
is unpacked line by line in a separate section.
Speed and depth. Most of an arbiter's work is over in a single half-move: the
legal moves of the side to move, and whether the game has ended. The hard half of the draw
falls outside that — there the engine really does look ahead, and it does so during the
game, at the moment a flag falls, not in testing. Speed is therefore not decoration. The
second place it counts is testing: perft (the standard correctness test
that counts every move sequence from a position) counts millions of nodes, where
seconds turn into minutes. engine_4x serves both — a
variant that enforces the same rules, 48 bytes longer and logically identical; between 2.4×
and 9.7× on move generation depending on the workload, and a great deal more on the
adjudication layer. Where those bytes go, and the measurements behind them, are
under a separate heading.
The bot is a separate layer. In the main build two people play; the engine does
not suggest moves, it runs the game. The claim is about rule density per byte, so no bot is
required — a build where two people play comes out cheaper than one driven by a bot picking
moves at random. And because the rule layer stands apart, putting a search on top does not
disturb the core: there is a separate build with a bot, one that leaves the clock and the
interactions between players out and keeps the rule side in place. Its measured strength is around 2400 Elo; the evidence, the tests and further bot variants are still to come.
The next question is how much Elo fits into a single 10 KB HTML file, rules and interface
included, on a reasonable processor budget — the target is 3000, and whatever the result, it
will be published in full.
The real claim is not these items one by one, but the ratio itself: bytes per unit of
service. Everything above comes out of a single downloadable file of 2.7 KB.
The engine was tested thousands of times during development, edge cases included, and
every known bug was cleared. Even so, if something catches your eye during a game, report
it; feedback improves the engine directly.
Play
Pick a build, start it
Each build runs in its own frame, and the clocks only start ticking once you start it.
Changing builds closes the running game.
If the board feels small in the L3 and L3 input builds, use the
browser's zoom — Ctrl+, or ⌘+ on macOS. The
game scales along with the frame; Ctrl0 restores it.
Two of the eight builds — L3 prompt and L3 numerical — take moves
through a prompt() dialog and announce the result with alert(). If
your browser blocks those dialogs — most offer a “prevent this page from creating dialogs”
option after the second one — the game stops silently. If you have blocked them, reload the
page and allow dialogs again. The remaining six builds use no dialogs at all.
Selected build L3. Start the game or show its code.
Ready
The story
Carving the marble — a chess universe fitted into bytes
Code golf is the art of writing a program in as few characters as possible without
giving up anything of its function, its rules or its logical correctness. There is one
constraint: the behaviour stays the same, the volume shrinks.
From beginning to end this project chased a single question:
What is the smallest number of bytes a complete,
100% FIDE-compliant game of chess can take, skipping no rule from first move to last?
The answer is below. But to ask the question at all, I first had to decide where the
word “complete” begins.
What can we remove and still have chess
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 pre-fifteenth-century
chess as the starting point — there the bishop leaps exactly two squares, the queen moves
one square diagonally, and there is no castling and no double pawn step. They share a name,
but that game and the one we play today are fundamentally two different identities; taking
it as the baseline would have meant answering the question for a different game.
What I took as the starting point was this: today's piece movement, check, checkmate and
stalemate, the pawn's single-square advance, and automatic promotion to a queen. That is the
core left over once everything removable has been removed. Take one more article away 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 did not arrive in one piece; it grew through overlapping rounds.
Choice of promotion piece came first, releasing the pawn from its sentence to the queen
and making knight, bishop and rook options too. Then the pawn's two-square advance, and with
it the en passant capture that has to come along — the two are inseparable, because the
second closes the hole the first opens. Then castling. At that point what was happening on
the board was chess as we play it today.
After that I moved off the board. The two theoretical limits that keep a game from
running forever, the 50-move rule and the clock, 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 of position, insufficient material, the draw offer and the draw
claim, flag fall, resignation. That brought in the rulings an arbiter makes without ever
looking at the board.
I cannot claim this path was 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 threw out the entire solution I had written
up to that point. Every reversal meant a structural revision and then a fresh 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. Which
rule broke what, which discovery forced a rewrite of what — I will tell that step by step
and in far more detail in a separate “Build log” section.
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. What the rules were was clear from the outset; the work
was carving away every excess that described them.
Claude Shannon's information theory tells you where that carving ends: every message has a
lower bound below which it cannot be compressed losslessly, and any saving past that bound
is no longer a saving but a loss of information. Code golf is exactly the walk toward that
bound. The difference here is that the compression has to be lossless: drop a single article
from the logical data the rules require and what is left is not a smaller chess; it is
something that is not chess.
The answer to the question is therefore a single number: 1,815 bytes.numerical_packed.html arbitrates an official game between two human players
from the first move to the last.
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 which device, which operating
system and which environment it will open in, the browser comes first by a wide margin. No
compiler, no runtime install, no package manager; double-clicking the downloaded file is
enough. On top of that HTML hands you the visual layer almost for free — no separate UI
infrastructure is needed for the board, the clocks and the indicators, and the file even
opens when <html>, <head> and <body>
are never written at all. If the distance the engine had to cover to reach a player were any
longer, the 2.7 KB claim would mean nothing in practice.
The second half is about the language itself. JavaScript suits Golfstack far better than
you would expect: it requires no variable declaration keyword, it imposes no mandatory
skeleton of entry point and imports, and implicit coercion means the board's numeric
encoding never forces you to write a cast. Arrow functions, the ternary operator and the
comma operator working together let an entire function collapse into one expression with no
braces and no return. Self-extracting packers are native to this language too;
the packed form of the engine rests directly on that.
An objection follows from this: the browser is hundreds of megabytes, and it supplies
both the JavaScript engine and the runtime. Isn't 1,815 bytes just a play on words, 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 nor the operating system nor the processor goes on the tab.
Cross-language comparison is done on this measure, and record attempts are open to
any language.
Anyone who takes the end user's view and wants a stricter measure is also right: same
environment, same language, zero libraries. Both are legitimate, and under both a single
file stands on this side of the line — nothing downloaded, nothing bundled, nothing
added.
The “smallest in the world” claim rests on that measure. I have been looking for a good
while and have not come across anything smaller that enforces every official rule, in
JavaScript or anywhere else. 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: why is there no AI opponent in the main build?
The usual route is clear enough. A board is drawn on the screen, the pieces are set out,
and the player takes on a bot that plays a little better than random; the great majority of
attempts so far have run along that line. 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, 3-fold repetition. Bot logic inflates
the code considerably; a build where two people play is far cheaper in byte budget. And
adding a bot contributes nothing to FIDE compliance.
The real issue is measurability. What counts as a bot is vague: is the thing that fits under 100 bytes and plays random but legal moves a bot? Is one that counts material a
bot, or is the threshold higher? Who decides? Rules are not like that; they are exact.
Whether a piece of code implements an article is visible on inspection and leaves no room
for argument. I tried writing a bot along the way too; then I put all my energy into the
side that can be measured.
The two stand apart in the code as well, and that is no accident. The engine core knows
nothing about the driver, so putting a search on top does not touch the rule side. That is
exactly how the build with a bot came about: the same rule layer, the clock and the
interactions between players left out, a search on top. Its measured strength is around 2400 Elo.
I do not consider it finished, because the criterion I have set for
bringing a bot here is the same one the arbiter engine had to meet: it has to be a
masterpiece in ratio of bytes to quality. That is a longer job — but since it is a hobby I
enjoy a great deal, I will make a concrete attempt at a record on that side too.
What remains is what the measurable side bought: at 1,815 bytes this build is
ahead of the large platforms on the hard half of the draw. The common practice, even
where pure FIDE is applied, is to stop at the material test: is there enough left on
the board to mate with. The side that looks at the position itself — whether a mating
position can be constructed once the flag has fallen, and the detection of
blocked positions — is not common. And that has been measured:
CHA-Solver scanned a
large public game database end to end and counted around 200,000 wrongly decided
games — 50,000 of them from blocked positions, the remaining 150,000 from
the question that goes unasked at flag fall. Chess.com applies a
FIDE-USCF hybrid for some articles; not a shortcoming but a
choice, blending the rules of two federations deliberately. The choice here is a different
one: one book, one measure. On those same articles the engine is therefore bound more
tightly to FIDE.
Beyond the bytes
Chess projects in the code-golf world generally compromise on the 50-move limit,
3-fold repetition and insufficient material — often on castling and en passant too — and
focus on the basic core alone. I looked at the side hardly anyone visits, and I think that
is exactly where the project's originality came from. As far as I know, Golfstack is the
world's smallest complete arbiter engine able to fit the full 100% FIDE standard into a byte
space this narrow.
It is not a commercial product and makes no such claim. But layering all the rules of
chess this way and fitting them into the fewest bytes is pure engineering, pushing the limit
on the code side.
Getting to this point I had substantial help from models like Claude and Gemini. Work
that would normally take months — debugging, code optimisation, picking apart browser
behaviour — I compressed enormously with those tools. The decisions stayed mine; what I
gained was speed.
As Chekhov put it: writing long is easy, writing short is the hard part. In this project
I tried to turn that vast world of chess into its shortest form without losing anything of
its function.
FideLite — what the name and the logo mean
The name has two parts: FIDE + Lite. Together they land on the sound of
the French fidélité — fidelity, adherence. Which is precisely the point:
the file shrinks, the adherence to the rulebook does not. “Lite” is the lightness,
“Fide” the federation; the two at once, an arbiter that discards 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-jumps
Rook — eight turquoise squares, four rays
Bishop — eight lilac squares, two diagonals
Queen — every rook and bishop square
King — the crimson frame, a one-step range
Pawn — the square its single-step advance lands on: the FIDElite wordmark
Rules
The rules the engine enforces
1. Piece movement, legality and king safety
Lines of movement. Rook, bishop and queen slide along their own lines; the
king one square in any direction; the pawn advances along the file and captures diagonally;
the knight jumps in an L.
Clear path. For every piece except the knight, no other piece may stand in its path.
Legality of the target. The destination square must be empty or hold an enemy
piece; no move may be made onto a square occupied by one's own piece.
King safety. No move may leave one's own king under attack.
2. Special moves
Castling. A two-piece move available when king and rook have never moved, the
squares between them are empty, the king is not in check, and the square it crosses is not
attacked. Moving a rook forfeits that rook's castling right; moving the king forfeits the
right on both.
En passant. Capturing an enemy pawn that has just advanced two squares as though
it had advanced only one, taking it diagonally. The right lasts for that move only.
Promotion. A pawn reaching the last rank is replaced by a piece of its own
colour other than a king or pawn — queen, rook, bishop or knight.
3. Checkmate and stalemate
Checkmate. The king is under attack and no legal move removes the attack. The
mating side wins.
Stalemate. The king of the side to move is not under attack, but that side
has no legal move left. The game ends in a draw.
4. Resignation, flag fall and insufficient material
Resignation and flag fall. A player who resigns or whose time runs out loses by
rule.
Dead position. Where checkmate is geometrically impossible by any sequence of
legal moves, the game is drawn at once: K vs K, K vs K+B, K vs K+N, and K+B vs K+B with all
bishops on squares of the same colour. The number of bishops plays no part: however many
either side holds, if they all stand on one square colour no mate can be constructed.
Blocked positions with only kings and pawns left on the board are detected as well.
Resignation or flag fall when mate is impossible. The player who resigns or flags
normally loses; but if it is geometrically impossible for the opponent to deliver mate by
any legal moves, the game is a draw. Article 6.9 covers flag fall and 5.1.2
resignation — the latter was added in 2023.
This exception does not ask “can the opponent force mate?”; it asks “can a mating
position be constructed with this material?” Because the losing side can help by
shutting off its own king's escape squares with its own pieces, the defender's material
counts too — the mechanism is the same one as in a smothered mate.
Material
Ruling
Lone king
Insufficient
A single bishop
Insufficient
Bishops all on one square colour
Insufficient
A single knight
Insufficient
Bishops on both square colours
Sufficient
Bishop + knight
Sufficient
Two knights
Sufficient
Any rook, queen or pawn
Sufficient
Mate with a lone knight can only be constructed if the defending side owns a piece to wall
itself in with — 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. What makes a
defending pawn sufficient is promotion: the article allows any series of legal moves, so the
pawn can turn into the piece that does the shutting in.
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
Yes
After promotion
K + B
K + N
Yes
♚♞♝♚
K + B
K + B opposite colour
Yes
♚♝♚♝
K + B
K + P
Yes
After promotion
K + N + N
K
Yes
♞♚♞♚
These are constructible positions, not forced wins. 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. Dead positions that do not rest on material — a locked
pawn chain, say — are detected separately, as long as only kings and pawns remain; seeing
those took a search, not a scan.
The second half of 5.2.2: blocked positions
FIDE article 5.2.2 reads as follows: 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.
The form of this condition that arises from insufficient material is fully implemented;
the two tables above describe exactly that. The second form — blocked positions,
where the material is sufficient but mate is impossible all the same — is now implemented
too, in part. When only kings and pawns are left on the board, the engine decides whether
the position is blocked. Measured coverage is 93%; every one of the remaining 7% involves a bishop.
The direction matters as much as the number: there are draws it misses, but none it
invents. Every position it calls dead really is dead. Anything it does not recognise it
plays on, so the margin of error always falls on the safe side.
♚♝♟♟♟♟♟♟♟♟♚♝
Material is more than sufficient, mate impossible. White's pawns sit on all
the light squares of the fourth rank, Black's on all the dark squares of the fifth: the
light-squared white bishop can never cross the fourth rank, nor the dark-squared black
bishop the fifth. The remaining squares are held by the opposing pawns, so the kings stay
in their own halves too. No pawn can move and no piece can be captured.
There are two bishops and eight pawns on the board in this position; no material test
calls that insufficient. Yet even with both sides cooperating, no mating sequence can be
constructed — it is a dead position in the sense of 5.2.2. The engine cannot see this, and
the game runs on until the counters fill — the blocked-position detector's scope is kings
and pawns, and the two bishops on the board switch it off.
k1b5/1p1p4/1P1P4/8/8/1p1p4/1P1P4/K1B5 w
The diagnosis was this: a blocked position is not revealed by any inference from the board
layout or the state variables. The rest of the engine is not like that — everything else can
be settled with geometry and arithmetic, in a way that is both sound on every diagnosis and
complete across every case. Here a scan was not enough; it took a search.
The search was written. Eight bitboard masks grow the squares the pawns and kings can
reach, step by step; when no mask grows any further, the position is blocked. Sixty-four
squares and eight masks put the worst case at 512 steps, and the loop
stops at 600 — so the bound is proved, and the search is never cut short. 488 bytes in total.
That search has been done.
CHA-Solver decides with
mathematical certainty whether a position is winnable; the algorithm was published in a
peer-reviewed paper, it is open source, and it has been run across the entire Lichess database. As of
8 August 2026, when this was written, it has identified around 200,000 games that were decided
wrongly, 50,000 of them from blocked positions and the remaining 150,000
from the question that goes unasked at flag fall. So soundness is a solved
problem — the answer it gives is never wrong.
What is not solved is completeness, and the difference is not a fine one. The
tool's own documentation says so plainly: complete on every known legal position, real or
constructed, but not proven complete in theory — and it invites you to find a position it
cannot resolve. That is not a flaw; it is the kind of problem this is. Induction rather than
proof: CHA too kept refining its implementation against the counterexamples that arrived
after the claim, closing each position it had missed. In the other articles of the book the
boundary is drawn by proof; here it is drawn by measurement.
An extension covering nearly all of the bishop cases was designed and measured: it would
have taken coverage from 93% to 99.94%. Even golfed it came to close to 1,000
bytes in plain form, and it was shelved.
The arithmetic runs like this. The detector in place buys 93 points for 488
bytes; the extension a further 7 for 1,000. The return per byte falls by a factor of 27. And what it buys is a subset of a position class that is already rare —
the exception to an exception. In an engine of 1,935 bytes 1,000 bytes is more than
half the program again; packed, it would take numerical_packed.html from 1,815
bytes to about 2,600.
And 99.94% is still not 100%. It is a measured figure, not a proved bound — 1,000
bytes does not buy completeness, it only pushes the shortfall into the decimals.
So the rule is shelved, not lost. Size and certainty are both measured on the
engine itself tab.
How long the miss actually lasts has been measured too. In the position below each side
has exactly one legal move — the white king shuttles between a1 and b1, the black king
between a8 and b8, both bishops are shut in by their own pawns, and no pawn can move at
all:
k1b5/1p1p4/1P1P4/8/8/1p1p4/1P1P4/K1B5 w
The engine cannot see this as dead, because there is a bishop on the board. But the game
ends on the sixteenth ply by five-fold repetition: the same position returns every
four plies. The code that comes out is 5R rather than DP, and both
are draws. The cost of a blocked position going undetected is not a hundred and fifty plies
but, more often than not, sixteen — being blocked is what makes mobility collapse, and that
is why the repetition arrives early.
The target was never 100%, and could not have been. It was to cover more than 90% of the
blocked positions that actually occur and to say plainly what was covered; the measured
result is 93%. The engine now declares a draw only where it is certain. What is common on the large
platforms, even those independent of any federation, is to stop at the material test.
That is the whole of the deviation. The two exceptions for impossibility of mate, at
resignation and at flag fall — 5.1.2 and 6.9 — are implemented in full, and each is reported
as a result of its own.
5. Move and repetition counters
One player's move is a ply; a move by each side together is one full move.
Repetition of position. For two positions to count as the same, the placement of
the pieces, the side to move, the castling rights and the availability of a legal en passant
capture must all match exactly. If even one differs, the position is unique.
The 50- and 75-move counters. They advance as long as no pawn moves and no piece
is captured; either event resets them.
By claim. A draw is awarded if 3-fold repetition or 50 full moves (100 plies)
is claimed.
Automatic. 5-fold repetition and 75 full moves (150 plies) are declared without
waiting for a claim.
♜♚♟♟♚
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, the availability is
never set either.
The “availability of a legal en passant capture” condition in the repetition key wants
that availability to be genuinely legal. The engine sets the square only when the
capture is actually playable — not automatically on every pawn pushed two squares.
The distinction is critical, because rejecting the move at the last step in the check
test 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 repetition counter:
availability is part of the position key. Set it while it is illegal and two positions
FIDE counts as identical produce different keys, so the third repetition fires late or
never fires at all.
The consequence can be a game that should have been drawn ending in a loss. Even though
the engine refuses the move, setting the flag would still be a rule violation — which is
why the unwanted edge case does not arise here. Because the test is done with the legal move generator itself, the cases
where the capture exposes one's own king are closed by the same test.
6. Draw claims and agreement
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 declares a move and states that the rule will be
satisfied once that move is played.
Draw by agreement. After making a move, the player to move offers a draw to the
opponent; if the offer is accepted the game is drawn.
Lapse of the offer. An offer loses its validity as soon as the opponent plays a
move that does not accept it.
Checkmate takes precedence. If a move results in checkmate, the mate stands even
when automatic draw conditions such as the 75-move rule or a 5-fold repetition arise on that
same move. The same holds for claims: a draw suffix appended to a move is ignored if the
move delivers mate. The instant the mating move is played, the game ends in a win.
Federation difference: FIDE and USCF
How a game may end varies considerably by federation. Under USCF rules resignation is
always a loss, whatever the material. There is no such thing as an automatic draw: 75 moves
and 5-fold repetition produce no result, and a draw is awarded only if the 50-move rule
or 3-fold repetition is claimed.
The real difference appears where flag fall meets material, and the two federations ask
different questions. FIDE asks: is mate possible even if the opponent plays the worst moves
available? That is the helpmate, the geometric possibility. The answer to that question can
be both sound and complete; it can be derived from a one-dimensional board array alone —
which is exactly what this code does. USCF asks something else: does the opponent of the
player who flagged have a forced mate?
Up to a point the two give nearly the same result. But USCF can produce unwanted edge
cases by calling a lone knight, a lone bishop, and sometimes even two knights insufficient
material outright. Chess.com goes the other way on that article: if the player who flags
faces a king and two knights, the result is a loss, not a draw. Over the board, spotting a
forced mate is easy; achieving the same accuracy in a digital algorithm requires a search.
Without one, cases such as a mate delivered by a single knight go undetected.
♟♚♞♚
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.
Three questions on one board, two results. In the position alongside, FIDE and USCF
agree: mate is both possible and forced, so if Black's flag falls White wins. Chess.com
asks a third question — can this material mate a bare king — and since a lone
knight cannot, it
awards the draw.
The test is position-independent, fixed-cost and gives the same answer in every game; at
server scale that is a defensible choice, and it spares them explaining the helpmate
ruling to a beginner. The price is this: the player who sees the loss coming can run the
clock down and reach a draw instead. The engine here makes the trade the other way round —
it reaches the same verdict from the array of 64, without a search.
Builds
Eight front ends, one engine
All eight use the same core. The differences are in the input format, in whether the board
is drawn at all, and in the clock. Sizes are in bytes and cover the whole shipped file: the
<script>
wrapper, the 28-byte markup prefix of the L3 input family, and the BOM in the
files that carry glyphs are all included.
If you are going to copy the source off the screen and paste it into a text file, there
is one thing to watch: the L3 family and L3 prompt carry chess
glyphs, so the file has to be saved as UTF-8. In Notepad, picking UTF-8 from the
dropdown in the save dialog is enough — choose the variant with a BOM and the encoding is
read from the file itself, with no guessing left to the browser. L3 input,
L3 input_blindfold, L3 numerical and the three .js files
are pure ASCII; there it makes no difference how you save them.
The measurement is the same in all five. The difference is in the rounding:
L3 rounds the remaining time up, so 0:00 appears only once the
flag has actually fallen, while the others that show seconds truncate. In the
L3 input family the same rounding would have cost 10 bytes, since the two clocks
are printed separately.
Promotion, resignation and draws
Anything other than playing a move is done differently in each build. In L3 each is a
separate control; in the rest they all go through the same text field.
Build
Promotion
Resignation
Draw
L3
A picker opens; the move is not completed until a piece is chosen
Resign button
Draw radio button
The three UCI builds
5th character of the move: r b n, anything else queens
r, plus Cancel in L3 prompt
= appended to the move; = alone claims or accepts
L3 numerical
5th character of the move: 0 bishop, 1 rook, 2 knight, anything else queens
Cancel, Esc, empty Enter, leaving the tab
Any input containing a non-numeric character
Indicators
In no build does the engine narrate the state; what gets displayed is entirely the
driver's choice, and the indicators multiply as the byte budget grows. Five builds report
the state in text, two say almost nothing.
Build
Coordinates
D?
C!
M=R=
Clock
Last move
L3
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
L3 input
Rank number + file letterflips with orientation
Yes
Yes
Yes
W: 600slive · bottom row is to move
—
L3 input_blindfold
—
—
—
—
W:600s B:600slive · fixed order
[e2e4]
L3 prompt
a8 / h1corner square only
Yes
Yes
Yes
W: 600son move · bottom row is to move
—
L3 numerical
—
—
—
—
900000 900000on move · raw ms, unlabelled
Raw input
L2
—
—
Yes
Yes
—
Green outlineon the destination square
L2_aybars_2400
—
—
Yes
Yes
—
Green outlineon the destination square
In the builds that carry indicators the line is built in the same order.
L3's status line has the form a8-h8 C! M=0 R=1;
L3 input and L3 prompt 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. The practical reading: if you see
M=99 on screen, then the move you are about to play will fill the threshold
provided 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 3-fold 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 the position becomes unique.
Both numbers carry two thresholds, and which one bites depends on the build.
L2 has no button for asking a draw, so the claim-based
R=3 and M=100 trigger nothing there; the game ends only on
the automatic R=5 and M=150. L2_aybars_2400 has no
player layer either, yet it still ends at R=3 and M=100, and
deliberately so: the bot's search reads those same two thresholds internally, and
raising the game's without touching the search would pull the two apart. A loose end,
left visible.
C! says the side to move is in check — so on your own screen, always
your own king. D? marks an open draw offer. In
L3 input and L3 prompt the two share one slot and D? takes
precedence; L3 does not write it at all — there the amber outline on the draw
radio carries the same information for free. When the game ends
L3 replaces the whole status line with the result code, while
L3 input writes only into that slot — M= and R= stay put.
D? means the offer bits are non-zero; it does not say who made the offer. In the normal
flow the offer you see is your opponent's, but if you enter = without a move
you will see your own offer as D? too. Only L3 draws the
distinction, and there it is the only signal: the draw radio takes the amber outline when
it is your opponent's offer that is open.
What to do in the builds without indicators. In both, keeping track of check, the counters and
repetition is entirely up to you; the engine reminds you of nothing. The only channels left
are the last-move log and the clock, and both carry more than you would think.
In L3 input_blindfold the draw suffix makes it into the log: if your opponent
played e7e5= you will see [e7e5=] in the square brackets, so
whether they offered or claimed is readable from there. The same goes for promotion —
e7e8n lands in the log as [e7e8N], letting you verify what they
actually promoted to without holding the board in your head. The side to move is not given,
so you read it off whichever clock is running.
In L3 numerical the log is the raw input itself, so an opponent's draw request
is read from the non-numeric characters in it: 1229asd is both a legal move and
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 whichever is falling tells you who is
to move. Since the clock only refreshes when the dialog opens,
a numerically valid but unplayable input is the only way to refresh
the display without triggering an offer.
Notation
How a move is entered
Clicking — L3
Everything is done by clicking: the piece first, then the destination square. When a
promotion square is reached a picker opens and the move is not complete until a piece is
chosen; the clock keeps running meanwhile. If you do not choose a piece you can cancel the
promotion and play a different move instead.
The draw radio works independently of the move. If the threshold is already met on the
board, ticking it triggers the draw directly; if your opponent has sent an offer, ticking it
accepts the offer; if neither holds, the tick stays set and is evaluated when you play your
move — a claim if the move meets the threshold, an offer to your opponent if it does
not.
UCI — L3 input, L3 input_blindfold, L3 prompt
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 — and the absence of a letter both give a queen, so
e7e8 is enough. Colour is the engine's own business; you only choose the
type.
Castling is written with the square the king lands on: e1g1 and
e1c1 for White, e8g8 and e8c8 for Black. There is no
king-takes-rook form. En passant is written with the square the pawn actually arrives on, so
it needs no rule of its own.
Draws use an = appended to the move. b2b3= means: if the
threshold is met once I play this move, I claim; if it is not, I offer a draw to my
opponent. The engine decides by looking at the state after the move is played; if
the move resets the counter or makes the position unique, no draw results.
= on its own claims the current position or accepts a pending offer.
Resignation is r. In L3 prompt, cancelling the dialog also counts
as resignation; an empty Enter does not — it is an illegal
move and the clock keeps running.
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 looked for at the end of the string, and
N is sitting there, so no draw is requested either. The SAN habit works
against you on both counts here. To promote to a knight use e7e8n; to
promote to a knight and ask for a draw, e7e8n=.
If a move delivers mate, a trailing = is ignored. Mate overrides the claim
just as it overrides every automatic draw condition.
Numeric — L3 numerical
The same layout, with squares as two-digit numbers. The numbering is deliberately 1–64
rather than 0–63: a1 = 01, h8 = 64.
Square
UCI
Numeric
Engine-internal
a1
a1
01
0
h1
h1
08
7
a2
a2
09
8
e4
e4
29
28
a8
a8
57
56
h8
h8
64
63
The promotion input is a digit: 0 bishop, 1 rook,
2 knight, 3 and everything else a queen. No letters — there are no
letters anywhere in this build. Castling again uses the king's squares: White
0507 and 0503, Black 6163 and 6159.
The draw has no button of its own; the channel rides on the input itself.
Any non-numeric input is a draw request. If the request happens to match a valid
claim — the 50-move rule or threefold repetition — it ends the game there; if it does not,
it passes to the opponent as an offer. Four cases follow:
A request alongside a move.5664asd — the move is played as
normal and carries the request with it.
A request with no move.asd, or even the single character
g — a claim on the board as it stands, and an offer if the claim does not
hold.
Accepting a pending offer. The opponent types something non-numeric too; the
moment both bits are lit the game ends by agreement.
Numeric but illegal input.6500 — neither claim nor offer; the
move is rejected and the same question comes round again.
Being able to attach the request to a move is not a convenience but a requirement of
the handbook. In the case of “the counter stands at 99 plies, but playing this 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 channel 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 sits in the
title of the prompt() dialog. Since numeric but illegal input reopens that
dialog without triggering anything, it is also the only way to see the time remaining.
Resignation is a separate channel: Cancel, Esc, an empty Enter and leaving the tab all
resign. 0, 00 and a space do not — all three are numerically
zero, which makes them merely illegal moves. Typing r is not a resignation
either; it contains a letter, so it falls into the draw channel.
The awkwardness of this interface is deliberate. The build was written for bytes, not for
comfort.
Result codes
Fifteen outcomes
There are fifteen ways a game can end; six decisive, nine drawn. The engine has two
encodings and both name the same fifteen outcomes: L3 and the three UCI builds
return a two-letter abbreviation, L3 numerical and engine.js an
integer. Each cell shows both.
W#1
White delivers mate
B#2
Black delivers mate
SM3
Stalemate
WT10
White won on time
BT11
Black won on time
TM14
Flag fall × mate impossible
WR12
White won by resignation
BR13
Black won by resignation
RM15
Resignation × mate impossible
754
75-move rule
5R5
5-fold repetition
DP6
Dead position
3R8
3-fold repetition claim
507
50-move 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 carrying a digit are draws.
All fifteen codes fall into these patterns; none is left outside.
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 trigger by themselves, the fifth
those that come at a player's request.
One naming choice is worth spelling out. DP carries the name of the
article rather than of the case: 5.2.2 covers dead positions arising from insufficient
material and blocked ones alike, and the engine reports both — measured coverage on the
blocked side is 93%. Naming the code after the narrow half would have been the easy
route; naming it after the article left the door open, and when
blocked positions were added they did come out under the same code:
the list stayed at fifteen.
These fifteen codes follow FIDE. Another federation's rules produce a different list.
Under USCF, for instance, the 75, 5R and RM endings do
not exist, and TM works quite differently. Adapting the engine to USCF is
possible.
Engine
engine.js — the core without an interface
How the board is represented
The board is a flat array of 64. Each element is one square, its value the code of the
piece on it. The index starts at zero on a1 and ends at 63 on h8;
i>>3 gives the rank, i%8 the file.
The piece code is type*2+colour. Bit zero is the colour — 1 white,
0 black — the rest is the type. An empty square is 0.
Type
P
Code · black
Code · white
Bishop
1
2
3
Rook
2
4
5
Queen
3
6
7
Pawn
4
8
9
King
5
10
11
Knight
6
12
13
The type numbering is not arbitrary. The upper trio — pawn, king, knight — feeds
G's ladder of P>3, P>4, P>5. In
the lower trio the queen is the bitwise union of bishop and rook:
3 = 1|2. That is why the diagonal test h==v&P lets bishop and
queen through together, and the straight test P>1 does the same for rook and queen. No separate
branch for the queen was ever written.
The classification is square-centric: the information hangs off the square, not
off the piece. The opposite pole is piece-centric representation — in a bitboard the
information is scattered across bit sets split by piece type, so “what is on this square?”
gets expensive while set operations get cheap. Here it is the reverse: a square query is one
array access, b[i], four characters.
And it is flat: no border padding, no 0x88, no sentinel squares. The array is
exactly 64, not one more. All of these were tried and eliminated; the reasoning is
in a section of its own.
The engine without a front end. Pure state and functions; no DOM, no dependencies, no
build step. One line, 1,935 bytes. The source below is the file in the
builds/ directory itself, read as the page loads.
The 4x switch moves to a second source: engine_4x.js, the accelerated
variant that enforces the same rules. 48 bytes longer and logically identical — the
same moves at every depth, the same node counts, the same result codes. What it does differently, and the
measurements behind it, are under a separate heading.
The perft switch opens the third: engine_onlyMoveGenerator.js, 727 bytes.
Only the move-generating half of the engine — piece movement, castling, en passant,
promotion and king safety. Everything else is gone; even the side to move is not state
here, the driver carries it.
This file passes the whole suite: the CPW positions, van
Kervinck's list, the Vajolet corpus, the move-list comparison against Stockfish. That is
not a surprise but the point — not one of them asks whether the game is over. The 1,219
bytes engine.js carries beyond this file — the counters, the repetition
table, the material test, the verdict — are called by none of those billion and a half
nodes. Passing the tests proves the move generation correct, not the whole rule set; what
covers the rest is a careful reading of the rule text. To run the suite at this level,
ENGINE=./engine_onlyMoveGenerator.js is enough.
The whole file amounts to thirty-six names: fifteen state variables, fifteen
functions, six scratch globals. Every remaining letter is a function parameter, and so free for a
driver to reuse. All of them are below.
The naming is not arbitrary. Most names are the initial of an English word — Aapply,
Ggeometry, Vvulnerable, Mmove, ddistance, iinitial,
ffinal — and in the tables below that letter is bolded
inside the word it comes from. A few follow a different
logic: n is the counting letter, k the one conventionally used for
vectors, q and Q come from ℚ, x and y are
coordinates, and z and Z sit at the end of the alphabet because
they carry the end of the game. O is shaped like the zero it looks for, and
$ like a hash.
Case carries a convention of its own: functions tend to take the capital, state variables
and parameters the lowercase. It is a tendency rather than a rule — l and
a are lowercase functions, R an uppercase table — but a bare letter
usually tells you which kind of name you are looking at. And where both cases of one letter
are in use, they often name the same idea at two levels: p the piece code and
P its type, l the check test and L the legal list,
c the castling rights and C the function that clips them,
z the stored result and Z the function that produces it.
engine.js—
Six calls to write a driver
A driver does not need to know chess. Validate the click or the text input with
L, apply it with A, then paint the screen. The remaining four are for
the ways a game can end away from the board.
L(square)
The square is a 0–63 index. Returns the array of legal destination squares for the piece on it — moves that would leave the king exposed are already filtered out. An empty array means that piece cannot move.
A(from, to, promotion)
Actually plays the move: updates castling rights, flips the side to move, writes to the repetition table and returns the result code. The promotion type is 1 bishop, 2 rook, 3 queen, 6 knight; omitted, it queens.
Z()
The result arising from the position alone: checkmate, stalemate, dead position, 5-fold repetition, 75 moves. 0 while the game continues. A calls it on the way out; there is no need to call it separately.
D(side)
That side offers a draw or accepts a pending offer; draws that depend on a claim also return from here. D() with no argument only evaluates, without touching the offer bits.
F(side, kind)
Kind 0: that side's flag fell. Kind 1: that side resigned. If the opponent lacks the material to mate, the result is turned into a draw — FIDE 6.9.
l(side)
Is that side in check? This is for display only — legality is L's job.
The side is everywhere the colour bit of the piece code. The clock
is the driver's business — the engine does no counting; it only carries the U and
N fields; decrement them on each tick and call F(side,0) when one
reaches zero.
Three more things worth knowing. The engine sets itself up: the moment the file loads,
the board, the side to move, the castling rights and the counters are at the starting
position; there is no separate setup call. There is no undo:A only goes
forwards. L takes a snapshot and restores it for its own legality trial, but
does not expose that; a driver wanting undo must save b, e,
n, t, c, R and $
itself — leave R out and the counter rolls back while the table keeps the
entry, so the next move produces a wrong $. There is no FEN
and no PGN: to set up a position, write to the b array directly.
State variables
Everything a driver reads in order to paint the screen is here. The only thing it has to
write is the clocks.
Name
Holds
Detail
b
Board
A 64-element numeric array — representation above. A driver may read it directly; it never needs to write.
t
Side to move
Whose turn: 1 white, 0 black. A flips it on every move.
z
Game result
The result code; 0 while the game continues. The engine does not write it itself — the driver puts the return of A, D or F here.
n
Ply counter
Resets on a capture and on a pawn move. At n>99 the 50-move draw may be claimed, at n>149 it is automatic.
$
Repetition count
How many times the current position has been seen. >2 allows the 3-fold claim, >4 is automatic.
o
Offer bits
Bit 0 is White's open offer, bit 1 Black's. When both are lit, a draw by agreement follows.
c
Castling rights
A four-bit mask: 1 white kingside, 2 white queenside, 4 black kingside, 8 black queenside.
e
En passant square
-1 when there is none. Written only if an adjacent pawn can genuinely capture — the correctness of the repetition key depends on this.
UN
White and black clock
Seconds — but not in four of the builds: in L3 and the L3 input family U is a two-element array, plus the U[2] timestamp that appears on the first sample, and N is not a clock at all — in the L3 family it is the 'innerHTML' alias, a helper rather than state, and in the L3 input family it is never read. In L3 numerical both are milliseconds. Details here. The engine neither reads nor decrements them; they are entirely the driver's territory.
R
Repetition table
Position key → number of times seen. The key is b+t+e+c.
Xm
Search budget
How many moves the 6.9 search may try. F sets it to 2e4 on every call and X spends one per attempt. When it reaches zero the search stops and answers mate is possible — an impossibility that cannot be proved does not count as a draw.
Xs
Search table
Searched position → the depth it was searched to — a transposition table, in chess-programming terms. It stores a depth, not a boolean, so a shallow result can never satisfy a deeper query by accident. The key is b+t+e.
Y
A letter carrying no load
Initialised by e=Y=-1 and neither read nor written anywhere after that. Its place in the declaration chain is there purely for the signature. The same letter also names J's parameter, but that is a separate binding shadowing the global: Z passes 1, the 6.9 search passes 0.
Q
the 'indexOf' alias
It appears in three search calls: b[Q](…) inside l and H, and L(x)[Q](…) inside M — the last of these searches the return of L, not the board. The same letter is also a parameter name in G and Z, where it shadows the global and is never read. One of the two abbreviations that have nothing to do with chess; its twin a sits in the functions table, and in the source the two stand side by side.
Functions
You saw six of them above. The remaining nine are internal machinery; a driver need
never call them, but they are necessary for reading the engine.
Name
Signature
What it does
G
G(i, f, T)
Geometry. Can the piece on i reach f — without regard to king safety. T is attack mode: when set, only the pawn's capturing moves count.
V
V(u, s)
Is square uvulnerable to the opponent of s?
l
l(g)
Is the king under attack — the legality test L runs on every candidate. Calls V with the king's square.
L
L(i)
The list of legal destinations. Plays each candidate with M, checks with l, then takes it back.
M
M(i, f, u)
The raw move. Moves the piece, moves the castling rook, removes the en-passant victim, promotes. It does not touch castling rights, the side to move or the repetition table — because it also runs inside L's trial loop.
C
C(i)
Square → which castling right it forfeits. 0 for anything but the king and rook squares.
A
A(i, f, u)
The real move — it applies what L only tried: clips the rights with C, calls M, flips the side to move, writes to R, returns Z.
I
I(g)
Insufficient material: the material half of 5.2.2, whose blocked half is carried by J. Called with g>1 it looks at both sides at once, which is why H calls it twice.
H
H(g)
The material half of FIDE 6.9: g is the side that flagged or resigned; is it impossible for its opponent to mate with the pieces that opponent holds? It is X's first gate — when it returns true the search never starts.
X
X(g, d)
The search half of 6.9. Can g's opponent construct a mating position within d plies, even with g helping? A true return means it cannot. What it looks for is a helpmate, not a forced mate, because that is what the article asks. It writes no separate branch for the mate test — it folds it into the default of its third parameter.
J
J(Y)
Dead position, the blocked half of 5.2.2. Eight bitboard masks grow the squares the pawns and kings can reach until none of them grows any further; if none does, the position is dead. Its scope is kings and pawns — with any other piece on the board it does not run at all.
Z
Z()
The result from the position. First checks whether a legal move exists; if not, it tells checkmate from stalemate. If one does, it consults I and J for a dead position, then the repetition and move counters.
D
D(g)
Draw offer, acceptance and claim.
F
F(g, k)
Flag fall and resignation. Sets up Xm and Xs, consults X, and builds the result from what it says.
a
a
The alias for Math.abs. The second of the two abbreviations unrelated to chess; its twin Q is in the state table. It is inlined in the packed build, where it does not exist.
Scratch values and parameters
Six letters are global scratch — born inside a function, meaningless once the call ends:
m the bishop square-colour mask, W the material
weight, B the bishop colours of g that H
holds while I runs a second time, r the square of the castling
rook, OH's absence test, s the position key —
the state — that A builds.
The remaining sixteen letters are all function-local; a driver can safely use the same
names as globals. The “Where” column shows which functions in engine.js that
letter appears inside.
Name
Where
Role
i
G V L C M I Z A
The initial square, or the index of the loop scanning the board.
f
G L M A
The final square — the destination.
p
V L M I Z
The piece code — the raw value read out of b.
P
G M
The piece type (p>>1). In M it collapses into a straight “is it a pawn?” test.
g
G l L I H D F
The side: 1 white, 0 black. Identical to the colour bit of the piece code.
q
L M
In L, the destination list being accumulated; in M, the midpoint of two squares — used to work out the castling rook's square and the en passant square.
u
V M H A
The promotion type in M and A — the argument only matters on an underpromotion, since omitting it queens. In V, the square being asked about; in H, the piece code O tests for the absence of.
T
G
Threat mode — the flag that switches G into the attack test. When set, only the pawn's diagonal moves count.
k
G F
In G, the direction vector — how much the index changes in one step. In F, the reason for ending: 0 flag fall, 1 resignation.
d
G M
The distance: in G, how many steps to take; in M, the index gap — castling (2) and the two-square pawn push (16) are recognised by it.
h
G
The horizontal distance, the absolute value of the file difference.
v
G
The vertical distance, the absolute value of the rank difference.
y
G
The rank of the origin square — its y-coordinate (i>>3).
S
G
The ray walker. Steps along a sliding piece's path checking whether anything has come between; it calls itself.
x
M
The adjacent pawn's square — where the engine tests whether the en passant right genuinely arises.
Q
G Z
Inside G, the ray walker's parameter; in Z, a parameter that is never used. Both shadow the global alias of the same name — since neither S nor Z reads it, the letter does two jobs at once.
Letters that never appear as identifiers in engine.js: Ej_. All seven files are below.
Flow
engine.js — what happens, line by line
What the letters mean is in the tables above. What is described here is the order in
which those letters do what: which expression enforces which rule, which operator turns
what into what, and which functions run in which order as a move is written to the
board.
The whole file is twenty-three top-level declarations: seven of initial state, one alias
— Q, a string — and fifteen functions; the fifteenth being a,
itself an alias for Math.abs. The same fifteen counted in the table
above.
Their order is not arbitrary — each function leans almost entirely on the ones before
it. It looks forward in three places: G to both V and
C, and L to M. Two of those are genuine mutual
recursion, the third is just a lookup into a constant table; the detail follows
below.
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 exposed. Above it, attack: is a square being attacked? Then legality: the
layer that actually makes the move, tests the king, then takes the board back. Then
adjudication: is the position over, and if so how? At the top, application: the
three functions that make a move permanent, forfeit rights, advance the counters and return
the result. A driver touches only this last layer.
Solid arrows are the layer order: each layer calls only the one below it. The
dashed gold arrows are the two exceptions — both arise from the chess rules
themselves.
The dependency is almost entirely one-way. It breaks in two places:
G → V. The geometry layer reaches up one layer for
castling: it cannot decide whether castling is valid without asking whether the square the
king crosses is attacked. So G and V are mutually
recursive — V calls G for every piece while G calls
V in its castling branch.
M → L.M, which writes the move to the
board, drops back into move generation while setting the en passant square: it writes the
square only if the adjacent pawn can genuinely make that capture legally. The
correctness of the repetition key depends on this.
There is a third recursion, but it does not cross a layer: in its castling branch
G calls itself to test whether the rook's path is clear.
Function
Calls
Layer
G
S · C · V · G
Geometry
C
—
Geometry
V
G
Attack
l
V
Attack
L
G · M · l
Legality
M
L
Legality
I
—
Adjudication
H
I
Adjudication
J
—
Adjudication
X
H · J · L · M · X
Adjudication
Z
L · l · I · J
Adjudication
A
C · M · Z
Application
D
Z
Application
F
X
Application
Setting up the board
The declaration that builds the board is a single expression:
Three steps. The template literal produces a 64-character string; the middle 40
characters are not written in the source but come into being at runtime from
10n**40n-10n**32n. The subtraction is really
10n**32n * (10n**8n - 1n), that is eight 9s followed by thirty-two
0s — exactly 40 digits. It has to be a BigInt: held in an ordinary Number, the
same value would long since have fallen into floating point and had its digits corrupted.
The spread operator splits the string into 64 single characters, and
map turns each character into a number. The trick here is precedence:
+ and - have equal precedence and associate left to right, so
'0x'+u 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'; no other conversion
this short exists.
In the resulting array index 0 is a1 and index 63 is h8.
The remaining initial state is seven declarations: c=15 leaves all four
castling rights open, e=-1 means no en passant square, t=1 puts
White to move, and z=n=o=0 zeroes the result, the 50-move counter and the
offer bits. U=N=600 sets up the clocks, but the engine never touches them; they
belong to the driver. Y is never read again — it is there only to complete the
signature. a=Math.abs saves 10 bytes over its three uses; Q='indexOf'
does the same job for array search, again over three uses, for 3 bytes. Neither is chess
state — both are aliases any project could use.
The last declaration builds the repetition table with the starting position already seen
once:
R={[b+t+e+c]:$=1}
The key has four parts: the array itself converted to text — sixty-four numbers, comma
separated — followed by the side to move, the en passant square and the castling mask,
with no separator. The absence of a separator looks at first like a collision risk,
but what closes the risk is a chess rule itself: e is either -1 or
a square in the 16–23 / 40–47 range, that is always two characters. Since the side to
move is one character, the key always reads as “one character side, two characters square,
the rest mask”. That the en passant square can only arise on the third or sixth rank is the
only thing that guarantees this parse.
Geometry — G
G(i,f,T) answers a single question: can the piece on square i
reach square fas far as the geometry of the board is concerned? It does
not look at whether one of its own pieces stands on the target, nor at whether the move
leaves the king exposed. Both are the business of the layers above. It is the engine's
longest function: 270 bytes, close to a quarter of the file.
The third argument T is attack mode. When set, three things change:
castling is switched off (castling is not an attack), the pawn's diagonal square counts even
with no piece on it (the pawn attacks that square), and the pawn's straight push is
switched off (a push attacks nobody). So G(i,u,1) means “does this piece attack
this square?”
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 spending the
keyword. By the time the body starts running, the whole geometry has been derived:
P is the piece type, g its colour, y the rank of
the origin square, and h and v the absolute horizontal and
vertical distances. Three of them are interesting:
d=h|v. Not addition — or. In every geometry that means
anything, one of the two is zero (a straight move) or the two are equal (a diagonal); in
both cases the bitwise or yields the larger. So d is how many squares the move
spans.
k=(f-i)/d. Dividing the total displacement by the number of squares
gives the single-square step vector: ±1 along a rank, ±8
along a file, ±7 and ±9 on the diagonals. No direction table, no
direction array — one division.
S. The ray walker. It advances i by k; if
it has arrived at f, true, and if not it calls itself on condition that the
square is empty (b[i]<1). That every square in between is empty is tested by
this one line.
For the knight d and k come out meaningless — 1|2
is three, and dividing by three gives an absurd vector — but the knight branch never reads
either. The same holds for pairs whose geometry fits no piece at all: when S
lands on a fractional or off-board index, b[i] is undefined,
undefined<1 is false, and the walk stops immediately. The infinite loop is
not prevented by anything written; it is absent because it is impossible.
Four branches, six pieces
The body is a single ternary chain. The ordering descends by type number from high to
low, and the last branch of the chain gathers three pieces at once.
Knight.h*v==2 — if the product of the distances is two the move is
an L, because the only integer pairs giving two are 1×2 and 2×1. One multiplication instead
of an eight-direction table.
King. Two branches. The first is d<2: one square in any direction.
The second is castling, with every one of its conditions in a single chain. T|v|h^2 gathers all three conditions into one expression: in attack mode,
off the rank, or with h anything but two, castling is out and the ordinary king
move d<2 applies instead. Then the rook's square is worked
out:
r = i + 3.5*k - .5 k=+1 → i+3 (kingside, h1)k=-1 → i-4 (queenside, a1)
The rook stands three squares away on the kingside and four on the queenside — that
asymmetry is closed by 3.5 and 0.5 rather than by a ternary. Then
c&C(r) tests that the rook's right is still in the mask, G(r,i)
that the rook finds a clear path all the way to the king's square — recursion, and the way
b1 is also tested for emptiness on the queenside — !V(i,g) that the
king is not currently in check, and !V(i+k,g) that the square it crosses is not
attacked. The square the king arrives on is absent here, because L's
legality filter already catches it: when the move is made and the king queried, an attacked
destination square is eliminated by itself.
Pawn. Direction first: f<i^g. Since < binds tighter than
^ the expression parses as (f<i)^g — White (g=1)
must increase the index, so f<i has to be false and the XOR yields one; for
Black it is the other way round. Then two branches, split on h: if the file changed it is a
capture, if not it is a push.
Capture.h*v==1 means exactly one diagonal step. Then
T|b[f]|f==e looks for one of three grounds: we are in attack mode, there is a
piece on the target, or the target is the en passant square. Joining the three with
or turns en passant into an ordinary capture without opening a branch for it.
Push.T|b[f] disposes of both conditions at once: in attack mode, or with a piece on
the target, there is no push. Then either one square (v<2) or two:
v<3 for the distance, !b[i+f>>1] for the emptiness of the
square in between — the midpoint of two squares is always the average of their indices —
and y%5==1 for the starting rank. There are exactly two ranks whose remainder
modulo five is one: 1 and 6, the pawn ranks of White and Black. A single modulo
covers both colours.
Sliding pieces. The last branch of the chain handles bishop, rook and queen
together, and does it by reading the type number as a capability bit field. Bishop 1,
rook 2, queen 3 — in binary 01, 10, 11. Bit zero
means “can move diagonally”, bit one “can move straight”.
h*v ? h==v&Pdiagonal test: equal distance, and bit 0 of P
: P>1straight test: the shortest way to read bit 1 of P
In h==v&P the comparison binds tighter than &, so the
result is (h==v)&P: bishop and queen pass, the rook falls at
1&2=0. In the straight test P>1 takes rook and queen and
leaves the bishop. Which direction either piece may move is written down nowhere — it sits
in the numbers themselves.
The & S() at the end of the branch forces the path to be clear. If the
geometry does not fit, the left side is zero and the product stays zero whatever
S() returns; the verdict does not change.
Attack — V and l
The geometry layer knows one piece. The attack layer asks the whole board.
V(u,s) asks: is square u attacked by the side opposings? The filter is p%2^s — the XOR of the piece's colour bit with
s, which is one when the colours differ. Then G(i,u,1), in attack
mode. some stops the moment it finds the first attacker; a full scan happens
only when the square really is safe.
Empty squares leak through the filter — 0%2 is zero, and with
s=1 the XOR gives one. The reason this is harmless lies a layer below: on an
empty square P is zero, so G falls into its last branch, where
h*v?h==v&0:0>1 is zero under every condition and the result does not
change.
l(g) is a one-line wrapper: it finds the king with
b[Q](10+g) — type 5, hence code 10+colour — and asks
V about that square. 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 that update would
also have to stay correct across the moves L takes back. Searching is
cheaper.
Legality — L and M
L — generate, try, take back
Geometry looks at the shape of a move, legality at its consequence. Between them lies one
rule: a move that leaves your own king exposed cannot be played. There is no short way to
test that statically, so Lactually makes the move, asks about the king,
and takes the board back.
L=(i,q=[],g=b[i]&1)=>(b.map((p,f)=>
filter !p|p&1^g && G(i,f) && (
savep=[b,e,n],
clone b=[...b],
play M(i,f),
test l(g)||q.push(f),
take back [b,e,n]=p)),q)
The accumulator q is born as a default parameter, so no further
let is spent. The filter !p|p&1^g requires the target to be
empty or to be of a different colour — since & binds tighter than
^, which binds tighter than |, the expression parses as
(!p)|((p&1)^g). This eliminates both moving onto your own piece and the case
where f equals i, without geometry ever running.
The real subtlety is in the backup. The state is written as the triple
[b,e,n]over the p parameter — over the letter that held
the target square's contents a moment ago and whose work is done. A global backup would be
corrupted here, because M calls L back while setting the en
passant square, and that inner call would write to the same global. That the backup lives on
the call stack, inside a parameter, is not elegance but necessity.
The board is cloned by spreading; e and n are numbers, so
saving them by value is enough. M(i,f) is called without specifying a promotion
type, so the default queen is used — irrelevant to legality, because whether the king is
left exposed depends on the destination square being occupied, not on the type of
the piece standing there.
M — five steps, in an order that cannot change
M writes the move to the board and does nothing else: it does not flip the
side to move, does not touch castling rights, does not write to the repetition table. The
reason is that L calls it as a trial — a move that will be taken back must
leave no permanent trace. Everything permanent lives one layer up, in A.
The 50-move counter.P says the piece is a pawn,
b[f] that there is a piece on the target; if either is true the counter resets,
otherwise it increments. The whole rule is one ternary.
Destination square and promotion.f%56<8 is the last-rank test:
squares 0–7 and 56–63 satisfy it, everything in between does not. If a pawn has arrived
there, the chosen type is written — u*2+p%2, that is the type and the
piece's own colour; the choice cannot change the colour.
Origin square and the en-passant victim. A chained assignment works right to
left: first b[X]=0, then b[i]=0. On an ordinary move X
is already i and the same square is zeroed twice. On en passant X
becomes f^8, and that expression finds the direction by itself: the en
passant square lies either in 16–23 or in 40–47; in the first, bit three is zero and the XOR
moves one rank forward, in the second it is one and it moves one rank back. In both cases
the captured pawn is on the right square.
The castling rook. The pair d==2&p>9 is castling's only
signature: p>9 says the piece is a king or a knight, but no knight move ever
has an index difference of two — a knight displaces only by 6, 10, 15 or 17. The rook's
destination is q, the midpoint of the king's origin and destination squares.
The origin square is found here with a ternary; in G the same calculation was
written as i+3.5*k-.5. Both are 11 characters — the difference is not bytes
but scope: M does not compute the step vector k.
The en passant square. The condition is d>9, because every other
pawn move has a difference of 7, 8 or 9; 16 arises only on the double step. The square
is first written provisionally with e=q — and that assignment
must come before the test, because inside the L(x) call below,
G's pawn branch performs exactly the f==e comparison. Then the two
neighbours of the destination square are examined: 17-p is the colour flip
(white pawn 9 → black pawn 8, and vice versa), and ~L(x)[Q](e) asks
whether that pawn can genuinely and legally make the capture. If it cannot, the
square reverts to -1.
The fifth step is this fastidious because of the repetition table. Since the key contains
e, writing an en passant square nobody could use would drop the same position
under two different keys and silently miss a 3-fold claim. FIDE's definition of “the same
position” includes the available moves; this line is that definition to the letter.
Because the neighbour search uses f-1 and f+1, it can spill
across the board's vertical edges. That is harmless: the spilled square is at the opposite
end of the a- or h-file, and L(x) will never generate a move from there to
e — G's pawn branch does not count a seven-file horizontal
difference as a diagonal capture.
Castling rights — C
C=i=>'20003001'[i%56]<<i/28
The four castling rights are four bits in c. The function that says which
square forfeits which bit is 27 bytes:
Two steps. i%56 folds the first and eighth ranks onto the same eight
indices: a1 and a8 to zero, e1 and e8 to four, h1 and h8 to seven. The 48 squares in
between land in the 8–55 range, that is outside an 8-character string — where
undefined is read. The string itself is a table: character zero is
'2', character four '3', character seven '1', and the
remaining five are '0'.
Then <<i/28. The shift operator coerces both of its sides to integers:
'2' becomes two, '0' becomes zero, undefined becomes zero; and
the right-hand side comes out 0 on the first rank and 2 on the eighth. The result is four
bits:
Square
i%56
Character
Shift
Bit
Right
h1
7
1
0
1
White kingside
a1
0
2
0
2
White queenside
h8
7
1
2
4
Black kingside
a8
0
2
2
8
Black queenside
e1
4
3
0
3
Both of White's
e8
4
3
2
12
Both of Black's
the other 58
—
'0' / none
—
0
—
c &= ~C(i) & ~C(f)
That the king's square forfeits two bits at once is the whole of the '3' in
the string. And all the forfeiting is one line in A:
The origin square says the king or a rook has moved, the destination square says a rook
has been captured on its own corner. Two separate rules, one expression. Since
irrelevant squares return zero, their complements are -1 and leave the mask
untouched — no condition needs writing.
Material — I and H
I=g=>(m=W=0,b.map((p,i)=>p&&g>1|p%2==g&&(
p<4 ? m|=(i/8^i)%2+1bishop: write the square colour into the mask
: W+=p<10?9:p>11)), heavy 9, knight 1, king 0W*2+m<3)
I(g) scans the board once, fills two scratch globals and answers a single
question: can mate be constructed with this material?
Called with g>1 the colour filter drops away and both sides are counted
at once; that is how Z uses it. Bishops are kept separate because it is not
their number that matters but their square colours: (i/8^i)%2 takes the
last bit of the XOR of rank and index, that is the colour of the square; +1
makes this 1 or 2 and it is or-ed into m. Five bishops of the same colour still
light one bit, while one bishop of each colour makes m 3.
Every other piece writes a score into W, and the threshold
W*2+m<3 fits all of FIDE's distinctions into one comparison:
Scoring the knight one, the bishop one per square colour and a heavy piece nine looks
arbitrary, but its only purpose is to cut this table in the right place. The threshold
distinguishes just three things: whether W is zero, one, or greater than one.
That is the only constraint on the heavy piece — it must not weigh less than two knights, so
at least two. Every digit from two to nine gives the same verdicts and every one of them
costs a single character; nine is chosen because it is the queen's conventional value, which
lets W read as material weight rather than as a counter.
When I finishes, m and W are left readable, and
H uses exactly that:
O(u) means “g has no piece of type u/2” —
O(4) rook, O(8) pawn, O(12) knight. Then
I runs twice: the first only for its side effect, because B=m has
to save g's bishop colours; the second for both its return value and its side
effect. After the second call, m and W now describe the
opponent's material, and the rest of the condition reads those two letters that
way.
What is being asked is not whether the opponent can mate but whether mate is
possible — that is, whether a mate could arise even if g plays the worst
moves available. The three sub-tests correspond, in order, to: against a bare king no
arrangement gives mate; if g has a knight or a pawn, mate can be constructed,
because that piece can shut off its own king's escape square; and g's bishop
only matters if it is on the same colour as the opponent's, in which case neither side can
ever touch the other's squares. The rule itself and a case-by-case breakdown are
in the rules section.
The impossibility of mate — X
X=(g,d,v=t^g|!l(g))=>
H(g) || Xs[s=b+t+e]>=d || J(0) three gates
|| (Xs[s]=d, b.every((p,i)=>!p|p%2^t
|| L(i).every(f=> v = n>14975-move counter full → drawn anyway
|| d && Xm-->0depth and budget
&& [3,2,1,6].every((u,x,w)=>
x && p>>1^4|f%56>7 one pass unless promoting
|| (w=[b,e,n,t], b=[...b],
M(i,f,u), t^=1,
X(g,d-1)|([b,e,n,t]=w)))))) recurse, then undo
&& v) leaf: has g been mated
The material test asks whether mate exists at all with these pieces. Article 6.9 asks
something narrower: can mate be built from this position. The material may be more
than sufficient while the position forbids it outright. X is the search that
closes the gap.
What it asks is exactly what the article asks: can the side that flagged or resigned
be mated by its opponent through any series of legal moves. It looks for a
helpmate, not a forced mate — can a mating position be built even with the losing
side helping.
There are three gates. H(g) leaves at once if the material already falls
short. Xs leaves if the position has been searched to at least this depth
before — the table stores a depth, not a yes or no, because a shallow result
satisfying a deeper query would quietly falsify the search. J(0) leaves if
the position is blocked.
Its neatest turn is the third parameter. v=t^g|!l(g) is a leaf test: if it
is not g to move, or g is not in check, there is no mate here.
When both are false and no legal move was generated anywhere on the board, v
keeps its default and comes out 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 line
separates stalemate: no move, but g not in check, and the answer is true —
no mate down this branch.
The undo sits inside the expression too. In X(g,d-1)|([b,e,n,t]=w) the
array assignment falls to NaN as an operand of | and contributes nothing, so
the recursion's answer passes through untouched.
The search sees more than mates; it sees every ending that arrives before one.
Where a forced capture leaves the material short, H closes the branch at the
next node. If the ply counter fills during the search, n>149 takes over.
There is no separate counter for repetition, but Xs cuts a branch that returns
to a position already searched — that is how forced repetition reaches its result. If the
budget runs out, v is zeroed and the answer becomes mate is possible:
an impossibility that cannot be proved does not count as a draw.
The visible consequence is this. A queen has driven you into the corner and given check,
your flag falls, but your only legal move is to take the queen and two bare kings remain —
you do not lose, you get TM. The converse is stranger still:
8/6pp/7k/6q1/7K/4Q1PP/8/8 w beyazın süresi bitti · White's flag falls
White is in check and has exactly one legal move: Qe3xg5, and that move is
mate. But there was no time left to play it. Black still has a queen, so
H has nothing to say about the material. X settles it in a single
node: White's only move mates Black, so Black never gets a turn, and therefore cannot mate
White by any sequence at all. The result is TM — a draw.
Article 6.9 asks whether your opponent can mate, not whether you can. The game ended
when the flag fell, and the winning move was never played.
Dead position — J
J=(Y, f=2n**64n-1n, y=f^f/255n, x=y>>1n, …)=>
~e|n>1&Y ? 0 : gate: did anything irreversible just happen
( b.map(p=>(p && (p-8>>2 ? w=1 anything but pawn or king → out of scope
: p>9 ? p&1?K=V=u:L=v=u kings
: p&1?W|=u:B|=u), u*=2n)), pawns
C=P=W, Z=Q=B,
!w && V*v && (X(600), grow until nothing grows
!( C>>56n | Z&255n is a promotion left
| S(C)<<8n&(Z|v) | S(C^W)<<8n&L
| S(Z)>>8n&(C|V) | S(Z^B)>>8n&K is a capture left
)))
I looks at the material, X at the search. Between them sits a
third question: the pieces may be sufficient and the search may still find nothing —
because the position is blocked. J is the scan for that.
The gate asks two things: is an en passant right standing now, and did anything
irreversible happen in the last two half-moves. Both are matters of chess, not of code.
The first is a silencer. While an en passant right stands the position
cannot be dead: the capturing pawn lands on the file the opponent has just vacated,
and its path there is permanently clear — promotion is possible, mate is possible. The
detector's model cannot see that sideways jump, because its advance set grows vertically
only; so it is not allowed to rule at all. “Where there is en passant there is no lock” is
not a guess but a provable proposition.
The second is a trigger. A position can only pass from live to dead through an
irreversible move: either a pawn advanced or a piece was captured. A king move is
reversible and can destroy no possibility for good — the king can walk back. A pawn move
and a capture are exactly the two things that reset n. So the natural trigger
is n=0.
On its own it is not enough, and the reason is the first point. An en passant square
arises from a double push, and a double push is a pawn move — so the ply on which
n resets is precisely the ply on which the silencer is in force. The moment
the trigger should fire is the moment the detector is forbidden to speak. The ruling has to
be deferred by one ply: look again once the en passant right lapses. At that moment
n is one. So the rule “look again when en passant lapses” needs no bit
of its own; widening the n=0 window by one ply covers it. Together they come
to n<2.
The same reasoning covers check. Because the scope is kings and pawns, there is no
sliding piece on the board; with no slider there is no discovered check, and a king cannot
give check itself. One possibility is left: a pawn move gave the check. That resets
n, and the escape makes it one. Inside the window again. The alternative was
to carry a snapshot of the previous ply — was there a check, what was the en passant square
— and all of that information is already sitting inside n.
J's parameter is a force switch. Z passes 1
after a real move and turns the window on; the 6.9 search passes
0 to say “judge this position as it stands”, because inside the search
n counts the search's own moves rather than the game's, and the window has no
meaning. The silencer stays in force on both calls. The parameter is named
Y and shadows the global of the same name — that global exists only for the
signature and is read nowhere.
Its scope is drawn by w. p-8>>2 lights up on anything
outside the pawn and king codes, and the !w condition then keeps the search
from starting at all. That single line is the whole reason blocked positions containing a
bishop get away.
The fill binds to direction, not colour: W is the pawns moving
toward increasing indices, B those moving the other way. That is why the
flipped-board build only swaps the binding and never touches the body.
The loop grows four mirrored pairs. P/Q are the blocked pawns,
C/Z the squares the pawns can still advance to,
K/L the squares the kings can reach, r/q
the ground each side controls. Every turn feeds them into one another: a king may enter
neither a blocked pawn nor a square an enemy pawn covers, and a pawn may not advance onto a
square an enemy pawn holds. When no mask grows any further, the balance has settled.
Eight masks across 64 squares put the worst case at 512
steps, and the loop stops at 600. So unlike X, no cut ever happens
here — the bound is not measured but proved.
Six questions remain at the exit. Two are about promotion: can White reach the eighth
rank, can Black reach the first. Four are about capture: do the diagonal neighbours of the
advance squares touch an enemy pawn or the ground an enemy king holds. If all six answer
no, the position cannot breathe.
Adjudication — Z
Z=Q=>b.some((p,i)=>p%2==t&&L(i)+'')
? I(2)|J(Y)?6 : $>4?5 : n>149?4 : 0 a move exists → automatic draws only
: l(t)?t+1:3 no move → checkmate or stalemate
Every game-ending rule hangs on one question: does the side to move have a move it can
play? If yes, the game can only end by an automatic draw; if no, it is checkmate or
stalemate. Z is precisely the shape of that distinction.
The two halves of the dead position meet here under a single |:
I reads it from the material, J from the structure. Whichever
returns true, the code is the same — 6, because the article is the same: 5.2.2.
L(i)+'' is the shortest form of the empty-array test: an empty array
converted to text is '' and counts as false, while a full one gives something
like '28,24' and counts as true. .length is 7 characters, this is 3.
The mate code is t+1. If Black is to move (t=0) the mating side
is White and the code comes out 1; if White is to move, 2. One addition names both
outcomes.
The order of the branches is itself a rule. FIDE 9.6 says 5-fold repetition and 75
moves end the game in a draw — unless the last move was mate. Here that exception
was never written, because mate lives inside the “no move” branch: on a mate with the
75-move counter full, the engine does not even look 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 too. Saying “the game continues” ends as soon
as the first legal move is found; saying “mate” requires trying every move and proving that
none exists. Measured, the difference is this: a mating move makes Z generate
moves for 48 squares; in the e2-e4 trace below the counter stopped at 33,
because the scan ended the moment Black's first movable piece was found.
The two in I(2) is the argument that drops the colour filter — insufficient
material is decided by looking at both sides at once. The filter p%2==t lets
empty squares through as well, but since L returns an empty array for an empty
square, the verdict does not change.
Application — A, D, F
A — five steps, each reading the one before
A=(i,f,u)=>(
1 c&=~C(i)&~C(f), forfeit castling rights2 M(i,f,u), write the board3 t^=1, flip the side to move4 $=R[s=b+t+e+c]=-~R[s], count the new position5 Z()) return the verdict
The first step gathers two separate rules into one expression. C(i) asks
about the origin square: if the king or a rook has left its own corner, the right is
forfeited. C(f) asks about the destination: if a rook has been
captured on its own corner, the right is forfeited too. In castling both of these
collapse onto a single square — C(e1) returns three, so both of White's rights
go at once and the rook's own square is never consulted.
The third step must precede the fourth, because the repetition key has to describe the
position after the move — and the side to move is part of that position. In the
fourth step the left-hand side of the assignment is evaluated first, so s is
already set to the new key when -~R[s] reads it. ~undefined is
minus one, and negating it gives one: a position seen for the first time lands directly on 1
without any default write.
Two things Adoes not do are part of its definition too: it does not
touch the clocks and it does not clear the draw offer. Both are driver policy, not
rules.
D — claim and offer
D=g=>(o^=2-g,n>99?7:$>2?8:o>2?9:Z())
The offer bits live in o: White is bit zero, Black bit one.
2-g selects between them with one subtraction. Called with no argument,
2-undefined 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. Fifty moves and
3-fold repetition are claimed; 75 moves and 5-fold repetition happen
of themselves. 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=(g,k)=>(Xm=2e4,Xs={},X(g,15))?14+k:10+2*k+g
g is the side that flagged or resigned, and k is zero for
timeout, one for resignation. If X(g,15) is true, it is impossible for the
opponent to mate and the result is a draw — 14 or 15. Otherwise the opponent wins and a
single piece of arithmetic names all four outcomes: k picks timeout from
resignation, g picks the side. 10 and 11 come from the clock, 12 and 13 from
resignation.
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 entries over a single A call was recorded.
An ordinary move — e2-e4
The driver first validates the clicked or typed square with L(12), and if
the target is in the list calls A(12,28). Inside, five steps run: the castling
mask does not change (C returns zero for both squares), M writes
the pawn to 28 and resets the 50-move counter, the move passes to Black, the new position
is entered into the table once, and Z returns zero — the game continues.
The en passant square is not written. Despite being a double step, there is no
black pawn on 27 or 29 to capture, so e stays at minus one. The same move, with
a black pawn beside it, would have written the square.
Function
G
L
M
V
l
C
I
Z
over A(12,28)
1,616
33
3
2
2
2
1
1
The distribution sums up the shape of the engine. The move itself is almost free — one
M, two C. Nearly all sixteen hundred geometry questions come from
the last step: Z has to show that Black has a piece able to move in order to
prove the game is not over, and it generates moves for 33 squares before finding
one. The engine works not to make the move but to arbitrate.
Kingside castling
Once the path is clear, L(4) returns the king's moves as
[5, 6, 12] — f1, g1 and e2. g1 sits in the list like an ordinary
destination; the driver never learns that it is castling, it merely clicks a square.
Every condition for castling is tested inside G, before the target ever reaches
the list.
In that single L call, C runs once and V five
times. Two of the five come directly from G's castling branch — that the king
is not currently in check and that the square it crosses is not attacked. Queenside castling
is never even asked about: with the bishop still on c1, L's filter eliminates
the target before it reaches geometry, which is why C is not called a second
time.
Then A(4,6): C(4) returns three and the mask drops from 15 to
12 — both of White's rights go in one step. M's fourth step fires, the
rook moves from h1 to f1 and h1 empties. The measured final state: white rook on f1, white
king on g1, h1 empty.
En passant
In the sequence 1.e4 a6 2.e5 d5 the real work happens on Black's double step.
M's fifth step fires: the difference is 16, the square is provisionally
written as e=43 (d6), and then the two neighbours of the destination square are
examined. There is a white pawn on 36 — 17-8 is looking for exactly that — and
L(36) does generate 43. That generation is only possible because e
has already been written; G's pawn branch performs the f==e
comparison. The result: e=43 stands.
The same move, with no white pawn on e5, ends with e=-1. I measured both.
The difference goes to the repetition table: had a square nobody could use been written, the
same position would fall under two different keys and a 3-fold claim would be silently
missed.
When White captures with A(36,43), M's third step comes into
play: since f==e, the square to be cleared is 43^8, that is 35 —
d5. The measured result is d5 empty, a white pawn on d6, e5 empty. And because this move is
not a double step, the fifth step does not fire and e reverts to minus one.
The driver boundary
The four text drivers call A as it stands: A(i,f,promotion),
writing the returned code into z. The builds that draw a board redefine
A and inline the five steps into their own body; in L3 the reason
is that the clock increment has to sit in the very middle of the third step, at the moment
the side to move is flipped. The engine's rule logic stands unchanged; the only thing inserted is
U[t^=1]+=5.
The ladder of short circuits
Five separate tests arrive at the same result. H, I,
J, the repetition counter and the ply counter all say the game is drawn. What
separates them is how early and how cheaply they say it.
The net at the bottom is the 75-move rule: every blocked position falls into it
eventually. Five-fold repetition short-circuits that, because being blocked is what makes
mobility collapse and repetition arrive sooner. I and J
short-circuit repetition by recognising the position on the first move. And H
short-circuits X, never letting the search start when the material already
falls short.
In principle X 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 w kilitli · blocked
Nothing in this position can cross to the other side. The pawns are blocked head to head
with empty diagonals; each bishop is confined to squares on its own half; neither king can
pass the rank the enemy pawns cover. It is a dead position. But there is a bishop on the
board, so J is out of scope, and the material is more than sufficient, so
H has nothing to say. That leaves the search itself.
Depth
Answer
Nodes
9
mate is possible
10
15
mate is possible
19
23
mate is possible
31
35
mate is possible
43
The node count grows linearly with depth, not exponentially — and the answer never
changes. The reason is in the design of X: every breaks on the
first false, and when the depth runs out the leaf zeroes v and answers "mate is
possible". The search walks a single line to the bottom, gives up there, and never opens a
sibling.
So X can only prove impossibility when every line closes before the depth
runs out. With the kings free, no line ever closes. What is needed is not a few times the
budget but a depth on the order of the reachable state space — the product of king and bishop
placements, tens of thousands of positions. That is exactly why the short circuits exist:
J reaches the same ruling by a scan, without the search ever opening.
An extension covering nearly all of the bishop cases was designed and measured: it would
have taken coverage from 93% to 99.94%. Even golfed it came to close to 1,000
bytes in plain form. In an engine of 1,935 bytes that is more than half the program again;
packed, it would take numerical_packed.html from 1,815 bytes to about 2,600. The
return per byte falls by a factor of twenty-seven.
And 99.94% is still not 100%. Even after adding it, I could never be certain of
completeness — it is a measured figure, not a proved bound. Putting something I cannot be
certain of into an arbiter engine weakens the guarantee everything else in it gives, and that
guarantee is exactly what the tests measure. So the rule is shelved, not
lost.
Verification
The tests it passes
The engine's claims about the rules were tested with perft: the number of legal move
sequences generated from a position down to a given depth, compared against published
reference values. A deviation of even a single node means an error in the rule set, which
makes perft the harshest test of an arbiter engine — it probes the whole of move generation,
king safety, castling rights and en passant availability 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.
At shallow depths where no published value exists, comparison is supplied by a naive 0x88
implementation written directly from the rules that shares not one line of code with the
engine.
The depths below are consolidated: where a position was split across several runs — to d5
first, then d6 and d7 in a separate run — it appears on one row at the deepest layer
reached. Every position was also verified at all intermediate depths from d1 up to that
layer.
The standard CPW positions
Seven positions, including the colour mirror of the fourth. The mirror is not incidental:
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 published depth was exceeded;
since no published value exists at those layers, verification was done 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 deterministic-random games from Kiwipete, at most 80 plies per game
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
because two errors cancel each other out; the list comparison is there because it rules
that out.
The tests run by default on engine_4x.js. To test
engine.js byte for byte,
ENGINE=./engine.js is enough.
Run them yourself
All the numbers above are a single claim: the engine produces these nodes in these
positions. You do not have to take my word for it to check.
The repository is public:
github.com/cuneytinann/FideLite. To
download the whole test folder as one file, use
test.zip · 236 KB — it contains the suite, copies
of all three engines, the Vajolet position list and eight double-clickable .bat
files, and you need nothing else. On Windows, double-clicking the file is enough; on other
platforms, 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 present and settles for the reference values if it is not.
Stockfish is not in the package; on its own it exceeds 100 megabytes. Put
your own binary in the test folder or add it to PATH; the suite looks in order at the
STOCKFISH variable, then stockfish.exe on Windows, then the usual
install paths on Linux and macOS.
For the places where neither a published value nor Stockfish is available, the package
includes ref.js: a naive reference written straight from the rules that shares
not one line of code with the engine. That the two arrive at the same number is proof of
genuine agreement rather than of a shared bug. Its board is
0x88 — the architecture eliminated from the engine over bytes,
and the most readable one where bytes do not matter.
Speed
engine_4x.js — same rules, +48 bytes, two to ten times the speed
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. The difference is only in how many
operations it takes to reach the same answer.
All 13 bytes go to four changes. The gain column below is each change's effect
measured on its own — on the Z() call, the engine's hottest path, in the
starting position, averaged across both sides to move.
Change
What it does
Bytes
Gain
p=b[i] cache
G was reading the piece code twice (P=b[i]>>1 and g=b[i]&1); now it is read once and both derive from it.
+1
1.07×
& → &&
Two places in G: !V(i,g)&!V(i+k,g) on the castling line and (…)&S() on the last. & does not short-circuit, so the right-hand side ran even when the left was false — an unnecessary attack scan in the first case, a full ray walk on every sliding-piece trial in the second.
+2
1.58×
p&& precondition
The scan conditions in V and Z were accepting empty squares too. More on this below.
+6
6.34×
map → forEach
L was allocating and discarding a 64-element result array on every call.
+4
1.02×
Applying all four to engine.js in order gives a file byte-identical to
engine_4x.js; there is nothing in between.
There was a fifth change, and it is not on the list because it now sits in both:
L used to call the expensive G first and only then ask “is the
target already my own piece?”, and the order was reversed. That swap is byte-neutral —
G(i,f)&&!p|p&1^g&& and
!p|p&1^g&&G(i,f)&& are both 18 characters — and on its
own it brings 1.49×. It is applied in every shipped build. Being free in the source does not
make it free in the packed output: because the repeat sequences RegPack sees changed, it cost
a total of 3 bytes across the packed files of the day.
The third row is the most interesting on the list, because what it fixes is not
slowness but asymmetry. The code for an empty square is 0, and in
engine.js the scan conditions are p%2==t and p%2^s.
Since zero modulo two is zero, those conditions count empty squares as Black's
pieces. The consequence: with Black to move, Z() runs a full legal move
generation for every empty square on the board, and with White to move it does not.
Measured, the difference is stark:
Z() in the starting position
White to move
Black to move
Ratio
engine.js
174 µs
1,393 µs
8.0×
engine_4x.js
97 µs
102 µs
1.1×
This is why “twice” is not a single number but a range that depends on the workload.
Perft d4 from the starting position — where the great majority of nodes have White to move,
the cheap side of the asymmetry — gives 2.4×. The same perft at d3, whose majority
ends with Black to move, gives 9.7×. The two in the build's name is the good-case
figure, not the bad-case one; a modest name, in other words.
The 13 bytes only repay themselves where the engine's hot path is permanently open. In a
driver that takes its move from a human, the engine's microseconds are invisible next to the
user's thinking time; which is why the measurement is run without a driver at all.
engine_4x.js is embedded in no driver; its source sits under
builds/, with a copy in the test bundle.
The 13 bytes were not plucked from the air
The four changes above are not a guess. A candidate list was measured, four survived,
several were eliminated. What survived is written out one by one above; what follows here is
what was eliminated and why.
First, the measurement itself
Every number under this heading depends on method, and there are two easy mistakes in
measuring speed — both of which inflate the gain.
The first is taking the best run. Run the same code 15 times and record the
fastest and you have measured the most generous moment of the noise, and that generosity is
not the same across builds. The second is the artificial bench: looping move
generation over a handful of fixed positions leaves out the state management that runs
alongside it in a real deep count.
Do both at once and the result does not merely shift numerically; it changes sign.
The unmake line below showed an 11% gain when measured on a synthetic bench with the best
run; with the median and a real perft, a 13% loss. The table is therefore perft d3
from the starting position (8,902 nodes), the median of 15 repetitions, each
build in a separate process.
The gain, function by function
The overall ratio is not a single number, because the gain differs sharply from one
function to the next. The measurement below exercises each function on its own, as the
median of 7 repetitions.
One thing has to be said first: perft never touches X or
J. Perft counts nodes, it does not rule; Z calls
J and F calls X only when a game ends. The perft
table above and the table below therefore measure different things — the speed of the move
generator on one hand, of the arbitration layer on the other.
Function
What was measured
engine.js
engine_4x.js
Gain
L
Every legal move from the starting position, 2,000 rounds
2,050 ms
885 ms
2.3×
X
The 6.9 search on an open position, 3,000 rounds
5,398 ms
2,039 ms
2.6×
J
A locked pawn wall, 20,000 calls
9,419 ms
255 ms
37×
The gulf in J comes down to a single line. The 1x version runs a fixed
600 iterations for a reason of correctness — so that the bound is a proved one. The
4x version watches the sum of the masks instead and stops the moment none of them
grows. In a locked position the balance usually settles in 15-20 rounds, so
the 4x version never does 99% of the work. That is what 24 bytes buy.
That the gain stays near two and a half in L and X is the same
reason inverted: the work done there is work that genuinely has to be done, and there is no
slack to cut.
The eliminated
The timings were measured on the generation before the
dead position and the mate-impossibility search; the byte column is
written against today's engines, because none of the four experiments touches those two
layers — their cost is the same whichever generation they are added to. All four were
rejected, and the reasons have not changed since.
Build
Bytes
Median
vs 4x
engine.js
1,935
12,385 ms
0.10×
engine_4x.js
1,983
1,177 ms
1.00×
+S hoisted out
2,005
1,091 ms
1.08×
+ target guard
2,008
1,035 ms
1.14×
+ both together
2,030
1,012 ms
1.16×
+ true unmake
2,068
1,349 ms
0.87×
Hoisting the S closure out of the parameter list.G's
last default parameter is a self-referencing closure, recreated on every call — before the
first comparison in the body even runs. The theory was that the closure forced
G's locals onto the stack on every call. +22 bytes, 8%. V8 handles the
closure better than the theory assumed.
Bounding the target scan. Non-sliding pieces cannot travel further than seventeen
in index terms — a pawn at most 16, the king 9, the knight 17. Putting a single guard in
L and bounding the scan to that range for non-sliders is +25 bytes, 14%.
The best ratio on the list, and a local bound whose correctness is provable from the
piece codes.
True unmake. Restoring only the squares M touched, instead of
L's reference-swap take-back; the copy path is kept for king moves, because the
castling rook writes two more squares. +85 bytes, and slower. It allocates a 5-element array per candidate, whereas [...b] is a single bulk copy of a
packed 64-element array. It is also the most volatile version measured — its maximum climbs
to 1,806 ms, meaning it feeds the garbage collector.
On top of that, its correctness depends on knowing exactly which squares M
writes to. In the first version the restore square was held in a global, and because
M's en passant branch calls L again from the inside, the inner
call was overwriting that global: the outer call restored the wrong square and the board was
silently corrupted. Every new write added to M would recreate the same bug.
The same lesson turned up one step beyond the map → forEach
change: replacing forEach with a hand-written for loop is
+19 bytes and 10% slower. Intuitions carried over from C — bulk copies are expensive,
hand loops are fast — do not hold in JavaScript.
Order is a variable too
Unmake once measured far better than this, and that measurement was not wrong either.
Before V's empty-square scan was fixed — the p&&
precondition above, 6.34× on its own — board copies were a far larger share of a far larger
total. Measured before and after that fix, the same edit looks like two different
optimisations.
Order matters when you credit a gain to a change: what you are measuring is not the
change itself but its share of the bottleneck at that moment.
Why we stopped here
The gulf in ratios shows up in a single line. From engine.js to
engine_4x, 13 bytes bring a tenfold gain. The best candidate after
that is 16% for 47 bytes. A hundredfold difference per byte.
The profile says why. In the opening position a full scan makes 768 shape probes, and
97% of them are rejections — all at the first comparison. The rejections break down
as 50% pawn, 31% slider, 12.5% knight, 6.3% king, which is exactly the material count.
Measured again ten moves later it is the same, because the scan is uniform: every piece,
whatever its type, receives the same number of target probes. Speeding up a particular piece
has no surprise payoff in any position.
There is nothing left to trim. Beyond this point means narrowing the scan space — target
lists per piece, that is, move tables — and that road was
already measured and eliminated; it costs kilobytes.
The goal was an engine that is fast without breaking Golfstack. engine_4x is
that. What comes after neither speeds things up enough nor earns its bytes.
35 bytes more — X and J
The 13 bytes above belong to the engine's base and are still exactly that. When
the dead position and the mate-impossibility search arrived, the gap
grew to 48; the 35 bytes in between come entirely from the fast variants of those
two functions.
Layer
engine.js
engine_4x.js
Difference
Base
1,175
1,199
+13
X
272
283
+11
J
488
512
+24
Total
1,935
1,983
+48
X — 11 bytes, four items
The check test moves to the leaf. In the 1x version the leaf test is the default
of the third parameter, v=t^g|!l(g). A default runs on every call —
including nodes that leave at once through H, the Xs memo or
J. And l(g) scans the board. The 4x version writes
v=0, which costs nothing, and moves the expression to the end, inside
&&(v||t^g|!l(g)). On every early exit the check test never runs at
all. +5 bytes.
The empty square short-circuits.!p||p%2^t instead of
!p|p%2^t. When the square is empty the colour comparison is never made.
+1 byte.
The promotion test starts with the cheap half.(f%56>7||p>>1^4) instead of p>>1^4|f%56>7. The
destination not being on the last rank is both the cheaper test and the far more common
answer, so it goes first under ||. +3 bytes.
The undo returns a number.([b,e,n,t]=w,0) instead of
([b,e,n,t]=w). The 1x version relied on the array falling to NaN under
|, which means turning the array into text and then into a number on every
node. A literal 0 does the same job for free. +2 bytes.
J — 24 bytes, 37×
The counted loop becomes a fixed point. This is the single largest gain in the
engine. The 1x version runs 600 iterations through
X=k=>k&&(…,X(k-1)), so that the bound is a proved one. The 4x
version takes one more local — o=W — and compares the sum of all the masks at
the end of every round:
X=_=>(o^(o=C+Z+K+L+r+q-P-Q))&&(…,X()). The moment the sum stops
changing, it stops. In a locked position the balance usually settles in 15-20 rounds, so the 4x version never does almost any of the work. The measured gain is 37×.
The fill allocates nothing.b.every instead of b.map.
map allocates a fresh 64-element array on every call and throws the
result away; every allocates nothing.
That is what 24 bytes buy. The rest of the rule's text is byte for byte the
same in both versions — the only thing that differs is when it stops.
Alternatives
Roads tried and eliminated
The engine's board representation is a flat, square-centric array
of 64. No padding, no 0x88, no bitboards. That is not a default — it is what was left over.
Everything below was either written or measured, and all of it was eliminated.
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 more fatal: 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 engine's present h and
v come from here, and one move closed both problems at once — the
collision, because (3,4) and (0,5) are now two different pairs; and the direction, because
f<i^g carries a signed comparison. The residue of the abandoned approach is
still visible: 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.
Padded board and index delta — one and the same decision
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, or
from per-piece move-vector tables, was tried.
The difference between the two formulations is not merely speed but kind. The chosen road
scans: for a source square it walks all 64 targets one by one and asks each “is this
shape legal?” The delta road generates: the piece type carries its own direction list
and produces targets with f = i + offset. Whether each generated target is
actually on the board is then a separate question — with the knight on h2,
+10 throws you to the other side of the board.
On a flat 64 that question goes unanswered. The delta 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 delta
road falls back into the shape test. Delta only acquires meaning on a padded board:
0x88's 128 cells or the sentinel border of a 10×12 mailbox absorb the wraparound and make
the difference unambiguous. That is why the two were tried together.
Both came out noticeably larger, and the reason is simple: in this engine there is no
problem for padding to solve.G works from-to and generates no offsets —
L walks the targets with b.map, so f is not generated
but an already existing square. With no arithmetic that could leave the board, wraparound is
impossible too; across a full scan in the opening position, the number of off-board indices
reaching G is zero. The only exposure is S's ray walk, and that
closes for free: b[i]<1 is false for
undefined.
What is more, in this scheme padding does not remove a test; it adds one. Under
0x88 the array grows to 128 cells, L walks 128 targets and has to filter half
of them out — a check that does not exist at all on a flat 64. The initial literal doubles
as well. The free edge test that padding sells finds a buyer only in a move generator that emits
offsets; here no offsets are emitted.
The cost of scanning is not as heavy as one might suppose either, because it is not a separate
loop: L walks b anyway while building the move list, and
b.map reads the piece code and supplies the target at the same time. In the
opening, a single Z() call runs G 192 times — the theoretical
ceiling is 4096, and early exits close the gap.
The trade would also have been incomplete. A delta table could only have replaced
G's shape half; whether a sliding piece's path is blocked still has to be
tested by walking, because blocking is a question independent of the representation. So the
cost of the padding and the table would be paid and S would remain. The
structure that survives is therefore flat 64 + G's shape test +
S's linearity walk.
The converse is not true: a padded board does not require vector tables, and
h / v could have run on top of 0x88 too. But the only reason to
pay padding's cost is to use deltas. The two were not eliminated separately; they were
eliminated together.
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 JS'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 in the
form b[…] in eighteen places — 13 reads, 5 assignments — and walks it a further 6 times with map, some and indexOf. 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 200 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.
Ghost en passant — moving e onto the board
The en passant square is a global of its own: e, a square index,
-1 when there is none. Marking it on the board instead of keeping it apart was
tried. The idea is clean: the position becomes the single source of truth, the repetition
key shortens from b+t+e+c to b+t+c, and a letter comes free.
The half-nibble encoding leaves three values unused:
1, 14 and 15. Only 1 is cheap, and the
reason is G's branch chain. The code separates piece types with
p>>1: 1>>1 is zero, so it lands in the last branch,
where an empty square already lands and the expression is always false. 14 and
15 give 7 — the first branch, the knight test. A ghost
there attacks like a knight and puts a phantom check on the king from eight squares; on top
of that the glyph string has no seventh character. 1, by contrast, comes free
in four separate places: it threatens nothing in G, it lands on the blank in
the glyph table, it silences the dead-position scan by itself, and it
does not disturb the material count — if a ghost is on the board a pawn is too, and the
count already sees enough material.
That is the beautiful part of the idea. It is also the only one. What loses fits in a
sentence: while e was a variable, where the en passant square sat
was free; buried in the board it has to be searched for on every move. The gains are
scattered and small — each is one use of the letter e disappearing. The cost
collects in two items.
Item
Bytes
e's initial value
−7
The repetition key, in two places
−4
The guard that silences the scan
−3
f==e on G's pawn diagonal
−5
The legality trial's snapshot, in two places
−4
The line that sets the en passant square
−5
Gain
−28
The line that rescues the forcing bit
+9
The clearing chain and the b[f]==1 test
+19
The guard on the counter reset
+2
The emptiness test, !p → p<2
+1
The guard on picking up a piece
+2
Cost
+33
Net
+5
+19 — clearing the old ghost. With e, clearing was a single
assignment. With a ghost the board has to be searched:
b[i]=b[b.indexOf(1)]=b[P&b[f]==1?f^8:i]=0
The assignment that writes the destination square also has to move down: in the original
the destination was written before the b[f]==1 test, and without the
reorder an en passant capture reads its own piece and clears the wrong square. Why the chain
works at all is subtle — JavaScript resolves assignment targets left to right and assigns
right to left, so indexOf and b[f] still see the old values. The
clearing also has to sit inside the raw move: a variant that passes the square in
from outside and searches once a layer up saves 2 more bytes, but every path that calls
the raw move without going through that layer — and the legality trial calls it exactly
that way — leaves a stale ghost behind.
+9 — rescuing the forcing bit. The trigger of the dead-position scan does three
jobs at once: silence the scan while en passant is open, force it once en
passant lapses, and force it once a check lapses. The first comes free with a ghost —
with a 1 on the board the scan already leaves through its own first gate, so no
separate guard has to be written, 3 bytes gained. But the second was hidden inside
e and a search has to take its place, 9 bytes lost. A net +6 from
those two lines alone.
Why the silencing is needed is interesting in itself: the scan grows the squares a pawn
can advance to vertically only, and cannot see a pawn jumping sideways to promote on a
neighbouring file. With en passant open, the capturing pawn lands on the file the opponent
has just vacated, and its path there is permanently clear — promotion is possible,
mate is possible, the position is not dead. So “where there is en passant there is no lock”
is not a conservative guess but a provable proposition, and the silencing closes this one
blind spot in the scan's model. Without it a classic locked wall gets declared dead by
mistake: a single double step waiting at the edge of the chain queens through the file that
the en passant capture opens.
Code base
En passant method
Bytes
Difference
Mate and stalemateno dead position
e index
1,598
—
ghost
1,600
+2
Counters and dead position
e index
2,324
—
ghost
2,329
+5
ghost + the indexOf alias
2,326
+2
In the mate-and-stalemate build most of the items a ghost could win are absent to begin
with — no repetition key, no silencing, no forcing bit — while the clearing chain stays
exactly as it is. In exchange the en passant mechanism vanishes there completely: no
separate f==e rule is needed on G's pawn diagonal, because the
ghost already reads as a capturable target. That is where the idea looks cleanest, and it
is also where it loses most plainly.
The alias story is instructive in its own right. Setting up an alias for
indexOf costs 12 bytes and saves 5 per use — break-even at three uses. In the
two shipped builds indexOf appears in a single place, where the alias is a 7-byte loss. The ghost creates the third use itself: the search that finds the
king's square, the search that rescues the forcing bit, and the search in the clearing
chain. It is the only file that crosses the threshold, and there the alias gains 3 bytes. So this is not an independent win but the ghost partly clawing back its own damage —
and it shows the real rule of the tool: an alias is not a lever but a threshold, set by
the number of uses.
Build
indexOf uses
Effect of the alias
Mate/stalemate, e index
1
+7
With counters, e index
1
+7
Mate/stalemate, ghost
2
+2
With counters, ghost
3
−3
There is a speed cost as well. Perft d3 from the starting position runs
25% slower in the ghost builds. It comes from two places: an extra board scan on
every raw move, and the dead-position scan walking 64 squares for nothing before leaving
through its first gate whenever a ghost is on the board. In the interface it cannot be
measured — a click already takes microseconds — but it shows up in testing, and it says the
idea is not free.
Not adopted. Break-even or losing in all three code bases, and slower. The final
irony is this: the ghost frees the e and f slots, but both give
>>1 = 7 and cannot be used without adding a guard to G's
hottest expression. The only cheap free slot in this encoding was 1, and the
ghost spends it to hand back two that cannot be spent.
Its correctness is not in question: across seven positions the perft counts come out
identical to the index-based builds — the starting position, four standard CPW positions,
and two positions built specifically to be dense in en passant. It was eliminated on price,
not on error.
The figures above belong to the code base as it stood before the trigger of the
dead-position scan was tied to the n<2 window; the ghost
was measured against that generation. The new trigger closes the ghost's last refuge as
well: the thirdindexOf use, the one that made the alias profitable, sat
on the very line the trigger removed. Without it the ghost is down to two uses and the alias
falls back below the threshold.
Two more attempts on the free slots
The ghost raises the question of whether the values left unused on the board can be put
to work. Two more things were tried with the same question, and both hit the same wall.
Classifying empty squares by threat. Could 0, 1,
14 and 15 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 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 current code 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, eight separate emptiness tests in
the code break — 16 bytes before a line of map maintenance is even written.
Moving the castling rights onto the board. The same idea applied to
c. Here the wall comes earlier: there is 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 already 27 bytes and so tight that the
string itself acts as the table — squares falling outside its range return zero for free
through undefined → NaN → 0. 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. 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 bit
and the engine uses it simultaneously as a truth value (p%2==g), an XOR
operand (t^=1), an addend (l(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 can do only the last of these, and a signed
direction is meaningful for one piece only. The pawn carries no separate variable today:
f<i^g asks whether the destination index is lower than the source and XORs
that with the colour bit — direction and colour in one expression.
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,
printing 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.
Packing
The two packed files
There are two packed builds: dom_packed.html at 2,728 bytes and
numerical_packed.html at 1,815. Both are self-extracting RegPack output — the
body of the file 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 not the plain file. What RegPack looks for is not brevity but
repetition, so the packed source is longer than plain: 2,238 bytes instead of 2,145
in L3 numerical, 3,368 instead of 2,796 in L3. The difference falls
under three headings — aliases expanded, functions inlined, operators widened — and in
L3 there is a fourth: obsolete markup.
Aliases and inlined functions
File
Alias expanded
Function inlined
Kept as a function
numerical_packed.html
a=Math.abs3 sitesQ='indexOf'4 sites
VZADF
GlLCMIHXJ
dom_packed.html
a=Math.abs3 sitesQ='indexOf'5 sitesN='innerText' — the one alias not expanded
ZH
GVlLCMIXJAFSd
X and J stay functions in both files; both are recursive, so
inlining them is not an option. V and l, on the other hand, differ.
l's body is a single call to V, so inlining one multiplies the
other's call sites. In L3 neither is inlined: l ends up with 7
call sites and V keeps its 3. In L3 numerical the choice goes
the other way — l stays a function and V, with 3 call sites in
the plain source, is inlined away.
Operators are widened wherever only the truth value is used: in L3 numerical&& goes from 25 to 37 and || from 10 to 14; in
L3 from 28 to 42 and from 14 to 19. In the engine, colour extraction is written
%2 rather than &1. Naming is preserved —
reassignVars is off, and in both files the globals sit on the same letters as
in plain.
That expanding an alias pays is specific to Math.abs; it is not a general
rule. In L3 numerical all 64 subsets of the aliases h,
v, d, y, P and g were
tried, and every one of them made the packed file bigger. The cheapest expansion
adds a byte to the baseline, the dearest twenty-six. Going the other way lands in the same
place — re-aliasing Math.abs costs 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 is the exception because it occurs in only three places and its
expanded form merges with the text beside it — Math.abs( — into a longer
shared pattern than it would make 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.
The L3 exception — obsolete markup
dom_packed.html is not the packed form of today's L3.html; it
comes from an earlier generation of L3. The clock and the
signature are the same in both — the recovered source opens with
c=15,U=[600,600],N='innerText',e=Y=-1,t=1 and measures elapsed time from the
U[2] stamp with new Date. The difference is in the markup: the
packed build prints the board and everything round it with document.write, using
methods today's plain build no longer contains.
Topic
L3.html
dom_packed.html
Markup
Static HTML; table body via T[N]=
All of it from a document.write template
Background
body{background:#ccc}
<body bgcolor=#ccc>
Centring
#T{margin:auto}
<center>
Cell spacing
*{border-spacing:0}
<table cellspacing=0>
Cell
<td>, td{width:54px}
<th width=54>
Square colour
E.background=
p.bgColor=
Margin
margin-left:36px
margin-left:36unitless — quirks
Writing text
innerHTML, through the N alias
innerText, through the N alias too
Table
carries id=T
no id; the body lives in the template
Font family
_
a
The globals match, but a few local names diverge. The render function is an expression
rather than a block and its parameter is p, not _; the
b.map indices in L and I converge on w;
G's ray walker is S=T=> rather than S=Q=>; and
the render's J, E and s temporaries are gone, with
everything going through p.
Two of these shadow something. S=T=> covers G's own T
parameter. d=p=> covers the promotion picker element for the whole body of
the render, and the b.map((p,w)=>(p=this[w], … )) inside takes the same
letter again. Both are harmless: the shadowed value is never read in that scope — the picker
appears only in A and S.
RegPack settings
The version is v5.0.4, from the GitHub tag. Taking the package from npm is not
enough: things stopped at 5.0.1 there, and that release is short in two ways. It has no
tokenCost, so it never accounts for a token that needs escaping costing two
bytes inside the regexp class; and it cannot handle modern syntax — the output of 5.0.0
dies on ||= with Unexpected token '='. The source has to come
from the repository.
5.0.4 has a habit of its own: it trims whitespace inside a template literal. On its own
that was reason enough to rule the version out; it no longer is, because
one space written as an escape settles it.
Setting
numerical_packed.html
dom_packed.html
reassignVars
false
crushGainFactor
0
3
crushLengthFactor
0
1
crushCopiesFactor
0
0
crushTiebreakerFactor
0
Winning stage
stage 1 — packToRegexpCharClass
Wrapper variable
Gno swap in either
BOM
none
EF BB BF
Body / file
1,798 / 1,815
2,708 / 2,728
The two packed files are not files whose source can be copied off the screen. Their
dictionary keys are control characters in the \x01–\x1f range;
the moment they pass through a clipboard, a text box or an editor's line-ending
normalisation they vanish or turn into something else, and the file never opens again.
For these two, downloading is the only correct route. The plain builds carry no such
restriction.
gain = count*(len - tokenCost) - len - 2*tokenCost // tokenCost: 2 for \, 1 for the rest
score = gainFactor*gain + lengthFactor*len + copiesFactor*count
The three coefficients only mean anything as a ratio; multiplying all of them by
the same positive number changes nothing. The default ratio (2/1/0 with a
tiebreaker of 1) is optimal for neither file, and the optimum moves with the
file. The sweep shows it: in L3 numerical zeroing every coefficient wins,
while in L3 the length coefficient has to stay in play.
The wrapper row comes from a constraint: RegPack hard-codes the unpacking loop's
variable as G in its own source, no option changes it, and G is
our geometry function. Even so, neither file needs the letter swapped: the unpacking loop
finishes before eval(_), the unpacked code writes its own value to that same
letter, and nothing reads G in between. Not a collision — a question of
order.
Repacked with v5.0.4 under these settings, the recovered source reproduces both
filesbyte for byte — dictionary character class, candidate ordering and
recovered source included. As it stands, packing is a reproducible step.
gain / length / copies / tiebreak
numerical
dom
2 / 1 / 0 / 1RegPack default
1,800
2,713
0 / 0 / 0 / 0
1,798
2,722
1 / 1 / 0 / 0
1,800
2,714
2 / 1 / 0 / 0
1,800
2,713
3 / 1 / 0 / 0
1,799
2,708
0 / 1 / 0 / 0
1,980
2,944
In dom the gain settles onto a plateau at 3/1: four, five and
six all return the same 2,708, so the smallest integer that reaches the plateau was taken.
Turning up the length coefficient on its own (0/1) is a disaster in both
files — it means chasing long patterns with no regard for what they gain.
Keeping reassignVars off is more than a preference. With it on, RegPack
redistributes the single-letter names by frequency and, taking the never-read
Y for a free letter, shifts it — breaking
the signature embedded in the source. With it off the recovered
source stays byte-identical to the input and not one identifier moves. The cost was
measured: allow the renaming and numerical would come to 1,789,
dom to 2,694. Fidelity costs 9 bytes and 14.
The option is called varsNotReassigned. Pass
varsNotReassignedRaw instead and RegPack skips the transformation block and
ignores the option in silence. The command line holds a trap of its own:
--reassignVars false does not work, because minimist hands the
value over as the string"false" and RegPack reads it as true.
Renaming stays on, the signature quietly disappears, and since the file comes out a few
bytes smaller it is not easy to notice. Either write
--reassignVars=, or pass the options through the API as a real boolean.
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.
A meaningful space inside a template literal must be written as
\x20. The same cleanup eats this space in the dom source:
onclick=S(id) id=${w} → onclick=S(id)id=${w}. The unquoted
onclick value becomes S(id)id=0 and swallows the id
attribute; not one of the sixty-four squares keeps an id,
this[w] comes back undefined, and the board is never drawn at all. Under Node
the tests never see it, because document.write there is a stub — it breaks
only in a browser. The fix is 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 fix is needed
either way.
The markup prefix must stay outside the packing. Static HTML that precedes the
<script> is not fed to the packer; if it were, the recovered source would
no longer be JS that eval can run.
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 — which is why moving the render's parameter and the font family off _ in
L3 makes _ a token.
dom holds the live example of the other direction. The
\x20 written into the template literal puts a
backslash into the source, and \ drops out of the pool that moment — it was
the dearest candidate anyway, costing two bytes inside the regexp class because it needs
escaping. A three-character fix does not stop at bytes: it takes a letter out of the token
alphabet too.
File
Printables absent from the source
Token class
numerical_packed.html
" # @ A F \ _ j
[\u0001-\u001f @ A j _ F #]six of the eight
dom_packed.html
" @ E H _
[\u0001-\u001f _ H E @]four of the five
What gets checked after packing
Packing makes the source unreadable, so there is no judging the output by eye. These
are the checks run on both files:
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 are in
place; not one identifier has shifted.
perft. Five standard positions — the start position, kiwipete and positions
three, four and five — to depth four
(perft: the standard correctness test that counts every move sequence
from a position), all passed.
Lock-step equivalence. The new pack and the previous generation were run
through the same sequence of inputs and clicks, and at every half-move b,
e, c, t, n, $,
o, z — plus, in dom, the hidden state of the
promotion picker — were compared. 1,452 steps in numerical, 3,501 in
dom: 0 divergences.
Generated markup.dom's document.write output is
identical to the previous generation's, down to the space inside
<th width=54 onclick=S(id) id=0>.
Play. 80 directed games, 112 promotions;
W#, B#, SM and DP
endings all seen, 0 invariant violations.
Anyone repacking should run three of these without fail: the signature,
the generated markup and lock-step equivalence against the previous pack.
A smaller size on its own says nothing — a file that has lost its signature also comes out
a few bytes shorter.
Compatibility
Obsolete techniques and quirks mode
The engine side is modern: BigInt literals, template literals, the logical assignment
||=, default parameters. The one old place is the packed L3, and
even there 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.
The whole load sits in dom_packed.html. Not one of the eight plain
builds carries a single obsolete element or attribute; the ones that draw a board write their
markup statically and leave the measurements to <style>. The “modern
equivalent” column below is an
inventory of what today's L3.html actually does; the “in the source” column
belongs to the generation before it.
The obsolete ones
What
In the source
Status
Modern equivalent
Cost
<center>
<center>
Obsolete element
text-align:center, on the * rule
+10
bgcolor
<body bgcolor=#ccc>
Obsolete attribute
body{background:#ccc}
+2
cellspacing
<table cellspacing=0>
Obsolete attribute
border-spacing:0, on the * rule
+3
width
<th width=54>
Obsolete attribute
width:54px, on the td rule
+2
.bgColor
p.bgColor='#c91'
Obsolete DOM property
E.background
+1
document.write()
printing all the markup
Deprecated
Static markup + innerHTML
—
with
packed only, RegPack's unpacker
Forbidden in strict mode
none
—
The cost column is what the shortest equivalent would cost in the source, before
packing — 18 bytes in total. It does 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 today's plain L3.html is 2,891 bytes2,470 of packed script + 404 of static markup + 17 of tags; the shipped older
generation sits at 2,728, and the 15 bytes of <!DOCTYPE html> fall
outside the packed payload and land directly on top of that.
Reaching for an obsolete technique remains not a preference but a last resort, turned to only
when there is no room left — the moment room appeared, the plain build dropped all
six.
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(). dom_packed.html prints markup rather than script,
and does it during parsing, so it falls outside the intervention. It does still block the
parser. Today's L3.html does not call document.write at all —
its markup is static and only the table body is written, through
innerHTML.
with is present in both packed files, 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
None of the nine HTML files contains <!DOCTYPE html>, so all of them
open in quirks mode. That is not an oversight but a 15-byte-per-file choice. For the
three JavaScript files — engine.js, engine_4x.js and
engine_onlyMoveGenerator.js — neither a DOCTYPE nor a rendering mode means
anything.
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 two classic quirks behaviours people reach for change nothing here either. The
first is the box model: it is widely believed that the old IE model still applies
in quirks, but in current browsers a table cell is content-box in both modes. Measured:
with <th width=54> and the default 1px padding the cell comes out
56×56 — 54 of content plus one pixel on each side, exactly the number standards
mode would give. The second is tables not inheriting the font from the body; that one is
moot as well, since every file opens with the universal selector and gives the cells a
rule of their own.
L3.html opens in quirks mode but does not depend on it. The file
contains no unitless length, no hashless colour and no presentational attribute; the only
difference between the two modes is none at all: the 56×56 measured above comes out the
same either way, leaving not even a digit to compensate. A
<!DOCTYPE html> could therefore be added today for 15 bytes,
without shifting a single pixel.
The one file that genuinely depends on quirks is dom_packed.html.
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.
V.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=V=>V.which!=13 is 3 bytes per file shorter
than writing V.key!='Enter'. Because the hook exists only in the
L3 input family, 6 bytes were left on the table — L3 prompt
and L3 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:
this[w] in L3, T and x in the text
drivers.
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
L3 input_blindfold, and that one already uses the non-parsing
innerText. L3 takes only clicks; no input text enters
L3 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.
Parsing
How a driver reads a move
The engine offers six calls and all of them speak in square indices:
for A(i,f,u), both i and f have to be numbers in
0–63. What the user supplies is letters, digits or a click. The conversion between the two
is the driver's job, and it runs both ways: a move in, a result out. Where the
input formats are described only the user's side appears; here is the
code.
There are three parsers and each is a single line. What they have in common: none of them
validates. An invalid input does not reach an error branch, it becomes a number that is not
in the L(i) list — and falls through there in silence.
The UCI trio — k
The L3 input family and L3 prompt, three builds in all, carry the same
parser. A square is two characters: the file letter and the rank digit.
k = V => K.charCodeAt(V) + 8 * K[++V] - 105
Three tricks are stacked on top of each other. The first is type mixing.K.charCodeAt(V) returns a number, while K[++V] is a one-character
string. Multiplying it by 8 forces JavaScript to coerce that string to a number
— no separate parseInt is needed, and the multiplier is already the rank's
weight in the index.
The second is a side effect.++V increments the parameter in place, so
a single call reads two characters. The caller only supplies where the pair starts:
k(0) is the origin square, k(2) the destination.
The third is the constant.105 did not fall from the sky: the code for
'a' is 97, and the first rank contributes 8*1. Their sum lands a1
on zero, and a1=0 indexing is the engine's layout anyway.
e2e4
charCodeAt
8 * digit
−105
Square
k(0) → e2
'e' = 101
8*'2' = 16
−105
12
k(2) → e4
'e' = 101
8*'4' = 32
−105
28
Because the input passes through toLowerCase() first, E2E4 and
e2e4 give the same square. Case-insensitivity is not a separate rule but a
by-product of one call.
The two-digit number — L3 numerical
L3 numerical has no k, because it does not need one. Its squares
are already numbers from 1 to 64, and the conversion finishes inline.
i = K[0] + K[1] - 1
f = K[2] + K[3] - 1
+ here is not addition but concatenation: K[0] and
K[1] are one-character strings, and '5'+'6' is '56'.
The -1 immediately after does two jobs — it coerces the string to a number and
shifts the 1–64 range to 0–63. String behaviour and then numeric behaviour, in one
expression.
Choosing 1–64 rather than 0–63 costs exactly those two
characters. In exchange the user never has to count from zero.
Clicking — a two-phase selection
In L3 there is no text to parse; there is a state machine instead. Each of
the sixty-four squares carries onclick=S(id) and, thanks to
named access, passes its own id.
S = u => z || p.hidden && (u ^= K,
~i && ~L(i)[Q](u)
? (f = u, b[i]>>1 == 4 & u%56 < 8 ? p.hidden = 0 : A())
: b[u] && b[u]%2 == t && d(i = f = u))
u ^= K turns a screen square into a board square. The render always
draws the board from the point of view of the side to move, so cell zero on screen is not a1
when Black is to move. The mask K = t*49+7 undoes this: ^7 flips
the files for White, ^56 the ranks for Black. It is the click handler's first
act, because everything after it speaks in board indices.
~i is the selection flag. With nothing selected i is
-1, and ~(-1) is zero — so there is no separate flag variable;
i itself carries both the selected square and the question of whether there is
one.
Two phases. If something is selected and the clicked square is in the
L(i) list, the move is made. If not — or if nothing was selected — and the
clicked square holds a piece of the side to move, the selection moves there. Clicking the
wrong square raises no error; it quietly changes the selection or does nothing.
The selection is set with i = f = u, both at once. The reason is the
render: the green outline marks f, so at the moment of selection it shows the
selected square, and after a move the destination. One variable carrying two meanings, both
of them right.
Showing the legal destinations
When a piece is selected, the squares it can reach turn gold. No separate mechanism does
this — the render already calls L.
s = L(i) sits outside the loop over 64 squares. Left inside,
it would be recomputed for every square — the engine's hottest path, sixty-four
times over. Hoisting it out is the only reason the letter s crosses the
engine–driver boundary, and why it appears in the
shadowing table.
With nothing selected, i is -1 and L(-1) returns an
empty array: b[-1] is undefined, the piece type is zero, and
G passes for no target. So the “nothing selected” case needs no branch of its
own — an empty list simply colours no square.
Three visual channels overlap, each answering a different question: gold for
reachable squares, a green outline for the selected or last-arrived square, white
text for the piece's colour. None overwrites another because they write to different
properties — bgColor, outline and color.
Where the promotion panel interrupts
When the move is legal, S does not call A() straight away; it
asks one question first: b[i]>>1 == 4 & u%56 < 8 — is the selected piece a
pawn, and is the destination on the first or eighth rank? If so, p.hidden = 0
opens the panel and the move is suspended there. f has already been
written; the panel's buttons call A(3), A(2), A(1),
A(6), supplying the piece type and finishing the move.
Suspending it needs no state variable of its own, but it does need the board locked, and the
z || p.hidden && guard is there for that. Without the guard, clicking one
of your own pieces while the panel is open runs S's second branch,
i = f = u, with the panel still open; pressing a button then runs
M(i,f) with i == f, and b[i] = 0 immediately after
b[f] = p clears the square just written — the piece vanishes and the turn
passes. Neither exists in chess. p.hidden is truthy at all other times and
zero only while the panel is open, so the guard closes exactly that window. The move stays
half-made until the promotion is chosen — this line is the source of the behaviour
described in the notation section.
The panel has a second door: the promotion buttons call A without going
through S, so S's guard does not cover them. If the game ends
while the panel is open — a resignation, a claimed draw, or a flag — F or the
timer writes z, the panel stays open (only A clears
p.hidden), and pressing a button lets A recompute z
and overwrite it: the game carries on after it has ended.
A = T => z || d(…) puts all four entry points — S,
A, F and the timer — behind the same door.
The draw radio — one element, two bits
L3's draw control is a radio button, and on its own it displays both
offer bits at once.
F = T => z || d(z = T ? j('R') : D(t), x.checked = o & 2-t)
x.style.outline = o & t+1 && 'solid #ff0'
… x.checked ? D() : Z(o = 0) // inside A
Bit 0 of o is White's open offer, bit 1 Black's. The radio reads both,
through two separate channels:
Being checked is your own offer.o & 2-t — with White to move
2-1 = 1, that is bit 0; with Black, 2, that is bit 1. Always the
bit of the side to move.
The yellow outline is the opponent's offer.o & t+1 — with White to
move that is 2, Black's bit. Exactly the reverse. The same element, the same
o, two different masks: one says “I have offered”, the other “I have been
offered”.
It travels with the move. A move played while the radio is checked takes the
x.checked ? D() : Z(o=0) branch. Because D() is
called with no argument it does not touch the bits, it only
evaluates — the offer was already lit by F(). If it is not checked,
o = 0 extinguishes every offer: making a move withdraws an open one.
The same function also serves the resign button: F(1) with an argument,
F() without. One letter carrying two controls.
Promotion: three encodings, one default
The three drivers take the promotion type in three different ways, but all three arrive
at the same place: M's third parameter, that is the
piece type.
Build
Expression
Queen
Rook
Bishop
Knight
UCI family
{r:2,b:1,n:6}[K[4]]
—
r
b
n
L3 numerical
'126'[K[4]]
—
1
0
2
L3
A(3)A(2)A(1)A(6)
♕
♖
♗
♘
The queen column is empty, and deliberately so. The first two expressions return
undefined for any character they do not recognise — no match in the object
lookup, an out-of-range index in the string. When undefined is passed as an
argument, JavaScript falls back on the default parameter, and M's default
is u=3: the queen.
So the behaviour “omit the promotion letter and you get a queen” comes from a language
rule, not from a check. It is the same in all three drivers, and in all three it costs zero
bytes.
L3's buttons are the exception: there is no default there because the user
has to pick one of the four. While the panel is open the move stays suspended, so the
undefined path never arises.
The other direction — writing the result
The engine returns a number: a code from 1 to 15. What goes on the
screen is the driver's business, and here too there are three roads.
L3 numerical does not convert at all — alert(z) prints the figure
itself. In an interface that stays numeric from end to end, turning the code into letters
would be the inconsistency. L3 never produces a number: it replaces
Z, D and the result builder with its own versions, and the string
comes out directly. Only the three UCI builds convert, and they do it in one line.
w = z => z && ' WBS75D53D'[z] + ' ##M5RP0RA'[z]
Two strings sit in parallel and are read with the same index. Put the z-th
character of each side by side and the two-letter code appears — no separate table, object or
branch.
z
1
2
3
4
5
6
7
8
9
' WBS75D53D'
W
B
S
7
5
D
5
3
D
' ##M5RP0RA'
#
#
M
5
R
P
0
R
A
Result
W#
B#
SM
75
5R
DP
50
3R
DA
Character zero is a space in both, but it is never reached: z &&
short-circuits, so while the game continues w returns zero. No branch is spent
on the “still going” case.
The strings are 10 characters long, so the coverage stops at 9. Flag fall and
resignation never pass through here; the driver's own
F=k=>(Xm=2e4,Xs={},X(t,15))?k+'M':'WB'[t]+k builds those — it takes 'T' or
'R' and adds a prefix or a suffix depending on
whether mate is impossible. Two mechanisms, because their inputs
differ: one takes a code, the other a letter.
The nine is not a coincidence either. In the engine, draw by agreement is 15; in
these three builds it is 9. A pair of strings running to fifteen would cost twelve more
bytes, and nine is the first free slot after the 75-move rule. That
the two encodings diverge is not a design preference but a consequence
of fitting this table.
The allocation of slots on the engine side carries the mark of the same squeeze: the low
slots were left to the literal codes so they would fit in a single digit, and the only code
that spills into two digits is the rarest, draw by agreement. The output the user sees is the
same under all three schemes; the difference is only in the inner layer.
Three loop shapes
Who calls the parser also varies from build to build, and that choice directly determines
how the clock is kept.
Build
Structure
Clock
L3 prompt L3 numerical
Blocking loop — for(;!z;), one prompt() per turn
Measured when the dialog closes: new Date - d. Nothing ticks; the elapsed time is computed
L3 input family
Event-driven — onkeyup, on the Enter key
Driven by a timestamp: U[2] is the Date of the last sample and the difference is written to the clock; setInterval samples 10 times a second
L3
onclick per square, plus the promotion and draw controls
Driven by a timestamp: U[2] is the Date of the last sample, the difference is written to the clock; setInterval samples 10 times a second and triggers the render
In the blocking builds the clock does not merely pause, it never ticks — a timer
cannot run while a modal dialog is open. Instead the difference is measured and deducted once
the dialog closes. The result is the same, but this is why L3 numerical's clock is
in milliseconds: since the measurement is a raw Date difference, rounding to
seconds would buy nothing.
The letters engine.js does not use are left to the
driver. The tables below say what those letters actually do across the five drivers, which
names are shadowed, and which are genuinely free. L2 and L2_aybars_2400, which have no player layer, and L1,
which carries no draw rules at all, are outside these tables. The tables to consult before renaming a letter are here; the
procedure itself is under Pitfalls.
File layout
All eight plain builds and all three engine files read as the same four blocks, in
the same order. First the state: c, the clocks, e,
t, the board, the counters and the repetition table — everything that
describes the game at this instant. Then the helpers:
Q='indexOf' and a=Math.abs, abbreviations with nothing to do
with chess, useful only as JavaScript. Then the engine:
G V l L C M I H X J Z A D F, the whole of the rules,
ignorant of input and output alike. Last the driver:
parsing, formatting, drawing, interaction, hooks and
start-up.
engine.js stops after the third block. That is what “no front
end” amounts to in practice.
In two places the boundary moves with the content. A letter finds its block by what
it holds: N is state in the four builds where it is a clock, and a helper
in the L3 family where it is 'innerHTML'. And where
functions are inlined the engine block closes early — in L3 the engine's
A is folded into the interface handler; in L2 and
L2_aybars_2400 the block ends at M. The bot layer of
L2_aybars_2400 sits after the driver, like a fifth block.
The two packed files stand outside this arrangement: their
aliases are written out, so the helper block is empty, and some of their functions are
inlined, so the engine block has gaps.
Driver globals
The 15 letters and three two-character names above are actually used by the five drivers, and the same letter does
a different job from build to build. The three tables below describe the plain
builds; the naming of the packed builds is a separate matter.
The sharpest divide is between L3 and the text drivers. Because
L3 writes to markup, most of its letters hold elements; in the text drivers the
same letters do parsing and timing work. K is the clearest example: the raw
input text in the four text builds, the board's orientation mask in L3.
Letter
L3
L3 input
L3 input_blindfold
L3 prompt
L3 numerical
if
Selected and destination squarei=-1 no selection · f stays on the destination
Origin and destination square
Origin and destination square
Origin and destination square
Origin and destination square
K
Board orientation maskt*49+7
Raw input text+ the replace offset in the render
Raw input text
Raw input text
Raw input text
Sk
—
Square parser
Square parser
Square parser
—squares are resolved inline
Td
Cell element tempthis[w]
—
—
—
—
d
Render function
Render function
Render function
Timestampnew Date
Timestamp
g
Bottom clock element
The side making the move
The side making the move
The side making the move
The side making the move
w
Loop counter+ markup index + clock temp
Result rendercode → two letters
Result render
Result render
—codes return as numbers
x
Draw radio
Input box element
Input box element
Elapsed timeseconds
Elapsed timemilliseconds
y
—
—
Move log
—
Move logthe raw input itself
E
Style temp+ the clock map's parameter
—
Draw = flag
—
—
p
Promotion picker element+ the markup map's parameter
—
Piece code cache
—
—
Q
The 'indexOf' alias — the same in all five
u
Render temp+ S's parameter
—
—
—
—
F
The engine's flag-fall rulingF=k=>(Xm=2e4,Xs={},X(t,15))?k+'M':'WB'[t]+k
Termination flag'T' or 'R'
The engine's own Freturns a numeric code
Bt
Handler for the ½ and ⚐ buttonsBt=T=>z||d(…)
—
—
—
—
S
Square click handler
—
—
—
—
N
the 'innerHTML' aliaswriting to elements
—dead, in the -1 chain
—dead
Black's clockseconds
Black's clockmilliseconds
onkeyup
—
Platform hook
Platform hook
—
—
Two letters live in a single build: u and S, both in
L3. E and p live in two builds each, y
in two. onkeyup appears only in the L3 input family —
the reason is the loop shape.
L3 numerical's two gaps are not accidental. J is absent because,
squares being two-digit numbers, they are resolved inline with K[0]+K[1]-1;
w is absent because this build returns the result code as a number and never
enters the lookup table. Together, the two are part of why the file is the smallest
build.
The unit of x is not the same in the two builds that hold it:
L3 prompt keeps the elapsed time in seconds, while L3 numerical keeps it in
raw milliseconds — since its clock is in milliseconds anyway.
Element ids
Three files carry markup, and all of them reach their elements from JavaScript by named
access: an id can be read as if it were a global of the same name. This is
a lever — not writing getElementById is worth dozens of bytes per file.
File
id
What
L3.html
q
Status line — orientation, C!, M=, R=, result
L3.html
h
Top clock — the side not to move
L3.html
g
Bottom clock — the side to move
L3.html
p
Promotion picker; starts hidden, opens on a promotion square
L3.html
x
Draw radio
L3.html
0–63
The 64 squares; the render walks them with this[w], and a click passes its own id via S(id)
input.html input_blindfold.html
T
Render target, a <pre>
input.html input_blindfold.html
x
Move input, an <input>
prompt.html numerical.html
—
No markup; both are <script> only
Two of these make up the entire 28-byte markup prefix of the L3 input family:
<pre id=T></pre><input id=x>. L3's prefix is 185
bytes and static too; only the ids of the sixty-four squares arise later, from a
template literal written through T[N]=. The same sixty-four
ids are used in
dom_packed.html.
When renaming a letter in this table, the id= in the markup has to move
too — otherwise the named-access binding breaks and the global stays
undefined. How to do it is under Pitfalls.
Shadowings
There are seventeen places where the same letter is two separate things, and none of them
is a bug. The reason is simple: the engine never touches the DOM, and the shadowing side
never reads what it shadows. The two are never live at the same time — by the time one sets
its value, the other has long since finished its work.
Letter
On one side
On the other
g
The engine's colour parameter — G l L I H D F
The bottom clock element in L3; the side making the move in the four text drivers
h
Horizontal distance in G
The top clock element in L3
q
A parameter of L and M
The status line element in L3; the replace match in L3 input's render — (q,K)=>
Q
The ray walker's parameter in G, an unused parameter in Z
The 'indexOf' alias in every build — as in the engine, so nothing is reused here
S
G's ray walker
The square click handler in L3
p
The engine's piece code — V L M I Z
The promotion picker element and the markup map's parameter in L3; the piece code cache in L3 input_blindfold
x
The adjacent pawn's square in M
The draw radio in L3; the input box in the L3 input family; elapsed time in L3 prompt and L3 numerical
T
The attack-mode flag in G
The render element in the L3 input family; a parameter of A and F in L3
u
The engine's V M A parameter
The render temp and S's parameter in L3
d
The distance parameter in G and M
The render function in L3 input, L3 input_blindfold and L3; a timestamp in the other two
K
The driver's raw input text
The replace offset in L3 input's render; the board orientation mask in L3
E
The draw = flag in L3 input_blindfold
The style temp and the clock map's parameter in L3
CO
The castling-bit function / H's absence test
b.map((C,O)=>) in L3 input and L3 prompt's board render
V
The engine's attack function
White's king square inside J; a parameter of Sk and of onkeyup
z
The result global
A parameter of w
s
The position key A builds
V's colour parameter; the legal target list in L3's render — s=L(i)
N
The engine's field for Black's clock
The 'innerHTML' alias in the L3 family — a helper, not state; never read in the L3 input family
z stays inside the engine and never reaches the driver. That is no longer
true of s: L3's render hoists L(i) out of the loop
and holds it in this letter — left inside, it would be recomputed for each of the sixty-four
squares. The rest sit on the engine–driver boundary, and the great majority arise only in
L3, since it is the only build that prints markup.
The densest shadowing in the file is inside one function. J declares
eighteen locals in its parameter list, and most of them stand on engine names —
W, B, V, K, L,
C, Z, P, Q, N,
S, X. None is read from outside; the rule works entirely on
bitboards of its own, and the shadowing is what keeps it from needing new global letters
at all.
What makes this safe is timing: u, for instance, holds the square index in
L3's render loop while the engine's M uses the same letter as the
promotion type. The two do not collide because the render does not begin until the engine
call has finished. The shadowing list is therefore not an exemption but a
constraint: consult this table before adding a new driver global.
Free letters
The four tables above say which letters are used. This one says the reverse: the letters
that never appear as identifiers in each file. The source of all seven plain files was
parsed and every single-letter identifier collected; what follows is what was left.
File
Free letters
Count
engine.js
Ej_
3
engine_4x.js
Ej
2
L3 numerical
Ej_
3
L3 prompt
Ej_
3
L3 input
Ej
2
L3 input_blindfold
j
1
L3
j
1
The count falls as you move from the engine without a front end to a complete driver:
engine.js has no interface and three letters free; one remains in
L3. Exactly one letter is free in all seven files:
j.
Even that one is not usable outright, 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
j
7
In three — the join call: L3, L3 input, L3 prompt
E
5
In three — engine_4x, L3 input, L3 numerical
_
3
In none of them
No letter passes both tests. j is free as an identifier in all seven files
but fails the second: the join call puts it into the source, as a character, in
L3, L3 input and L3 prompt. _ passes the
second test — it does not occur even once in the three files where it is free — but it can
only be used in those three; for a single name across all seven it does not qualify.
E is narrower still: free in five files, present as a character in three.
The space was not always this tight. X used to be free in all seven files,
and that is exactly why it was held in reserve; when the 6.9 search
arrived it took the letter. J went to the dead position
scan in the same round, and K and w to the locals of those two
functions. What was once the extreme case — k, the direction vector in
G, free in no file and present as a character in all seven — is now the rule
rather than the exception: X, J, K and
w lose both tests as well.
Pitfalls
Pitfalls and things to watch for
Every line of golfed code is a concession, and most of the concessions are invisible.
What follows is what anyone about to change the engine or the drivers needs to know: the
points that break silently when touched, or that are misread at first glance.
The engine's fragile spots
In S it must be b[i]<1, not !b[i]. The
ray walker reads S=Q=>(i+=k)==f||b[i]<1&&S(). For an empty square
the two give the same answer, but off the board b[i] is
undefined: undefined<1 is false, !undefined is
true. Write the second and the walker recurses forever on every unaligned candidate
move.
M is side-effect-free and must stay that way. It does not touch
castling rights, the side to move or the repetition table, because it also runs
insideL's legality trial — L plays each candidate with
M, checks with l and takes it back. For a real move the driver
always calls A; that is what clips the rights, flips the side to move and
writes to R.
L's backup has to be function-local. For each candidate it backs up
the board, the en passant square and the ply counter with p=[b,e,n], and
p is the map callback's parameter — fresh on every iteration. A global backup
would not work, because L calls itself indirectly: L →
M → L again in the en passant check. The inner call would
overwrite the outer one's backup and the board could not be restored.
Z's existence test has to be L(i)+'', not
L(i)[0]. Both are 3 characters; the swap gains no bytes, it only loses them.
Because L appends targets in ascending order via b.map, if a1 is
in the list it is always the first element — and a1 is square zero, so it counts as
false. L(i)[0] would read that piece as having no moves. If every movable piece
of the side to move can go to a1, some returns false: the engine declares a
phantom stalemate if that side is not in check, and a phantom
checkmate if it is. Perft would not see this error — move
generation works flawlessly; the layer that misreads is adjudication.
.length is correct but 4 characters expensive.
Places that are misread at first glance
D() with no argument does not touch the offer bits. The function
opens with D=g=>(o^=2-g,…); if g is not supplied,
2-undefined is NaN, and o^=NaN is o^0,
that is o. The effect is “evaluate only, without disturbing the bits”, and it
is deliberate: during a move L3 uses it as
x.checked?D():Z(o=0).
O's argument is a piece code, not a type. Inside H,
O=u=>!~b[Q](u+g) means “there is no piece with code u+g on
the board”. Since the code is type*2+colour, O(4) tests for the
absence of a rook, O(8) a pawn, O(12) a knight. Queen and bishop
never go through this test at all; both are assessed through the W material
weight and the m bishop square-colour mask that I builds.
s is both key and counter in one expression.A's last
act is $=R[s=b+t+e+c]=-~R[s]. s builds the position key,
R[s] holds the times-seen count, and -~x is the same length as
x+1 but yields 1 on undefined, so the same
expression also creates the table entry on first sight.
The file-edge overflow in [f-1,f+1] is not a bug; it closes in the
second condition of the same line.M's en passant line looks at the
destination square's two neighbours along the rank; if f is on the a- or
h-file, one neighbour spills to the opposite end of the adjacent rank — for
f=24 (a4), f-1=23 (h3). Adding an edge check is unnecessary: even
if a pawn of the right colour stands on the spilled square,
~L(x)[Q](e) eliminates it, because a seven-file horizontal difference can
never satisfy the h*v==1 condition in G's pawn branch. The safety
is not in the neighbour arithmetic but in the legality test behind it; adding an edge check
only loses bytes.
State variables vary by build
The state table describes the engine's general form. Four variables
step outside it, and this is the first thing to break when moving code from one driver to
another. In the two builds without the player layer there is no one left to claim, so
3-fold repetition and the 50-move rule are ruled on without waiting for a claim.
Name
General form
Deviation
UN
White and black clock, in seconds — U=N=600engine.js · engine_4x.js · prompt
L3: U is a two-element array, [600,600], plus the U[2] timestamp that springs into being on the first sample; N is not a clock at all but the 'innerHTML' constant. L3 input and L3 input_blindfold: U=[600,600], with the stamp in U[2] again; N sits in the N=e=Y=-1 chain, a signature letter that, like Y, is never read. L3 numerical: milliseconds, U=N=9E5. L2 and L2_aybars_2400: no clock, U never comes into being
o
Offer bits
L2 and L2_aybars_2400: with no one to offer, the letter is free
z
Game result
A numeric code in engine, engine_4x and L3 numerical; a two-letter string in every other build
In L3's array the index is the inverse of the colour bit:
U[0] is White, U[1] Black. The driver decrements the running clock
with U[t^1] and applies the increment with U[t^=1]+=5 — which,
after the side to move has been flipped, lands on the player who made the move.
The flag test depends on those units too. Both prompt drivers look for the flag
in the product of the two clocks, but with different thresholds: L3 numerical
uses U*N<1, L3 prompt uses U*N<=0. That is not a
matter of style. L3 numerical keeps its clock in whole milliseconds, so there the
product is either at or below zero or at least one, with nothing in between;
<1 is a byte shorter and safe. The clock in L3 prompt is in
fractional seconds, and had <1 been written there, a scramble with
0.9 and 1.0 seconds left would give a product of
0.90 and declare a win on time with neither flag down. So
<1 is correct only on an integer clock; change the unit one day and the
flag starts falling early, in silence.
The promotion panel is a third way in
It is easy to assume the board in L3 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 A in the markup —
<button onclick=A(3)> — so they never pass through S.
No guard placed on S can stop them.
Here is the hole that opened. When a pawn picks the last rank 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
A 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 vanished from the status line and play carried on.
The guard is A=T=>z||d(…), 3 bytes. The same 3 bytes sit
in F: without them, pressing resign or draw again after the game is over
rewrites the result.
The panel's second gift is worse. Click another piece while the panel is open and
the selection moves there, but the panel stays up; press the queen afterwards and
A now runs a move whose i and f are the same
square — and because M writes b[f]=… before
b[i]=0, the piece evaporates. What stops that is the
p.hidden&& in S: while the panel is open, square
clicks are not processed at all.
L2 closes the same hole differently, and more cheaply. Instead of
p.hidden=0 it writes 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
S=u=>z||(…) locks itself out. It costs nothing, because that build has
neither clock nor buttons — nothing but A ever sets z. Which
is why only the second half of this trap was reachable in L2. The
same trick would not work in L3: with three separate routes setting
z, the -1 would collide with their results.
L3.html: the BOM and the non-breaking space
Some files carry non-ASCII glyphs: the ones that draw a board use the chess pieces, and
prompt.html full-width letters. Every one of them begins with a BOM, and it is
necessary —
the files carry no <meta charset>, and the BOM is the only thing
declaring the encoding.
The second trap is subtler. In the render string of the board-drawing builds, the character for an
empty square is not an ordinary space but a non-breaking space — the first character
of the string ' ♝♜♛♟♚♞' is U+00A0. Convert it to 0x20 and it
becomes collapsible whitespace in the markup, the cell counts as empty and the board
collapses. An editor that “cleans up an unnecessary character” does this all by itself.
Y and the signature
The order of the six assignments that precede b, the one that builds the
board, is not a coincidence. Read their initials and a name signed at the bottom of the
source appears — case does not change the reading:
c=15, U=N=600, e=Y=-1, t=1, b=[...], ...
// c U N e Y t → cUNeYt
The order is preserved in every build that carries a clock — engine.js,
engine_4x.js and five front ends — and it reappears intact when the two packed
files are unpacked. A driver without a clock cannot carry the reading at all — there is no
U and no Y to read.
Y is the one dead letter inside the signature. It is initialised to
-1 and is neither read nor written anywhere after that; its only function is to
complete the reading of the acrostic. That is not waste but the price of the acrostic: five
of the letters are already doing work, and the sixth costs two bytes.
The same letter also names J's parameter, but that is a separate binding
shadowing the global — the force switch. The two do not collide, because
the acrostic belongs to the text of the declaration, not to run time. The practical
consequence is that a driver may take Y for its own use and the signature will
still read.
Anyone removing Y has to move the initialisation of i and
f as well: instead of e=Y=-1,…,i=f=Y, the chain
e=i=f=-1 is required. Two bytes gained, the signature lost.
Without the chain, f stays undefined. Since the render asks “is
this the destination square?” with u^f, the expression falls to
u^0 and square zero appears with a green outline on startup. A silent, cosmetic
bug; it passes the tests and sits on the screen.
Renaming
Changing a letter is not as mechanical as it looks; a blind find-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.html; the 'C!' indicator in
L3, L3 input and L3 prompt, and 'D?' in the
last two; the 'T' timeout
code and the ${…}s seconds suffix in the L3 prompt and
L3 input families.
The id= in the markup. Every letter in the
element id table has to be moved in the markup 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
Identifier nodes, doing the id= migration separately by hand. That
way the literal and template fragments are never seen at all.
After a change
All the perft runs look outward: the measure is FIDE itself and
the engine's history is irrelevant. Once the source is touched the question changes — not
“is the rule right?” but “did this change break something?” The measure is now the previous
build. Five checks exist for 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 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 $ and L(i) 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 the pipeline
source byte for byte.
Perft is not on this list: if move generation was not touched it is not needed, and if it
was, all the runs above rerun from the start anyway.