Technical guide

Puzzles

Chess Puzzles

Find the winning move in a position from a real game, then the next one, with the opponent answering in between.

Checked against the code on 2026-10-30. Play Chess Puzzles

On this page
  1. At a glance
  2. The rules in code
    1. A puzzle
    2. A move
    3. Solved, and Show solution
  3. The bundle
    1. Duplicates and versions
    2. Measured
  4. The daily
    1. The band
    2. The puzzle
    3. Endless
  5. What the browser holds
  6. Your move
  7. Posting and the referee
    1. Misses
  8. Rating: your rating against the puzzle’s
  9. The interface
    1. Screens
    2. Moving
    3. Helpers and options
    4. The clock
    5. Results and sharing
    6. Accessibility
    7. Stored in the browser
  10. Leaderboards and stats
  11. Its look
  12. Testing
  13. Changing it
  14. Known limitations
  15. Known issues

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.jsfromRow · 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.pyPuzzle.solution · lines 38–41
@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.jsjudge · 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.jssolves · 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 movesShare of a band
10.12
20.48
30.3
40.1
Live from tools/chess/bundle.py, LENGTHS

A 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.pychoose · lines 134–176
def 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.pycarried · lines 192–203
def 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.pyband_of · lines 54–67
def 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")
BandDays in a hundred
beginner5
easy25
medium40
hard25
expert5
Live from chesspuzzles/daily.py, BAND_WEIGHTS

The home page’s card names the band and rating, so a player knows what’s coming.

The puzzle#

chesspuzzles/daily.pypick · lines 30–41
def 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.pymin_seconds · lines 54–59
def 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.pyneeds_referee · lines 26–31
def 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.pymiss · 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.pyChess._event · lines 384–393
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:

DayBandPuzzleResultChance of solvingChangeAfter
MonMedium1520Clean61%+10.41610
TueEasy1180Clean91%+2.41613
WedHard1930One hint16%+11.11624
ThuMedium1710A wrong move39%−10.21614
FriExpert2310Show solution2%−0.61613
SatMedium1460Two hints69%+4.01617
Live from guide/worked/chess.py, WEEK.rows

A 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, run python3 -I tools/chess/bundle.py <csv>, then node tools/chess/duplicates.mjs --write, npm run check:chess and the suite. Played dailies keep their puzzles by id.
  • Band weights: BAND_WEIGHTS in chesspuzzles/daily.py. Kept days don’t change.
  • The rules or the engine: games/static/games/chess/engine/index.js is shared by the game and the referee. Run npm run check:chess and npm 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.js and chess.css; run npm 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.