Puzzles
Chess Puzzles
Find the winning move in a position from a real game, then the next one, with the opponent answering in between.
On this page
Chess Puzzles are tactics from real games. You get a position and the opponent’s last move, then find the winning line one move at a time, with the opponent answering in between. There’s one daily, Easy early in the week up to Hard on Sunday, and an endless game in five bands of difficulty.
It wears the pencil-puzzle kit’s look, but its puzzles come from a bundle of files instead of an engine, so its server side is a small app of its own, chesspuzzles/ (ADR 0028). Other chapters cover the chess bundle, the referee, the rating arithmetic and the daily pick. This one puts them together.
At a glance#
| Part | Where | What it is |
|---|---|---|
| Rules | games/static/games/chess/engine/index.js |
Bands, shards, judging a move, replaying a solve, moves in words. Legality is the vendored chess.js |
| Bundle | games/static/games/chess/puzzles/ |
index.json and 80 shards (beginner-00.json … expert-15.json) |
| Interface | games/static/games/chess/js/ |
app.js (routes), home.js, play.js, board.js, sheets.js (How to play, Options), store.js, hub.js, bundle.js (shards, Endless), art.js, pieces.js |
| Look | games/static/games/chess/chess.css, games/static/games/chess/pieces/ |
Olive #4d6a1f on the house’s paper; Cburnett’s pieces (BSD) |
| Server | chesspuzzles/ |
bundle.py (reads the shards), daily.py, api.py, referee.py, views.py, models.py (DailyChess, Solve, Miss) |
| Referee | referee/chess.mjs |
Replays a solve under Node, for a mate off the line |
| Tools | tools/chess/ |
bundle.py (the filter), duplicates.mjs, check.mjs, play.mjs, pieces.mjs |
| Pages | games/templates/games/chess.html, games/templates/games/thumbs/chess.html |
The page; the box art (No. D497l, Easy) |
| Tests | tests/test_chess.py |
The bundle, dailies, posting, the referee, boards |
| Design notes | docs/chess.md |
The design in prose, and open questions |
The rules in code#
A puzzle#
A bundle row is [id, fen, moves, rating, themes]. A FEN is the standard text form of a chess position.
games/static/games/chess/engine/index.js fromRow · lines 42–46/** A puzzle from a shard's row: [id, fen, moves, rating, themes]. */
export function fromRow(row, band) {
const [id, fen, moves, rating, themes] = row;
return { id, band, fen, line: moves.split(' '), rating, themes: themes ? themes.split(' ') : [] };
}
line is in UCI, a plain move notation (e2e4, e7e8q). Its first move is the opponent’s, played for you, and you play every other move after it. So you play the side not to move in the FEN, the line has an even length, and you make half of it:
chesspuzzles/bundle.py Puzzle.@property
def solution(self) -> list[str]:
"""The solver's moves: every other move of the line, from the second."""
return list(self.line[1::2])
A move#
chess.js decides what’s legal, so an illegal move can’t be made and never counts. A legal move is judged against the line:
games/static/games/chess/engine/index.js judge · lines 79–87/**
* Judge the solver's move, already played on `chess`, as move `ply` of the
* line: 'right' (the line's move), 'mate' (another move, but it mates, so
* the puzzle's solved) or 'wrong'.
*/
export function judge(puzzle, ply, chess, uci) {
if (uci === puzzle.line[ply]) return 'right';
return chess.isCheckmate() ? 'mate' : 'wrong';
}
- right: the line’s move. The opponent answers with the line’s next move.
- mate: not the line’s move, but it mates. That’s Lichess’s rule (a mate in one often has more than one), and it solves the puzzle whatever the line had left.
- wrong: anything else. The piece shakes and slides back (“Not that one. Try again.”), it counts as a mistake, and you keep playing. Lichess fails the puzzle instead; the design notes leave that open.
A promotion is part of the move (e7d8n), so a queen where the line wants a knight is wrong, unless it mates.
Solved, and Show solution#
The puzzle is solved when your move ends the line or mates. Show solution asks first (“Show solution?”, for six seconds), then plays the rest out. The puzzle ends unsolved and isn’t posted. A hint (H) rings the piece to move; a second press marks its square too. Each press counts.
The referee replays a whole solve with the same judge:
games/static/games/chess/engine/index.js solves · lines 89–109/**
* Replay a solve: the solver's moves, in order, on the puzzle. True if every
* one is legal and right, the opponent's answers in between, and the last
* either ends the line or mates. What the referee checks of a posted solve.
*/
export function solves(puzzle, moves) {
if (!Array.isArray(moves) || !moves.length || moves.length > solverMoves(puzzle)) return false;
const chess = positionAt(puzzle, 1);
for (let k = 0; k < moves.length; k++) {
const ply = 2 * k + 1;
const uci = moves[k];
if (typeof uci !== 'string' || !play(chess, uci)) return false;
const verdict = judge(puzzle, ply, chess, uci);
if (verdict === 'wrong') return false;
const last = k === moves.length - 1;
if (verdict === 'mate') return last;
if (last) return ply === puzzle.line.length - 1;
if (!play(chess, puzzle.line[ply + 1])) return false;
}
return false;
}
The bundle#
ADR 0028 bundles a slice of the Lichess puzzle database (CC0, about six million puzzles) as static files. Endless needs no server, shards (the bundle’s small JSON files) cache like any static file, the server reads the same files, and a remade bundle is a commit. The chess bundle explains how tools/chess/bundle.py filters and samples. In brief:
| Parameter | Value |
|---|---|
| Puzzles per band | 8,000 |
| Rating deviation at most | 80 |
| Popularity at least | 90 of 100 |
| Plays at least | 1,000 |
| One motif’s share of a slice, at most | 25% |
| Candidates kept per slice | 8,000 |
| Shards per band | 16 |
| Solver's moves | Share of a band |
|---|---|
| 1 | 0.12 |
| 2 | 0.48 |
| 3 | 0.3 |
| 4 | 0.1 |
tools/chess/bundle.py, LENGTHSA slice is a band’s hundred-point rating range at one line length. Each hundred gets an equal share of the band, split by length as above. A slice’s candidates are the 8,000 with the smallest SHA-256 of their id, which looks random but is repeatable. choose takes them in order, setting aside a puzzle whose motif (the first of MOTIFS among its themes, else “other”) is over the cap unless the slice can’t fill otherwise. A hundred short of one-movers is topped up with longer ones, and a band still short takes from its other hundreds:
tools/chess/bundle.py choose · lines 134–176def choose(pools):
"""Each band's puzzles: equal hundreds, lengths in proportion, no motif
over its cap (relaxed only if a slice can't fill otherwise)."""
chosen = {band: [] for band in BANDS}
for band, (low, high) in BANDS.items():
hundreds = (high - low) // 100
for hundred in range(hundreds):
before = len(chosen[band])
for length, share in LENGTHS.items():
quota = round(PER_BAND / hundreds * share)
pool = pools.get((band, hundred, length), [])
cap = max(1, int(quota * MOTIF_CAP))
taken, motifs, rest = [], Counter(), []
for entry in pool:
if len(taken) == quota:
break
if motifs[entry[6]] < cap:
taken.append(entry)
motifs[entry[6]] += 1
else:
rest.append(entry)
taken += rest[: quota - len(taken)]
chosen[band] += taken
# Strong puzzles are rarely one move long: a hundred that falls
# short is topped up with the next two-movers, then three.
short = round(PER_BAND / hundreds) - (len(chosen[band]) - before)
if short > 0:
have = {e[1] for e in chosen[band]}
spare = [e for length in (2, 3) for e in pools.get((band, hundred, length), []) if e[1] not in have]
chosen[band] += spare[:short]
print(f"{band} {low + 100 * hundred}s: topped up {min(short, len(spare))} short one-movers with longer ones", file=sys.stderr)
# A hundred with too few good puzzles even so (Beginner's 600s: few
# puzzles that easy are played enough to pass the bar) leaves its
# share to the band's other hundreds, two- and three-movers first.
short = PER_BAND - len(chosen[band])
if short > 0:
have = {e[1] for e in chosen[band]}
mine = [(length, pool) for (b, _, length), pool in pools.items() if b == band]
spare = sorted(e for length, pool in mine if length in (2, 3) for e in pool if e[1] not in have)
spare += sorted(e for length, pool in mine if length not in (2, 3) for e in pool if e[1] not in have)
chosen[band] += spare[:short]
print(f"{band}: {min(short, len(spare))} more from its other hundreds; {PER_BAND - len(chosen[band])} short in all", file=sys.stderr)
return chosen
Duplicates and versions#
Lichess has a few puzzles twice, from games that reached the same position. tools/chess/duplicates.mjs keeps the lower-rated of each group (same position, same moves) and retires the rest in index.json’s duplicates. A retired puzzle stays in its shard, so old dailies and links still open, but it’s never drawn again: Endless skips it, and the daily pick passes over it without shifting the order. npm run check:chess fails on an unlisted duplicate.
The bundle’s version (2, sent by the daily API as bundle) only goes up. A remake carries over the old version and duplicates, adding one if any shard changed, and duplicates.mjs --write adds one if the list changes:
tools/chess/bundle.py carried · lines 192–203def carried(old, changed, ids):
"""What a remade bundle keeps of the index it replaces (docs/chess.md,
"Duplicates and versions"): its version, raised by one if any shard
changed (a first bundle is version 1), and its retired duplicates whose
two puzzles are both still in it. Dropping either would undo a version
the site has served: the daily API would send an older `bundle`, and a
retired puzzle could be drawn again. `node tools/chess/duplicates.mjs
--write` then brings the list up to date with the new puzzles."""
if not old:
return {"version": 1, "duplicates": {}}
kept = {d: k for d, k in old.get("duplicates", {}).items() if d in ids and k in ids}
return {"version": old["version"] + (1 if changed else 0), "duplicates": kept}
Dailies and solves are kept by Lichess id, so a remade bundle never changes a played day. A puzzle the bundle drops just can’t be posted (422, “That puzzle isn’t in this game’s collection”).
Measured#
Measured on 2026-10-19 from the shards. Motifs use the filter’s own MOTIFS; “ends in mate” is Lichess’s mate theme:
| Beginner | Easy | Medium | Hard | Expert | |
|---|---|---|---|---|---|
| Ratings (median) | 600–999 (921) | 1000–1399 (1199) | 1400–1799 (1599) | 1800–2199 (1999) | 2200–2599 (2381) |
| By hundred | 156 / 1,363 / 2,000 / 4,481 | 2,000 each | 2,000 each | 2,000 each | 2,275 / 2,006 / 2,000 / 1,719 |
| Solver moves 1 / 2 / 3 / 4 | 810 / 5,851 / 1,107 / 232 | 960 / 3,840 / 2,400 / 800 | 960 / 3,840 / 2,400 / 800 | 642 / 4,158 / 2,400 / 800 | 12 / 4,045 / 3,143 / 800 |
| Ends in mate | 47.0% | 33.1% | 27.5% | 17.5% | 6.0% |
| Commonest motifs | mate in 2 28%, fork 21% | other 18%, fork 17%, mate in 2 12% | other 21%, fork 13%, mate in 1 11% | other 25%, fork 9%, defensive move 8% | other 33%, defensive move 11%, quiet move 8% |
| Daily pool (2+ moves) | 7,190 | 7,040 | 7,040 | 7,358 | 7,988 |
| Retired in the pool | 1 | 4 | 0 | 1 | 2 |
| Shard sizes | 483–528 | 474–531 | 460–554 | 469–547 | 469–531 |
The bundle is 5.0 MB of JSON (the suite holds it under 6 MB). Bands differ by Lichess rating, a difficulty measured from millions of players’ results, and so by kind of idea: Beginner is half mates, Expert almost never. Expert has few one-movers because strong ones are rare, and Beginner leans to the 900s because few easier puzzles have a thousand plays. Top-ups ignore the motif cap, so Beginner’s mates in two reach 28%.
The daily#
The band#
From 2026-10-20 there’s one daily a day (ADR 0034); earlier days keep their Easy, Medium and Hard. From 2026-10-26 the band follows the week (ADR 0041, the week climbs): Easy Monday and Tuesday, Medium Wednesday to Saturday, Hard Sunday (WEEK_BANDS). Beginner and Expert stay in Endless. Between those dates, the day’s seed picked the band, weighted toward the middle:
chesspuzzles/daily.py band_of · lines 54–67def band_of(day):
"""The band of a day's one daily: the weekday's, from the day dailies
follow the week (docs/adr/0041). Before, by the site's seed for the day
(so it couldn't be worked out ahead and was the same for everyone),
weighted by BAND_WEIGHTS: the seed's place in a hundred falls in one
band's share."""
if daily.follows_week(day):
return WEEK_BANDS[daily.week_level(day, len(WEEK_BANDS))]
at = daily.seed("chess", "band", day.isoformat()) % sum(BAND_WEIGHTS.values())
for band in BANDS:
if at < BAND_WEIGHTS[band]:
return band
at -= BAND_WEIGHTS[band]
raise AssertionError("BAND_WEIGHTS covers every band")
| Band | Days in a hundred |
|---|---|
| beginner | 5 |
| easy | 25 |
| medium | 40 |
| hard | 25 |
| expert | 5 |
chesspuzzles/daily.py, BAND_WEIGHTSThe home page’s card names the band and rating, so a player knows what’s coming.
The puzzle#
chesspuzzles/daily.py pick · lines 30–41def pick(band, day):
"""The puzzle a band's daily on `day` would be, given the dailies kept so far."""
order = _order(band, daily.seed("chess", band))
recent = set(DailyChess.objects.filter(band=band, date__gte=day - NO_REPEAT, date__lt=day + NO_REPEAT).exclude(date=day).values_list("puzzle", flat=True))
start = (day - EPOCH).days
# (a duplicate in the bundle, retired, is passed over like a recent one: the order itself never moves)
skip = recent | set(retired())
for k in range(len(order)):
candidate = order[(start + k) % len(order)]
if candidate not in skip:
return candidate
raise LookupError(f"Every {band} puzzle has been a daily lately")
Each band’s pool (daily_pool: puzzles where you make two moves or more, so a daily is never over in one) is sorted by SHA-256 of the band’s seed and the id, an order only the site’s key can produce. Day n since EPOCH (2026-01-01) takes the nth puzzle of its band’s order. One that was a daily within 1095 days, or is retired, is passed over for the next. The index moves every day whatever the band, so a puzzle returns only after about nineteen years. A pick is kept in DailyChess on first ask, so a day never changes.
Endless#
Endless never asks the server. bundle.next(band, seen) picks a random puzzle from a random shard, skipping retired ones and the last 4,000 this browser opened. It includes one-movers and can serve a puzzle that is or will be a daily (the design notes ask whether to reserve the daily pool).
What the browser holds#
Everything: every line is in the static bundle, so a player could read the answer. The server checks what it can (the moves, a believable time, a miss as it happens). Mistakes and hints are counted by the game, so a clean solve is on trust, like the clock (ADR 0003).
Your move#
The first puzzle shows the house’s first-move prompt (Your first move) after the opponent moves: Find the best move (“You play White: tap a piece, then its square, or drag it”). Once per browser (uo:chess:first-move); the clock waits for it.
Posting and the referee#
A solve posts { id, band, moves, mistakes, hints, seconds }, with only your moves in moves. chesspuzzles/api.py checks the shape (one to eight UCI moves, counts up to 500), finds the puzzle, and checks the time:
chesspuzzles/api.py min_seconds · lines 54–59def min_seconds(moves):
"""Faster than this isn't a real solve: a second a move (the clock
starts once the opponent's first move has landed). The game posts
whole seconds, one at least (play.js, `solved`), so a one-mover seen at
a glance still posts."""
return max(1, len(moves))
Moves that match the line are right without running anything. A solve that follows the line and leaves it only at its last move goes to the referee, which replays it under Node and accepts it only if that move mates. Anything else gets 422:
chesspuzzles/referee.py needs_referee · lines 26–31def needs_referee(puzzle, moves) -> bool:
"""A solve that follows the line but for its last move, which ends the
puzzle early or differently: right only if that move mates."""
line = puzzle.solution
n = len(moves)
return 0 < n <= len(line) and moves[:-1] == line[: n - 1] and moves[-1] != line[n - 1]
Replays are limited to 60 a player an hour. Only a player’s first solve stands, on the day’s board only if posted that day. A player with a Miss for the puzzle gets at least one mistake on the solve, whatever it says.
Misses#
A signed-in player’s game posts a miss the moment of the first wrong move or Show solution, so closing the tab can’t undo it:
chesspuzzles/api.py miss · lines 142–176@require_POST
def miss(request):
"""A puzzle gone wrong, posted by a signed-in player's game the moment it
happens: the first wrong move (its moves so far, checked to follow the
line up to that move and leave it there), or Show solution (no moves to
check). It's one loss for the puzzle against their rating, however the
puzzle ends; the first posted stands (docs/ratings.md)."""
if not request.user.is_authenticated:
return _fail(401, "Sign in to keep your rating.")
try:
body = json.loads(request.body)
pid, band, how = body["id"], body["band"], body["how"]
moves = body.get("moves", [])
except (ValueError, KeyError, TypeError, RecursionError):
return _fail(400, "That submission was incomplete.")
if not isinstance(pid, str) or not ID_RE.fullmatch(pid) or not isinstance(band, str) or band not in BANDS or how not in (Miss.WRONG, Miss.GAVE_UP):
return _fail(400, "That submission didn’t describe a puzzle.")
if not isinstance(moves, list) or len(moves) > 8 or not all(isinstance(m, str) and UCI_RE.fullmatch(m) for m in moves):
return _fail(400, "That submission didn’t describe any moves.")
puzzle = bundle.get(band, pid)
if not puzzle:
return _fail(422, "That puzzle isn’t in this game’s collection, so it can’t be checked.")
# A wrong move is one off the line where the line goes on (any other
# mate would have been a solve, and the game posts it as one).
if how == Miss.WRONG and not referee.needs_referee(puzzle, moves):
return _fail(422, "That isn’t a wrong move in this puzzle.")
if Solve.objects.filter(user=request.user, puzzle=pid).exists():
return JsonResponse({"ok": True, "already": True, "rating": None})
slot = DailyChess.objects.filter(date=today(), band=band, puzzle=pid).first()
try:
found, made = Miss.objects.get_or_create(user=request.user, puzzle=pid, defaults={"band": band, "rating": puzzle.rating, "daily": slot, "how": how})
except IntegrityError:
found, made = Miss.objects.get(user=request.user, puzzle=pid), False
from ratings.service import posted
return JsonResponse({"ok": True, "already": not made, "rating": posted("chess", found, found.user_id)})
A wrong-move miss is checked to be a real move off the line (needs_referee again). A Show solution miss has nothing to check. The first result for a puzzle, miss or solve, is what’s rated.
A miss that can’t be sent (the statuses passing names) waits in the browser and goes on the next load, before any held solve; a 422 drops it. The puzzle is marked missed before posting, so it never posts twice.
Rating: your rating against the puzzle’s#
Every puzzle is one game against the puzzle at its Lichess rating, held certain (Skill(rating, 0)) and rated once when posted. Time doesn’t count. Your rating moves; the puzzle’s never does. Lichess also uses a Glicko scale (a rating system that tracks uncertainty) centered on 1500, so yours reads roughly like a Lichess puzzle rating.
ratings/sources.py Chess.def _event(self, e) -> Event:
item = [Seat(skill=Skill(e.rating, 0.0))]
key = self.own_key(e)
if self.is_miss(e) or e.mistakes:
score, half = 0.0, False
elif e.hints:
score, half = 0.5, True
else:
score, half = 1.0, False
return Event(self.game, key, "puzzle", e.created, [[Seat(user=e.user_id)], item], score=score, half=half, detail={"puzzle": e.rating})
| Result | Scored as |
|---|---|
| Clean (no wrong move, no hint) | A win |
| Hints, no wrong move | Half a clean solve’s gain: the score is (1 + p) / 2, where p is your chance of solving it (Half a win, exactly). One hint or six, the same |
| Any wrong move, or Show solution | One loss, however many wrong moves. A later clean finish counts on the boards but isn’t rated or clean |
The first result posted stands, a miss first on a tie. Daily or endless makes no difference to the rating.
Worked: a week of dailies (guide/worked/chess.py). A settled player at 1600 ± 70, each day rated from where the last left them:
| Day | Band | Puzzle | Result | Chance of solving | Change | After |
|---|---|---|---|---|---|---|
| Mon | Medium | 1520 | Clean | 61% | +10.4 | 1610 |
| Tue | Easy | 1180 | Clean | 91% | +2.4 | 1613 |
| Wed | Hard | 1930 | One hint | 16% | +11.1 | 1624 |
| Thu | Medium | 1710 | A wrong move | 39% | −10.2 | 1614 |
| Fri | Expert | 2310 | Show solution | 2% | −0.6 | 1613 |
| Sat | Medium | 1460 | Two hints | 69% | +4.0 | 1617 |
guide/worked/chess.py, WEEK.rowsA clean Easy was expected, so it’s worth little. A Hard with a hint earns half of a large gain. A wrong move costs about what a clean solve would have gained. Show solution on an Expert costs almost nothing, because the puzzle was expected to win.
The interface#
Screens#
Routes are the kit’s: #/ (today’s puzzle, Endless, puzzles left part way), #/daily/<band>, and #/p/<band>/<id> for any puzzle. The board turns to your side, and after a beat the opponent’s move flies in.
Moving#
Tap a piece then a square, or drag it. From the keyboard, Tab to the board, arrows move, Enter or Space picks up and puts down, Escape lets go; H is hint, P pause, ? How to play. A pawn reaching the far side asks which piece, in a column over the square. Reduced motion lands every move at once.
Helpers and options#
From SETTINGS in games/static/games/chess/js/sheets.js. Every helper is on by default, each with its own opt-out:
| Option | What it shows |
|---|---|
Show where a piece can go (dots) |
Dots on a picked-up piece’s legal squares, rings on pieces it can take |
Show the last move (last) |
The move just played, shaded |
Show check (check) |
The king in check glows red |
Coordinates (coords, a look option) |
a–h and 1–8 round the edge |
These show legality, never the answer. The hint is the only help that knows the line, and it counts.
The clock#
The clock runs only while the board is in view and the puzzle is in play; pause, a sheet or a hidden tab stops it. A sheet hides the board with the house kit’s hideBoard() (the front end). Time is saved every five seconds and on pagehide, and a reload resumes where you were. A solve posts max(1, round(seconds)), matching the server’s floor of a second a move.
Results and sharing#
The result sheet celebrates a new best clean time (or a rating high, once posted) with the house’s best, else a daily with daily (Celebrations). It gives the time, mistakes and hints, the motifs (“Fork · Middlegame · Rated 1778”), an “On Lichess” link, the streak and the rating change. Share result is “♟️ Chess Puzzles · Sun, Oct 19 · Medium”, then a square for each of your moves (✅ found, 👀 shown by Show solution) followed by any wrong tries (❌) and hints (💡), then “🎯 Clean · ⏱️ 0:41” and today’s place once posted. It never names a move. An endless share links the puzzle; a daily’s links the game, so it doesn’t give the puzzle away.
Accessibility#
The squares are named gridcells (“e4, white knight”). Every move, hint and wrong move is announced in words (moveWords: “Knight takes pawn on e5, check”).
Stored in the browser#
All under uo:chess: (games/static/games/chess/js/store.js): each puzzle opened (p:<band>/<id>, the 150 most recent, with moves, mistakes, hints, time and state), options, a tally by band, seen (the last 4,000 puzzles, for Endless), the days a daily was solved, and solves and misses waiting to post.
Leaderboards and stats#
chesspuzzles/views.py:
- Daily: clean solves first, then less help (mistakes plus hints), then time, then who posted first; top 100.
- All time, by band or all: clean solves, ties to whoever got there first.
- Ratings, and the streak: days in a row, ending today or yesterday, with a daily solved that day.
- The account page: “12 puzzles solved, 9 cleanly · 3-day daily streak”.
Its look#
Chess Puzzles’ personality, in the design system’s personality sheet. Everything else is the puzzle book’s (Puzzles).
| Family | Puzzles: the pencil-puzzle kit’s look and skeleton, on the paper, with an interface and server of its own |
| Palette | Its accent, olive #4d6a1f: the one fill, the ribbon, the kicker and the stamp. The squares are #efe8d4, the paper a shade warmer, and sage #a3b07f, the accent washed into the paper. A coordinate takes its square’s own ink: #5b6542 on a light square, #2f3620 on a dark one. Washes, one job each: the last move a warm gold, a picked piece signal blue (yours), check a red glow on the king (its own, --sq-check, warmer than the paper’s red for a mistake). The hint is signal blue too. |
| Faces | The paper’s: Gloock and Figtree. The pieces are Cburnett’s set (BSD), drawn unchanged. |
| Corners and edges | The kit’s; the board has a 2px ink frame. |
| Board | A diagram printed in the book: light and sage squares in a 2px ink frame, coordinates in the corners of the edge squares. A picked piece’s square washes signal blue; where it can go shows as graphite dots, or a ring round a piece it can take. The hint is a signal-blue ring on the piece, then a dashed one on its square. The promotion picker is a column of four paper discs over the square, queen first, on a veil. Your move glides, the opponent’s flies, and a wrong one shakes and slides back; none of it travels under reduced motion. |
| Emblem and art | A white knight in an accent ring. Its box: a real bundled puzzle (No. D497l, Easy): Black has just played Qe6, and the arrow is White’s answer. |
| Voice | A puzzle everywhere; its first-puzzle prompt is Find the best move, and a band is named with its Lichess ratings (“Easy 1000–1399”). |
| Payoff | An accent stamp, “Clean 0:42” for a clean solve (else “Solved”), presses onto the folio. The result sheet celebrates a new best clean time or a rating high with the house’s best, else a daily with daily. |
| The glance | Now: the status line, and a disc of the solver’s color in the folio for whose move it is. The countdown: the clock. |
| Exceptions | The coordinates sit in the corners of the edge squares, under the pieces and against the next square, as a printed board’s do (the overlap and room checks). A square is an eighth of the board, so on a phone it can be under a finger’s 44px (the tap check). |
Testing#
| Check | What it proves |
|---|---|
npm run check:chess (tools/chess/check.mjs, tools/chess/hub.test.mjs) |
Every puzzle sits in its shard and band, is legal, and is solved by its line and not by a near miss. The duplicates list is exact. The rules’ corners. Held misses sent, kept or dropped as they should be. |
tests/test_chess.py |
The bundle’s shape and size, dailies (stable, kept, no repeats), posting (first post stands, off-line moves refused or sent to the referee, bad posts, the replay limit), the referee on a sample, the boards. |
npm run check:chess:play (tools/chess/play.mjs) |
Headless Chrome with real clicks and keys: castling, en passant, promotion, a mate off the line, a wrong move, returning mid-move. It finds its puzzles by what they need, so a remade bundle doesn’t break it. |
Changing it#
- Remake the bundle: download the dump outside the repo,
zstd -d, runpython3 -I tools/chess/bundle.py <csv>, thennode tools/chess/duplicates.mjs --write,npm run check:chessand the suite. Played dailies keep their puzzles by id. - Band weights:
BAND_WEIGHTSinchesspuzzles/daily.py. Kept days don’t change. - The rules or the engine:
games/static/games/chess/engine/index.jsis shared by the game and the referee. Runnpm run check:chessandnpm run check:chess:play. There’s no engine version, because a solve names a puzzle id. - The board’s look or input:
games/static/games/chess/js/board.jsandchess.css; runnpm run check:details.
Known limitations#
- Mistakes, hints and time are on trust. The server can check the moves and a miss, not how much help a solve had.
- The answer is public. Every line is in the static bundle.
- No engine analysis. Only the line, or a mate, is right. Another winning move that isn’t mate is wrong, as on Lichess.
Known issues#
None open. The five found while writing this chapter were fixed in October 2026.