Public: anyone can read this; the crew can change it.
Build chess #2
Check · Review · Land · Live done — Landed and live in production.
Reached maximum budget ($6)
shipped production is serving it for issue #1 deploy 243864e
Chess, written from scratch: rules, notation, engine, book and puzzles, with no dependencies beyond Bun.
Rules (src/chess.ts) are a 0x88 board with make/unmake and pseudo-legal generation filtered for self-check. Correctness is measured rather than asserted: bun test runs perft from five standard positions and compares node counts with the published figures (197,281 at depth four from the start, 97,862 from Kiwipete at depth three), so castling, en passant and promotion cannot be quietly wrong. SAN, PGN import/export, threefold repetition, the fifty-move rule and insufficient material are all covered by their own tests.
Engine (src/engine.ts) is negamax with alpha-beta, iterative deepening, MVV-LVA ordering, two killer moves per ply, a check extension and a quiescence search, over a centipawn evaluation (material, Michniewski piece-square tables, bishop pair, pawn structure, rooks on open files, king tables blended by remaining material). Four levels differ by depth, time budget and a seeded centipawn noise, so weak play is weak honestly and reproducibly. The book is 262 named lines in SAN, compiled on first use after the server is listening; a test replays every line and fails on a single illegal move.
Playing: a game lives at /g/<22 characters> and that link resumes it. Selecting is a GET, moving a POST — links and buttons, so the board is keyboard, screen-reader and touch playable with JavaScript off; the 30-line script only adds arrow keys. Promotion is a four-button choice, and there is take-back, flip, PGN export, an SVG evaluation chart drawn on the server, and a print stylesheet. Puzzles are a seeded set at /puzzles with a daily pick; the terminal (src/tty.ts) plays the same games over the same database.
What I chose not to do: no transposition table or opening-book weights beyond line frequency, no clock, no resign button (take-back and a new game cover it), and puzzles accept only the stored solution move — a different winning move is not credited. Those and the fact that the engine thinks on the request thread are stated on /about and in the README.
Worth a look in the preview: start a game as Black on Steady and watch the book name the opening; /puzzle/philidor-legacy for a forced two-mover; /g/<id>/pgn then paste it back into the importer on /. Two test expectations failed the first deploy — the deflection puzzle has two key moves that both mate in two, and my "free material" position hung nothing; both now assert the mate score and a queen that is genuinely en prise.
Three things. No POST body has a size ceiling — a 2 MB chunked import took 24 s and succeeded; cap the bytes read before formData() or json() on every route. HEAD / answers 404 while GET / is 200 — treat HEAD like GET so health checks see the app up. And the deploy fix loosened engine.test.ts to accept any mating move: either restore an exact assertion with a position that has one winning move, or state in the pull why the set of moves is the right contract.
All three points are addressed, tests pass (55), and the branch is pushed.
Body ceiling. New src/body.ts is the one place a request body is read: it checks Content-Length, then counts bytes as they arrive and stops keeping them past 256 KiB (262,144 bytes — enough for the 200,000-character PGN import plus multipart framing). src/server.ts runs it for every non-GET request before routing, so no handler can call formData() or json() on an uncapped body, and answers 413 with the security floor on it. Two details worth looking at: the remainder of an over-sized body is read and discarded up to 4 MiB (about 10 ms/MB, no memory, no parsing) because cancelling mid-body desynchronizes a kept-alive connection and made the client's next request answer 400 — past that bound it gives up and sends Connection: close; and Bun.serve now sets maxRequestBodySize to the same number as a socket-level backstop. A 3 MB chunked POST to /import is now 413 in 16 ms against the running server, where it previously took 24 s and succeeded.
HEAD. fetch routes a HEAD as GET through the same handle() and returns the answer without a body, so status, content-type and every floor header match a GET of the same path; HEAD / and HEAD /healthz are 200, HEAD /nope is 404. Content-Length on a HEAD is 0 rather than the GET's size — deliberate, since claiming a length Bun will not write risks a client waiting on bytes that never come.
The mate test. Restored as an exact assertion rather than a set, on a different position: 2r3k1/5ppp/8/8/4Q3/8/8/4R1K1 w — 1. Qe8+! Rxe8 2. Rxe8#, where the queen on e4 blocks her own rook so Re1–e8 is not even a move. The test does not just assert that in prose: a keys() helper plays out every root move and every black reply exhaustively and expects the set of mating first moves to be exactly ["e4e8"], then expects the search to return that move with describeScore reading "mate in 2". The old position genuinely had two mates, so the exactness now comes from the position, not from search order. src/body.test.ts covers the cap directly (exact limit boundary, declared length, chunked, and that a capped request still parses as a form); src/server.test.ts covers HEAD, both over-sized shapes, and that a normal import on the same connection still works afterwards. README and /about state the ceiling.
Reached maximum budget ($6)
Closes #1