When I added chess to mympgames.com, I did not want a pretty board that quietly accepts illegal moves. Two people in a room, maybe on slow connections, need the same rules. So I wrote a small, pure rules module and put the server in charge of what counts as legal.
The module is shared by the Node server, the browser board, and the computer's Web Worker. Same FEN, same move list, same endings.
A mailbox board, not bitboards
The position is a 64-square Uint8Array. File and rank come from bit ops; a piece is a type from 1 to 6 (pawn through king), plus 8 if it is Black:
function fileOf(sq) { return sq & 7; }
function rankOf(sq) { return sq >> 3; }
function sqOf(f, r) { return r * 8 + f; }
function colorOf(p) { return p < 8 ? WHITE : BLACK; }
function typeOf(p) { return p & 7; }
function makePiece(type, color) { return type + (color ? 8 : 0); }
I went with this over bitboards because I can read it in a sitting, and legal generation is fast enough for a live game. The test I trust more than any comment is perft. From the standard start position the engine counts 20, 400, 8,902, and 197,281 positions at depths 1 through 4, which matches the published numbers. Positions load and dump as FEN, including the usual starting string, so a room can be snapshotted as one line of text.
Pseudo-legal first, then legality
genPseudo walks the side to move and pushes every move a piece could make if pins and checks did not exist: pawn steps, double pushes flagged F_DOUBLE, captures, promotions to queen, rook, bishop or knight, and en passant when the target square matches pos.ep.
Sliding pieces walk bishop, rook, or both (queen) directions until they hit something. Knights and kings use fixed delta lists. Castling is special-cased at the end, after the ordinary king steps.
That list is only a starting point. A move is legal only if your own king is safe once it is played:
function legalMoves(pos) {
const raw = [];
genPseudo(pos, raw);
const out = [];
const us = pos.stm;
for (let i = 0; i < raw.length; i++) {
const m = raw[i];
makeMove(pos, m);
const ok = !inCheck(pos, us);
unmakeMove(pos, m);
if (ok) out.push(m);
}
return out;
}
inCheck looks up that side's king square and asks isAttacked: pawn captures, knight hops, adjacent king squares, then bishop/queen rays and rook/queen rays. A knight pinned on e2, with a rook on e8 and the king on e1, still generates hops in genPseudo. legalMoves throws them out, because the king would be in check.
makeMove and unmakeMove push and pop castle rights, the en passant square, the halfmove clock, both king locations, and a Zobrist hash. That undo stack is also what a search needs, so the computer can think without cloning the whole board on every ply.
Castling
Four bit flags cover white and black, king side and queen side. A castle is generated only when all of this is true:
- you are not already in check
- the rights bit is still set
- the king and that rook are on their start squares
- the squares in between are empty
- the king does not pass through or land on an attacked square
White's short castle is king e1 to g1 and rook h1 to f1 (squares 4 and 6 in the mailbox). After the move, castleMaskFor clears rights for that king, and also if a rook is captured on its home square.
The unit tests cover the annoying cases: you cannot castle out of check, through check, into check, through a piece, or after the rook has already moved.
En passant
A double pawn push sets pos.ep to the skipped square. On the next turn a neighboring pawn can capture onto that square, and makeMove removes the captured pawn from to - fwd * 8, not from the destination. If any other move is played, the en passant square dies.
The same make/unmake filter stops the classic trap. White king on a5, pawns on d5 and e5, Black rook on h5: exd6 would open the fifth rank and put White in check, so it is not legal even though genPseudo offered it.
When the game is over
outcome is deliberately boring:
- no legal moves and in check → checkmate
- no legal moves and not in check → stalemate
- halfmove clock at 100 or more → fifty-move draw
- bare kings, or king and bishop or knight versus a lone king, or bishops locked on the same colour → insufficient material
- the same repetition key three times → threefold draw
Scholar's mate ends with Qxf7#. SAN is built from the legal list, with file or rank disambiguation when two pieces of the same type can reach the same square, and a + or # suffix after a trial make/unmake.
There is also a King of the Hill variant. After a move, if that king sits on d4, e4, d5 or e5, that side wins, even if a classic game would keep going.
The server is the referee
The browser never applies a move on its own in an online game. A click becomes:
send({ type: 'move', from, to, promo });
The play shell emits that as a Socket.IO game:action. On the server, playMove rebuilds the position from the stored FEN, checks that the seat actually to move sent the action, looks the move up with findMove, and rejects anything that is not in the legal list — "Illegal move", "Not your turn", or "Game over".
If the move is legal, the engine settles the clock (1+0, 3+2, 5+0, 10+0, 15+10, or no clock), applies the move, stores the SAN, and emitRoom pushes a room:update to every player and spectator. The snapshot includes the new FEN, the last move, whether the side to move is in check, remaining clock times, and a fresh list of legal moves as { from, to, promo }.
The board uses that legal list for highlights and for the click handler. I also keep a cheap geometry check in the client so a rejected click can say "pinned piece" or "can't castle out of check" instead of failing silently. The server's list is still the truth. Undo and loading a FEN are allowed in local and vs-computer games only. Online, you cannot rewind your opponent's clock.
Clocks tick on the server several times a second. A flag fall is a timeout win, not something the client gets to declare.
The computer opponent thinks in a Web Worker with the same rules module, then sends a move back through the same game:action path, flagged as the bot seat. Stronger levels can take a moment; the board stays responsive.
Tradeoffs I'd make again
I did not use bitboards or magic attacks. For a browser game with one position at a time, a 64-square mailbox and make/unmake is plenty, and I can debug a weird castle in a test instead of in a bit mask.
I hash the board with a small Zobrist table (seeded, so hashes are stable across runs) and key repetitions by side to move, castle rights, and the en passant square only when an ep capture is actually legal. That is closer to "the same position" than dumping the whole FEN, which also carries the halfmove clock.
Sharing one rules file between Node and the browser means I cannot accidentally ship a client that allows a move the server will reject. The cost is discipline: the module has to stay pure. No DOM, no sockets, no clocks inside the rules. Clocks live in the chess wrapper around the rules.
If you want to poke at it, play a game at mympgames.com/play/chess — versus a friend in a room, pass-and-play on one device, or versus the computer. There are 50+ other free multiplayer games on mympgames.com if chess isn't the mood.
Top comments (0)