Technical guide

The site

Referees and anti-cheat

How the server checks every posted solve with the game’s own rules, so the leaderboards show only real results.

Checked against the code on 2026-10-25.

On this page
  1. Five referees
  2. The Register: rebuilding a case
    1. The posting view
    2. Running Node
    3. Warming ahead
    4. What it can’t see
  3. Hashi: make the islands, check the bridges
  4. The pencil-puzzle kit: the one answer
  5. Solitaire: the replay is the proof
  6. Chess Puzzles: the line, and mates off it
  7. Engine versions
  8. Times
  9. Status codes, and posting later
  10. What a cheater could and couldn’t do
  11. Table games run on the server
  12. Changing a referee

Every solo game on the site runs in your browser. That lets it work offline, keep your progress without an account (AGENTS.md rule 6) and cost the server nothing while you play. But anything a browser says could be made up, so before a solve reaches a leaderboard, the server checks it.

The games’ code is public, their state sits in localStorage for anyone to edit, and a post is an HTTP request anyone can write. So the server believes nothing it can check for itself. That’s AGENTS.md rule 7:

Trust the referee, not the client. A posted solve is accepted only if the referee, rebuilding the case with the game’s own engine, agrees on the killer; a Hashi solve, only if its bridges solve the puzzle the engine makes from its code; a Solitaire win, only if its moves, replayed on the deal the engine makes from its code, win it; a kit puzzle’s solve, only if its answer is the one answer of the puzzle the engine makes from its code. Don’t add score paths that skip it.

Checking is cheap because every puzzle comes from a short code through a deterministic engine (Dailies, codes and seeds). A post names the code; the server makes the puzzle itself, with the same engine under Node, and checks the answer against it.

How a posted solve is checked The player's browser posts the puzzle's code, the engine version, the answer and the time. The game's view checks that the player is signed in, that the post is well formed and that the time is believable. If the puzzle has been made before, the server reads it from its table; if not, it runs the game's own engine under Node to rebuild it from the code. The answer is then checked against it: refused with a plain message if wrong, kept as a verified solve if right, which feeds the boards, the streak and the rating. the browser solves, and times it POST the game's view signed in? well formed? a believable time? made before? read it from the table no Node referee the game's own engine rebuilds from the code yes check the answer the killer, the bridges, the moves, the one answer wrong refused a plain message right a verified solve boards, streak, rating
Every posting view has this shape; each game fills in its own check.

Five referees#

Game A post carries The referee Kept, so it’s made once
The Register case number, difficulty, mode, engine, the accused, time, wrong guesses, hints referee/solve.mjs rebuilds the case and names the killer SolvedCase
Hashi code, size, engine, bridges, time referee/hashi.mjs makes the islands; hashi/check.py checks the bridges in Python hashi.Puzzle
A kit puzzle code, size, difficulty, engine, answer, time referee/puzzles.mjs makes the puzzle and its one answer puzzles.Puzzle
Solitaire board, code, engine, every move, time referee/solitaire.mjs replays the moves on the deal nothing: every win is replayed
Chess Puzzles puzzle id, band, moves, time, mistakes, hints the bundle’s line, read in Python; referee/chess.mjs only for a mate off it the bundle itself

The Node scripts are thin on purpose. Each imports the game’s own engine from games/static/games/<slug>/engine/, the files the browser loads, so there’s no second copy of the rules to drift. The Register’s is the whole thing:

referee/solve.mjslines 1–15
// The leaderboard's referee: rebuild a case from its number with the
// Register's own engine and report who the killer is.
//
//   node referee/solve.mjs CODE DIFFICULTY MODE   →   {"engine":8,"killer":1234,...}
import { generateCase, unseal } from '../games/static/games/the-register/engine/generator.js';

const [code, difficulty, mode] = process.argv.slice(2);
try {
  const c = generateCase(code, difficulty, mode);
  if (!c.validation.ok) throw new Error('case failed validation');
  process.stdout.write(JSON.stringify({ engine: c.engine, killer: unseal(c.solution), title: c.title, names: c.names.length }));
} catch (e) {
  process.stderr.write(String(e.message || e));
  process.exit(1);
}

Each takes input on the command line (The Register, Hashi) or as JSON on stdin, writes JSON to stdout, and exits non-zero on failure with the reason on stderr. Python runs it through obstacles/node.py, with settings.NODE_BINARY (node by default), which lets only 1 referee run at a time across the whole dyno, so a burst of posts can’t push it past its memory (Node, called from Python, ADR 0049). Heroku’s Node buildpack runs before the Python one, so Node is on the server; locally it’s Node 24.

The Register: rebuilding a case#

A Register case is a book of tens of thousands of names and a chain of clues that leaves exactly one the killer. The browser builds it from the case number; the server builds it again and asks who the killer is.

The posting view#

/api/scores/ in full:

leaderboard/api.pysubmit · lines 41–78
@require_POST
def submit(request):
    if not request.user.is_authenticated:
        return _fail(401, "Sign in to post your time.", login=reverse("accounts:login", args=["the-register"]), signup=reverse("accounts:signup", args=["the-register"]))
    try:
        body = json.loads(request.body)
        code, difficulty, mode = str(body["code"]).upper(), body["difficulty"], body["mode"]
        engine, accused = int(body["engine"]), int(body["accused"])
        seconds, wrong, hints = int(body["seconds"]), int(body.get("wrong", 0)), int(body.get("hints", 0))
    except (ValueError, KeyError, TypeError):
        return _fail(400, "That submission was incomplete.")
    if not CODE_RE.match(code) or difficulty not in DIFFICULTIES or mode not in MODES or not (0 <= wrong <= 999 and 0 <= hints <= 999):
        return _fail(400, "That submission didn’t describe a case.")
    if not scoring.MIN_SECONDS <= seconds <= scoring.MAX_SECONDS:
        return _fail(422, "That time doesn’t look like a real solve.")
    if not cached(code, difficulty, mode, engine) and not _may_build(request.user):
        return _fail(429, "That’s a lot of new cases in an hour. Try posting again later.")
    try:
        case = solution(code, difficulty, mode, engine)
    except RefereeBusy as e:
        return _fail(202, str(e), retry=True)
    except RefereeError as e:
        return _fail(422, str(e))
    if accused != case.killer:
        return _fail(422, "That isn’t the killer of this case.")

    existing = Score.objects.filter(user=request.user, case=case).first()
    if existing:
        return _done(existing, already=True)
    try:
        score = Score.objects.create(
            user=request.user, case=case, seconds=seconds, wrong=wrong, hints=hints,
            final_seconds=scoring.final_seconds(seconds, wrong, hints), points=scoring.points(difficulty, mode, wrong, hints),
        )
    except IntegrityError:
        return _done(Score.objects.get(user=request.user, case=case), already=True)
    record(request, "score_submitted", game="the-register", mode=mode, difficulty=difficulty)
    return _done(score)
  1. A guest is told to sign in (401, with the game’s sign-in links).
  2. The body must describe a real case: a code matching CODE_RE (two groups of four of the site’s letters), a difficulty and mode the game has, and sane counts (400).
  3. The time must be believable (422, Times).
  4. The referee runs, and the accused must be its killer (422).
  5. The solve is stored once per player per case. A repeat post gets the first back with already: true; a race between two posts is settled by the unique constraint (the IntegrityError branch).

The referee can cost 22 seconds of Node in the request, so it’s rationed. An engine version the server doesn’t run is refused before Node starts. A player may have 30 new cases checked an hour (then 429, as in Hashi); cached cases are free and uncounted.

Running Node#

leaderboard/referee.pybuild · lines 41–55
def build(code, difficulty, mode, timeout=120):
    """Run the engine. Returns {engine, killer, title, names}."""
    try:
        done = node.run([SCRIPT, code, difficulty, mode], timeout=timeout)
    except subprocess.TimeoutExpired as e:
        raise RefereeBusy("Still checking this case. Try posting again in a moment.") from e
    # What went wrong is for the logs; the player gets a sentence with a way
    # forward, never the engine's raw output (docs/design-language.md, rule 15).
    except OSError as e:
        log.error("Referee couldn't start for %s/%s/%s: %s", code, difficulty, mode, e)
        raise RefereeError(UNCHECKED) from e
    if done.returncode:
        log.error("Referee failed for %s/%s/%s: %s", code, difficulty, mode, done.stderr.strip()[:500])
        raise RefereeError(UNCHECKED)
    return json.loads(done.stdout)

A timeout becomes RefereeBusy (try again), any other failure RefereeError. The player never sees a 500 or Node’s output: stderr goes to the log, and the player gets UNCHECKED (“We couldn’t check this case just now, so it wasn’t posted. If it keeps happening, tell us through Feedback.”).

The result is cached in SolvedCase, keyed by code, difficulty, mode and engine version, so each case is built once:

leaderboard/referee.pysolution · lines 72–83
def solution(code, difficulty, mode, engine):
    """The case's killer: from the cache, or by building it within a request's time."""
    found = cached(code, difficulty, mode, engine)
    if found:
        return found
    if engine != ENGINE:  # Node would build this server's engine's case, then the answer would be refused: don't run it
        raise RefereeError("This case was made by a different version of the game, so it can’t be checked.")
    try:
        return _save(code, difficulty, mode, engine, build(code, difficulty, mode, timeout=REQUEST_TIMEOUT))
    except RefereeBusy:
        warm(code, difficulty, mode, engine)
        raise

Warming ahead#

A case of 52,000 names can take a dozen seconds to build on a small dyno, and Heroku gives a request thirty. So The Register, alone among the referees, checks cases ahead of time on a background thread.

leaderboard/referee.pywarm · lines 98–115
def warm(code, difficulty, mode, engine):
    """Queue a case to be checked in the background, unless it's known or queued."""
    global _worker
    if not settings.REFEREE_WARM:
        return
    if engine != ENGINE:
        return
    key = (code, difficulty, mode, engine)
    if cached(*key):
        return
    with _lock:
        if key in _waiting or len(_waiting) >= MAX_WAITING:
            return
        _waiting.add(key)
        if _worker is None or not _worker.is_alive():
            _worker = threading.Thread(target=_work, name="referee", daemon=True)
            _worker.start()
    _queue.put(key)
leaderboard/referee.py_work · lines 118–130
def _work():
    while True:
        key = _queue.get()
        close_old_connections()
        try:
            if not cached(*key):
                _save(*key, build(*key[:3]))
        except Exception:  # a background check must never take the site down
            log.exception("Background check of %s failed", key)
        finally:
            with _lock:
                _waiting.discard(key)
            close_old_connections()

One daemon thread in each worker works through a queue, waiting like any referee for the dyno’s Node slot, skipping anything cached or already queued. The start beacon is anonymous, so the queue is rationed: only the server’s engine version is queued, the queue holds 20 cases (beyond that, a post builds the case in its own request), and one address can queue 60 an hour. Cases are queued:

  1. When a player starts a case. The game’s case_started play-count event (analytics/api.py) names the code, difficulty, mode and engine, so a case is usually checked by the time it’s solved.
  2. When a post finds it unchecked. If solution can’t build within 22 seconds, it queues the case and the view answers 202 with retry: true; the game says “Try posting again in a moment”.

(Queuing each day’s daily case ended with the Register’s daily, ADR 0040.) settings.REFEREE_WARM turns the thread off, as the tests do in obstacles/settings_test.py, since it would race the test database.

What it can’t see#

The browser has to know the killer to tell you an accusation is wrong, so the case data holds the answer, sealed only against a casual look at a save file (ADR 0003). The referee stops everything else: the wrong accused, a case that doesn’t exist, an engine the server can’t rebuild, an impossible time. Wrong accusations and hints, which add minutes to a time, are counted by the browser and only checked to be between 0 and 999.

Hashi: make the islands, check the bridges#

A Hashi chart takes a fraction of a second to make, so its referee runs in the request, with no queue. Node makes the puzzle; Python checks the answer.

referee/hashi.mjslines 1–17
// Hashi's referee: make a puzzle from its code with the game's own engine and
// report its islands, so the server can check a posted solve against them.
//
//   node referee/hashi.mjs CODE SIZE [VERSION]   →   {"engine":3,"w":10,"h":10,"islands":[[x,y,n],…]}
//
// VERSION, if given, is an earlier engine version the engine still makes
// (its VERSIONS), for a daily made under it; any other is refused.
import { generate, ENGINE_VERSION } from '../games/static/games/hashi/engine/generate.js';

const [code, size, version] = process.argv.slice(2);
try {
  const p = generate(code, size, version ?? ENGINE_VERSION);
  process.stdout.write(JSON.stringify({ engine: p.engine, w: p.w, h: p.h, islands: p.islands }));
} catch (e) {
  process.stderr.write(String(e.message || e));
  process.exit(1);
}
hashi/referee.pypuzzle · lines 79–96
def puzzle(code, size, engine, legacy=""):
    """The puzzle, from the cache or made now. A puzzle cached under an older
    engine still checks, since its islands were kept; so does one of an
    earlier version the engine still makes (a daily made under it); any
    other the server's engine didn't make can't be."""
    found = _cache(code, size, engine, legacy).first()
    if found:
        return found
    if engine not in VERSIONS:
        raise RefereeError(OLD_VERSION)
    built = build(code, size, engine)
    if built.get("engine") != engine:
        raise RefereeError(OLD_VERSION)
    made, _ = Puzzle.objects.get_or_create(
        code=code, size=size, legacy_difficulty="", engine=engine,
        defaults={"w": built["w"], "h": built["h"], "islands": built["islands"]},
    )
    return made

hashi/check.py ports the game’s own rules (layout() and status() in board.js) and asks whether the posted bridges solve the puzzle, rather than comparing with the engine’s answer:

hashi/check.pysolves · lines 48–75
def solves(w, h, islands, bridges):
    """True if the bridges solve the puzzle: each island has exactly its
    number, no built bridge crosses another, and it's all one piece."""
    links, crosses = layout(w, h, islands)
    counts = counts_from(links, bridges)
    if counts is None:
        return False
    have = [0] * len(islands)
    for (a, b, *_), c in zip(links, counts):
        have[a] += c
        have[b] += c
    if any(have[i] != n for i, (_, _, n) in enumerate(islands)):
        return False
    if any(counts[k] and any(counts[j] for j in crosses[k]) for k in range(len(links))):
        return False
    # One piece: follow the built bridges out from the first island.
    near = [[] for _ in islands]
    for (a, b, *_), c in zip(links, counts):
        if c:
            near[a].append(b)
            near[b].append(a)
    seen, todo = {0}, [0]
    while todo:
        for j in near[todo.pop()]:
            if j not in seen:
                seen.add(j)
                todo.append(j)
    return len(seen) == len(islands)

Each bridge must join two islands that face each other with nothing between (layout lists every such pair), with one or two lines and no pair named twice. Every island must have exactly its number, no bridges may cross, and the bridges must connect every island. Any answer that does all that counts, even one the engine’s solver didn’t find.

Hashi’s view differs from The Register’s in three ways. It parses numbers strictly: _number and _index refuse true, 1.5 and "Infinity", which Python would accept. It limits each player to 60 new puzzles an hour (cached ones are free). And it answers a referee failure with 503 and retry: true, distinct from a wrong answer’s 422.

hashi/api.pysubmit · lines 67–107
@require_POST
def submit(request):
    if not request.user.is_authenticated:
        return _fail(401, "Sign in to post your time.", login=reverse("accounts:login", args=["hashi"]), signup=reverse("accounts:signup", args=["hashi"]))
    try:
        body = json.loads(request.body)
        code, size = str(body["code"]).upper(), _number(body["size"])
        engine, seconds = _number(body["engine"]), _number(body["seconds"])
        # Hashi no longer has difficulties (ADR 0024). A solve of a puzzle from
        # before that, posted from a tab left open or held for sign-in, still
        # names it, and it's how that puzzle is told apart from its siblings.
        legacy = str(body.get("difficulty", "")) if engine == 1 else ""
        bridges = [tuple(_index(n) for n in bridge) for bridge in body["bridges"]]
    except (ValueError, KeyError, TypeError, RecursionError):  # RecursionError: JSON nested a hundred thousand deep
        return _fail(400, "That submission was incomplete.")
    if not CODE_RE.match(code) or size not in SIZES or (legacy and legacy not in LEGACY_DIFFICULTIES) or not 0 < len(bridges) <= 1000 or any(len(b) != 3 for b in bridges):
        return _fail(400, "That submission didn’t describe a puzzle.")
    if not plausible(size, seconds):
        return _fail(422, "That time doesn’t look like a real solve.")
    if not cached(code, size, engine, legacy) and not _may_build(request.user):
        return _fail(429, "That’s a lot of new puzzles in an hour. Try posting again later.")
    try:
        found = puzzle(code, size, engine, legacy)
    except RefereeDown as e:
        return _fail(503, str(e), retry=True)
    except RefereeError as e:
        return _fail(422, str(e))
    if not check.solves(found.w, found.h, found.islands, bridges):
        return _fail(422, "Those bridges don’t solve this puzzle.")

    existing = Solve.objects.filter(user=request.user, puzzle=found).select_related("puzzle", "daily").first()
    if existing:
        return _done(existing, already=True)
    # A daily solve counts on the day's board only if it arrives that day.
    slot = DailyPuzzle.objects.filter(date=today(), code=code, size=size, engine=engine).first()
    try:
        solve = Solve.objects.create(user=request.user, puzzle=found, daily=slot, seconds=seconds)
    except IntegrityError:
        return _done(Solve.objects.select_related("puzzle", "daily").get(user=request.user, puzzle=found), already=True)
    record(request, "score_submitted", game="hashi", mode=str(size), daily=bool(slot))
    return _done(solve)

The pencil-puzzle kit: the one answer#

Every kit puzzle (Star Battle, Fillomino, Yin-Yang, Galaxies, Aquarium, Griddles, Low Poly) has exactly one answer: its engine proves it by searching for a second and finding none (ADR 0023). So Node makes the puzzle and its answer once, and checking a post is a comparison in Python. Judged puzzles, below, are the exception.

referee/puzzles.mjslines 1–69
// The pencil-puzzle kit's referee: one entry for every puzzle on the kit,
// each run with its own engine (games/static/games/puzzles/engine/engines.js;
// docs/puzzles.md, ADR 0023). The input is JSON on stdin; the answer is JSON
// on stdout.
//
//   node referee/puzzles.mjs make  {"slug":"star-battle","code":"ABC234","size":"8","difficulty":"medium"}
//     → {"engine":2,"puzzle":{…as the engine made it},"solution":[3,13,…]}
//
// `make` may name an "engine": an earlier version the engine still makes
// (its VERSIONS), for a daily made under it; any other is refused.
//
//   node referee/puzzles.mjs check {"slug":"star-battle","puzzle":{…},"answer":[3,13,…]}
//     → {"engine":1,"ok":true}
//
// `make` is the puzzle the engine makes from its code, and its one answer
// in its written form; the server keeps both and compares a posted answer
// with it. `check` runs the engine's own rules on an answer, for a puzzle
// kept without its answer.
//
// A judged puzzle (its engine exports JUDGED: Chicago Traffic, where any
// legal answer solves it) has no one answer to compare: `make`'s solution
// is one of the shortest, and `check` is how a posted answer is judged,
// saying too how many moves it took, as the engine counts them:
//
//   node referee/puzzles.mjs check {"slug":"chicago-traffic","puzzle":{…},"answer":[["A",-1],…]}
//     → {"engine":1,"ok":true,"moves":23} A code the engine won't make exits with status 2
// (the player's message says so); anything else that goes wrong, status 1.
import { engine } from '../games/static/games/puzzles/engine/engines.js';

class Refused extends Error {}

const read = async () => {
  let text = '';
  for await (const chunk of process.stdin) text += chunk;
  return JSON.parse(text);
};

try {
  const [command] = process.argv.slice(2);
  const input = await read();
  let game;
  try {
    game = await engine(String(input.slug));
  } catch (e) {
    throw new Refused(e.message);
  }
  let out;
  if (command === 'make') {
    const version = input.engine ?? game.ENGINE_VERSION;
    let puzzle;
    try {
      if (!(game.VERSIONS || [game.ENGINE_VERSION]).includes(version)) throw new Error(`This engine doesn't make version ${version}.`);
      const args = [String(input.code), String(input.size), String(input.difficulty)];
      puzzle = await (version === game.ENGINE_VERSION ? game.generate(...args) : game.generate(...args, {}, version));
    } catch (e) {
      throw new Refused(e.message);
    }
    out = { engine: version, puzzle, solution: game.solution(puzzle) };
  } else if (command === 'check') {
    out = { ok: game.check(input.puzzle, input.answer) === true };
    if (game.JUDGED && out.ok) out.moves = game.movesOf(input.answer);
  } else {
    throw new Error('Usage: puzzles.mjs make|check < input.json');
  }
  process.stdout.write(JSON.stringify({ engine: game.ENGINE_VERSION, ...out }));
} catch (e) {
  process.stderr.write(String(e.message || e));
  process.exit(e instanceof Refused ? 2 : 1);
}

The engine is found by slug in games/static/games/puzzles/engine/engines.js, so one script serves every kit puzzle, new ones included. Exit status 2 (Refused) means the engine won’t make that code, size or difficulty, and the player is told; 1 is the server’s problem:

puzzles/referee.pyrun · lines 39–55
def run(command, payload):
    try:
        done = node.run([SCRIPT, command], input=json.dumps(payload), timeout=TIMEOUT)
    except subprocess.TimeoutExpired as e:
        raise RefereeDown("The referee took too long with this puzzle. Try posting again in a moment.") from e
    except OSError as e:
        raise RefereeDown("The referee isn’t working just now. Try posting again later.") from e
    if done.returncode == REFUSED:
        log.warning("The referee wouldn't %s %s: %s", command, payload.get("slug"), done.stderr.strip()[:200])
        raise RefereeError("The referee couldn’t make this puzzle, so it can’t be checked.")
    if done.returncode:
        log.warning("The puzzle referee failed on %s: %s", command, done.stderr.strip()[:200])
        raise RefereeDown("The referee isn’t working just now. Try posting again later.")
    try:
        return json.loads(done.stdout)
    except ValueError as e:
        raise RefereeDown("The referee isn’t working just now. Try posting again later.") from e
puzzles/referee.pypuzzle · lines 85–106
def puzzle(kind, code, size, difficulty, engine):
    """The puzzle, from the cache or made now. A puzzle kept under an older
    engine still checks, since it was kept; so does one of an earlier
    version the engine still makes (a daily made under it); any other the
    server's engine didn't make can't be."""
    found = cached(kind, code, size, difficulty, engine)
    if found:
        return found
    if engine not in kind.versions:
        raise RefereeError(ANOTHER_VERSION)
    built = build(kind, code, size, difficulty, engine)
    if built.get("engine") != engine:
        raise RefereeError(ANOTHER_VERSION)
    if built.get("solution") is None:
        # Every puzzle the engine makes has one answer its solver finds.
        log.error("The referee made %s %s %s %s without an answer", kind.slug, code, size, difficulty)
        raise RefereeDown("The referee isn’t working just now. Try posting again later.")
    made, _ = Puzzle.objects.get_or_create(
        kind=kind.slug, code=code, size=size, difficulty=difficulty, engine=engine,
        defaults={"data": built["puzzle"], "solution": built["solution"]},
    )
    return made
puzzles/referee.pysolves · lines 109–117
def solves(kind, found, answer):
    """Whether a cleaned answer solves a kept puzzle. Every pencil puzzle on
    the kit has exactly one answer (the engine proves it), so it's the kept
    answer or it's wrong; a puzzle kept without its answer asks the engine.
    (A brainteaser's answer is how it was played, and many solve it, so the
    engine judges every one: `judged`, above.)"""
    if found.solution is not None:
        return answer == found.solution
    return judge(kind, found.data, answer)

First the view cleans the posted answer into the exact form the engine’s solution() writes, with the puzzle’s own clean (declared in its Kind). Cleaning checks shape only; rightness is the referee’s call. Star Battle’s turns a list of cells into a sorted list of distinct integers on the grid, refusing a true or a 2.0:

puzzles/star_battle.pyclean · lines 14–23
def clean(answer, size):
    """An answer is the cells holding stars (row * n + column): whole
    numbers on the grid, none twice, n × stars of them. Written increasing."""
    n, stars = GRIDS[size]
    if not isinstance(answer, list) or len(answer) != n * stars:
        raise ValueError("not the right number of stars")
    # Exactly integers: not a bool (an int to Python), a fraction or text.
    if not all(type(i) is int and 0 <= i < n * n for i in answer) or len(set(answer)) != len(answer):
        raise ValueError("not cells of the grid")
    return sorted(answer)

So answer == found.solution is exact. If a bug let a second answer through generation, a correct-but-different answer would be refused, never a wrong one accepted. A puzzle kept without its answer (solution empty, for one moved over from elsewhere) is judged by the engine’s check under Node instead.

A judged puzzle has no single answer: the brainteasers (ADR 0042), whose answer is the play itself, Chicago Traffic’s slides or Codebreaker’s guesses. Its engine exports JUDGED and its Kind says judged=True. Any legal slides that get the red car out solve it, scored by fewest moves. make still returns a shortest answer (solution()), but posts go to check instead: it replays the slides on the puzzle the code makes, refuses anything illegal or short of the exit, and returns the engine’s move count (moves, from movesOf(): a run of slides by one vehicle is one move), so the server never counts moves itself. submit asks referee.judged instead of solves and keeps the count as Solve.moves, which boards rank first. The puzzle carries best, its fewest moves, proven by search when it was made.

The kit’s view keeps one solve of a daily on its day and one of a puzzle at any other time; the first stands, and a repeat gets it back with already: true. They’re separate because a daily’s code may be one the player solved in Endless (Low Poly’s daily code is the picture’s own), and that mustn’t cost them the day. Griddles (Kind.picture_key) goes further: outside its day, a solve of a picture the player has solved at that size under any code gets their first solve back (Griddles). Two unique constraints enforce the day rules: one per puzzle for non-daily solves, one per daily.

The view caps an answer at 20,000 characters of JSON, before cleaning, and has Hashi’s limit of 60 puzzles per player an hour. tools/puzzles/tamper.mjs makes wrong answers from right ones for every puzzle (a cell flipped, the picture shifted a column, an answer one short; for a judged puzzle, a slide into another vehicle, a slide after the red car is out, one slide short), and npm run check:puzzles makes sure each engine and the referee refuse them all. It also re-proves each judged puzzle’s best with its own plain search (tools/puzzles/judged.mjs), sharing no code with the engine.

Solitaire: the replay is the proof#

A deal can be won many ways, so a post sends the whole play, every move in order, undos included, and the referee replays it on the deal the code makes (ADR 0022):

games/static/games/solitaire/engine/solitaire.jsreplay · lines 196–231
/**
 * The referee: deal the board's code and play `moves` through. A solve
 * counts only if every move is legal and the last one wins (nothing after
 * the win). A line is the player's whole play, undos included: "u3" takes
 * back the last three moves (an Undo press and the helpers' moves it
 * took with it), and costs one. The count that ranks is the moves still
 * standing plus one for every undo, so it's worked out here, from the
 * line, and never taken from the player.
 * Returns { ok: true, moves, kept, undos } or { ok: false, error, at }
 * where `at` is the index of the entry that failed.
 */
export function replay(boardKey, code, moves) {
  if (!BOARDS[boardKey]) return { ok: false, error: 'unknown board', at: -1 };
  if (!CODE_RE.test(code)) return { ok: false, error: 'bad code', at: -1 };
  if (!Array.isArray(moves)) return { ok: false, error: 'no moves', at: -1 };
  const state = deal(boardKey, code);
  const g = rules(state);
  const done = [];
  let undos = 0;
  for (let k = 0; k < moves.length; k++) {
    if (g.won(state)) return { ok: false, error: 'moves after the win', at: k };
    const back = typeof moves[k] === 'string' && UNDO_RE.exec(moves[k]);
    if (back) {
      const n = Number(back[1]);
      if (n > done.length) return { ok: false, error: 'nothing to undo', at: k };
      for (let i = 0; i < n; i++) g.revert(state, done.pop());
      undos++;
      continue;
    }
    const m = typeof moves[k] === 'string' && g.moves(state).find(x => x.s === moves[k]);
    if (!m) return { ok: false, error: 'illegal move', at: k };
    done.push(g.apply(state, m));
  }
  if (!g.won(state)) return { ok: false, error: 'not won', at: moves.length };
  return { ok: true, moves: done.length + undos, kept: done.length, undos };
}

Every move must be legal, the last must win, and nothing may follow. An undo (u3 takes back three moves: one Undo press and the helper moves it took with it) can’t take back more than was played. The fewest-moves count (moves still standing plus one per undo) is computed here from the line, so a client can’t claim a shorter one.

solitaire/api.pyMOVE_RE · lines 21–24
# The engine's move strings (engine/solitaire.js, MOVE_RE), ASCII digits
# only, and "u3", an undo of the last three moves: a posted line is the
# whole of the play, undos included, and the referee counts them.
MOVE_RE = re.compile(r"[dnw]|[wr]>(?:f|t[0-9])|t[0-9]>f|t[0-9]:[0-9]{1,2}>t[0-9]|f[SHDC]>t[0-9]|p[0-9]{1,2}(?:\+(?:p[0-9]{1,2}|w))?|u[1-9][0-9]{0,2}")

The view checks every move against the engine’s pattern before Node sees it, caps a line at 10,000 entries and refuses other engine versions up front. A replay takes milliseconds, so there’s no cache: it runs on every post, at most 120 per player an hour.

The script’s other job is winnable: a longer solver search for whether a deal can be won, so a deal given up counts against a rating only if it could have been won (Ratings). Answers are kept in Winnable, one per deal. (Until Solitaire left the dailies, ADR 0037, a daily command also picked each day’s deals: the first candidate its solver won within CHECK_NODES positions.)

solitaire/referee.pyrun · lines 35–48
def run(command, payload):
    try:
        done = node.run([SCRIPT, command], input=json.dumps(payload), timeout=TIMEOUT)
    except subprocess.TimeoutExpired as e:
        raise RefereeDown("The referee took too long. Try again in a moment.") from e
    except OSError as e:
        raise RefereeDown("The referee isn’t working just now. Try again later.") from e
    if done.returncode:
        log.warning("The Solitaire referee failed on %s: %s", command, done.stderr.strip()[:200])
        raise RefereeDown("The referee isn’t working just now. Try again later.")
    try:
        return json.loads(done.stdout)
    except ValueError as e:
        raise RefereeDown("The referee isn’t working just now. Try again later.") from e

Chess Puzzles: the line, and mates off it#

Chess puzzles are real positions from the Lichess puzzle database, bundled with the game as static files the server reads too (ADR 0028), so a post names a Lichess id and band instead of a code. Each puzzle has a line: the opponent’s first move, then the solver’s and opponent’s moves in turn. The solver’s are every other one:

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])

Most solves follow the line exactly, and the view just compares lists. The exception is Lichess’s rule that a move off the line that mates also solves it. Spotting mate needs chess rules, so a solve that follows the line until its last move and then leaves it goes to Node:

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]
referee/chess.mjslines 1–28
// Chess Puzzles' referee (docs/chess.md): replays a posted solve on the
// puzzle with the game's own rules (games/static/games/chess/engine/), for
// the one case the server can't settle by comparing moves itself, a mate
// other than the line's. The input is JSON on stdin; the answer is JSON on
// stdout.
//
//   node referee/chess.mjs solves {"puzzle":{"id":"0JMFA","fen":"…","line":["h4h5",…]},"moves":["c4f7","g3g6"]}
//     → {"ok":true}
//
// Anything that goes wrong exits with status 1.
import { solves } from '../games/static/games/chess/engine/index.js';

const read = async () => {
  let text = '';
  for await (const chunk of process.stdin) text += chunk;
  return JSON.parse(text);
};

try {
  const [command] = process.argv.slice(2);
  if (command !== 'solves') throw new Error('Usage: chess.mjs solves < input.json');
  const { puzzle, moves } = await read();
  const ok = solves({ id: String(puzzle.id), fen: String(puzzle.fen), line: puzzle.line.map(String) }, moves) === true;
  process.stdout.write(JSON.stringify({ ok }));
} catch (e) {
  process.stderr.write(String(e.message || e));
  process.exit(1);
}

solves is the game’s own check, using the vendored chess.js:

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 game posts such a solve only when that last move mates, so a player sends a handful at most; the view still caps them at 60 an hour. The view’s checking half:

chesspuzzles/api.pysubmit · lines 72–119
@require_POST
def submit(request):
    if not request.user.is_authenticated:
        return _fail(401, "Sign in to post your time.", login=reverse("accounts:login", args=["chess"]), signup=reverse("accounts:signup", args=["chess"]))
    try:
        body = json.loads(request.body)
        pid, band, moves = body["id"], body["band"], body["moves"]
        seconds = _count(body["seconds"], 2**31)
        mistakes, hints = _count(body.get("mistakes", 0), MAX_HELP), _count(body.get("hints", 0), MAX_HELP)
    except (ValueError, KeyError, TypeError, RecursionError):  # RecursionError: JSON nested a hundred thousand deep
        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:
        return _fail(400, "That submission didn’t describe a puzzle.")
    if not isinstance(moves, list) or not 1 <= 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.")
    if not min_seconds(moves) <= seconds <= MAX_SECONDS:
        return _fail(422, "That time doesn’t look like a real solve.")

    if not referee.follows_line(puzzle, moves):
        if not referee.needs_referee(puzzle, moves):
            return _fail(422, "Those moves don’t solve this puzzle.")
        if not _may_replay(request.user):
            return _fail(429, "That’s a lot of solves to check in an hour. Try posting again later.")
        try:
            if not referee.replay(puzzle, moves):
                return _fail(422, "Those moves don’t solve this puzzle.")
        except referee.RefereeDown as e:
            return _fail(503, str(e), retry=True)

    mine = Solve.objects.select_related("daily")
    existing = mine.filter(user=request.user, puzzle=pid).first()
    if existing:
        return _done(existing, already=True)
    # A daily solve counts on the day's board only if it arrives that day.
    slot = DailyChess.objects.filter(date=today(), band=band, puzzle=pid).first()
    # A puzzle already missed (a wrong move, or the solution shown) is never
    # clean, whatever this solve says (docs/ratings.md).
    if Miss.objects.filter(user=request.user, puzzle=pid).exists():
        mistakes = max(mistakes, 1)
    try:
        solve = Solve.objects.create(user=request.user, puzzle=pid, band=band, rating=puzzle.rating, daily=slot, seconds=seconds, mistakes=mistakes, hints=hints)
    except IntegrityError:
        return _done(mine.get(user=request.user, puzzle=pid), already=True)
    record(request, "score_submitted", game="chess", mode=band, daily=bool(slot))
    return _done(solve)

The daily board ranks clean solves first, but mistakes and hints are counted by the browser. So the server adds one thing it can know: when a signed-in player makes a wrong move or shows the solution, the game posts a miss at once (/games/chess/api/misses/), checked to be a real move off the line. Offline, it’s held and sent on the next load. A later solve of that puzzle is never clean, whatever it says.

Engine versions#

A code names a puzzle only together with the engine that makes it, so every engine has an ENGINE_VERSION and every post names the one the browser ran. AGENTS.md rule 4: bump it whenever any code would make a different puzzle (for Solitaire, deal different cards or change which moves are legal). The engine checks (npm run check:register, check:hashi, check:solitaire, check:puzzles) partly exist to catch a missed bump.

The server reads the version from the game’s own file, so the referee runs the engine the site serves:

hashi/referee.pyENGINE · lines 25–26
# The engine version of the hub's copy of Hashi, read from the game itself.
ENGINE = int(re.search(r"ENGINE_VERSION = (\d+)", _SOURCE).group(1))

The kit’s Kind.engine does the same from each puzzle’s engine_file. Most engines also put the version in their seed string, so a bump makes every code a new puzzle.

Hashi’s and the kit’s engines may also list older versions they can still make (VERSIONS beside Hashi’s ENGINE; the kit’s Kind.versions), for a bump that only switched a new filter on, such as a depth floor. The referee makes those on request (node referee/hashi.mjs CODE SIZE VERSION, or "engine" in the kit’s make input), so a daily made before a bump keeps its puzzle (a day keeps its engine version).

Game A post naming an older version
The Register Checked if that case was already made under its version and kept (SolvedCase is keyed by engine). Otherwise refused with a plain message (“Reload the page for the latest”), since the server can only run today’s engine.
Hashi, the kit Made and checked if the engine still makes that version (VERSIONS); else checked if that puzzle was already made under it and kept (Puzzle is keyed by engine); else refused with the plain message.
Solitaire Always refused: there’s nothing kept to replay on.
Chess Puzzles No engine version: a solve names a puzzle id, and is refused only if the bundle no longer has that puzzle.

Solves already posted are never touched. A post from an old tab after a deploy still checks if the engine makes its version or someone already posted that puzzle (usually a busy daily). If neither, the player is told to reload and loses the game in progress.

Hashi’s and the kit’s daily rows record their version and keep it while the engine can make it, so a bump changes no day already made, and a solve lands on a day’s board only at the day’s version. If the engine can’t make the old version any more, Hashi makes a new-version daily beside the old one, each with its own board, and a kit daily from before versions were kept plays at the current engine. The Register’s old daily rows don’t name a version.

Times#

The browser keeps the time and posts whole seconds. The kit’s clock (games/static/games/puzzles/kit/play.js) adds up performance.now() while the puzzle is on screen and not paused, and posts max(1, round(elapsed)). The server can only check the time is believable: no faster than entering the answer with it in front of you, no slower than a clock left running.

Game Fastest believable Slowest
The Register 60 seconds 5,184,000 seconds
Hashi by size, below 86,400 seconds
Solitaire by board, below 86,400 seconds
A kit puzzle by size, in its Kind 86,400 seconds
Chess Puzzles a second a move the kit’s
Hashi sizeSeconds
78
1015
1530
2060
Live from hashi/times.py, MIN_SECONDS
Solitaire boardSeconds
klondike-130
klondike-330
tripeaks15
pyramid15
canfield30
Live from solitaire/times.py, MIN_SECONDS
Star Battle sizeSeconds
66
810
1020
Live from puzzles/star_battle.py, KIND.min_seconds
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))

Outside the range, the post is refused with 422 (“That time doesn’t look like a real solve”); inside it, the time is taken as given. The server doesn’t compare a daily’s time with how long the daily has existed, and can’t tell a fast solver from a copied answer posted after the minimum. ADR 0013 accepts that: the minimums are low enough for fast players, so they let copies through too, and should rise if the boards fill with impossible times.

What matters most for a fair day the server does decide. A solve is on a day’s board only if posted that day by the server’s clock (Dailies, codes and seeds). Only the first solve per player per puzzle counts (the kit: one on the daily’s day, one any other time; Griddles: one per picture), so there’s no reposting for a better time. Boards break ties by who posted first.

Status codes, and posting later#

Every posting view answers with JSON (ok, plus error when it isn’t) and a status the game acts on:

Status Means The game
200 Verified and kept, or already kept Shows the rank, best, streak, rating
202 The Register’s case is still being checked Offers to post again in a moment
400 The post didn’t describe a puzzle or an answer Shows the message
401 Not signed in Holds the solve and offers sign-in
422 A wrong answer, an unbelievable time, another engine Shows the message
429 Over the hourly limit Holds the solve for later
503 The referee itself failed Holds the solve for later

A failure that isn’t the player’s fault (offline, signed out, 202, 429 or a 5xx; passing in each game’s hub.js) leaves the solve in the browser’s pending list, to post again next time the game loads signed in. A solve held while you sign up still lands and is checked like any other, but misses its day’s board if it lands after midnight.

What a cheater could and couldn’t do#

The code is public, so here is exactly what the referees guarantee.

No post can:

  • Score a puzzle other than the one its code makes: the server makes it with the game’s own engine.
  • Score a wrong answer: the wrong killer, bridges that don’t solve the chart, a grid that isn’t the one answer, an illegal Solitaire move or a line that doesn’t win, a chess move that’s neither the line’s nor mate.
  • Claim fewer Solitaire moves than its line has.
  • Post a time outside the believable range.
  • Put a solve on a day’s board after that day, or on any board twice.
  • Work out tomorrow’s daily: its code comes from the secret key (Dailies, codes and seeds).
  • Post as someone else: a post needs a signed-in session and a CSRF token.
  • Make the server rebuild a puzzle it already made: results are kept by code and version.

A determined player could still, because only the browser sees it:

  • Find the answer and post it with a believable time. The browser makes the puzzle, so it can make the answer: run the engine’s solver, read The Register’s sealed killer, look up a chess puzzle in the public bundle.
  • Under-report help: The Register’s wrong accusations and hints, Chess Puzzles’ mistakes and hints (unless a miss was posted), Solitaire’s undos (a line without them is a line like any other).

The ADRs accept this each time as fine for a family-and-friends leaderboard (ADR 0003, ADR 0013, ADR 0022, ADR 0023, ADR 0028). Stopping it would mean watching the play itself, which means running the game on the server. The table games do exactly that, because there’s no other way to keep secrets between players.

Table games run on the server#

Multiplayer games work the other way round. Their rules run only on the server (tables/): a browser sends what its seat wants to do and gets back only what that seat may see (AGENTS.md rule 8). Nothing a browser says is taken as a result, so there’s nothing to referee afterwards. Tables has how.

Changing a referee#

  • Changing an engine. Run its check (npm run check:register, check:hashi, check:solitaire, check:puzzles or check:chess) and bump ENGINE_VERSION if any code would make a different puzzle. The referee reads the new version from the file; nothing changes on the server. For Hashi or a kit puzzle, keep the old version in VERSIONS if the engine can still make it, with its fixed puzzles in the check, so its dailies stay as they were.
  • A new kind of answer. For a kit puzzle, its clean and its tampering in tools/puzzles/tamper.mjs; the referee stays the same. Otherwise, a script in referee/ that imports the game’s engine and reads and writes JSON, plus a Python wrapper that turns every failure into a message (RefereeError) or a retry (RefereeDown, RefereeBusy).
  • Never add a score path that skips the referee (rule 7). Ratings, boards, streaks and Stats tabs read only verified solves, so a path around the referee would reach them all.