Technical guide

The site

How this guide stays true

How the guide keeps up with the code, and how it warns us when a chapter falls behind.

Checked against the code on 2026-10-30.

On this page
  1. Where it lives
  2. Live snippets and values
  3. Stamps: knowing when a chapter needs reading
    1. When the check fails
    2. What else the check holds
  4. Drawing a page
  5. Where it’s found
  6. Writing a chapter

Guides to code usually go wrong quietly: the code changes and the words don’t. This guide pulls its code and numbers straight from the source, and it flags any chapter whose code has changed since someone last read it.

  • Nothing is copied. A chapter names a piece of code or a number, and the page pulls it in as it’s drawn.
  • Every chapter is stamped. A chapter lists the code it explains (covers:), and its reviewed: stamp is a hash of that code. When a covered file changes, npm run check:guide fails until someone reads the chapter, updates it and stamps it again.

AGENTS.md rule 2 says every change that alters behavior updates this guide in the same pull request. The check names exactly the chapters a change touches, and it runs with the tests, so the rule is cheap to follow and hard to forget (ADR 0035).

Where it lives#

The chapters are Markdown files in docs/guide/, with the design system’s in docs/guide/design/ and one per game in docs/guide/games/. The front page lists them in groups: the site’s chapters, then Design (ADR 0055), both numbered in reading order, then each family’s games. README.md (for writers) and known-issues.md (every chapter’s known issues, gathered for fixing) sit there too but aren’t chapters. A small Django app, guide/, serves the guide:

File What it does
guide/chapters.py Reads a chapter’s front matter and body.
guide/directives.py Resolves the live snippets and values.
guide/render.py Markdown to HTML, with heading anchors, the contents and the text for search.
guide/coverage.py What a chapter covers, its hash, and what changed since its stamp.
guide/checks.py check:guide.
guide/views.py /guide/ and /guide/<chapter>/.
static/guide/ guide.css, and guide.js for search and for marking where you are in the contents.

The pages use the hub’s paper (games/static/games/house/paper.css, loaded first by guide/templates/guide/page.html, ADR 0036): cream and graphite even in dark mode, Gloock for titles, Figtree for text. guide.css adds only a link blue, code colors and IBM Plex Mono (fonts/).

Front matter is a small subset of YAML that guide/chapters.py parses itself, so the site needs no YAML library:

---
title: Ratings
summary: One line for the index and the page's lead.
order: 60
covers:
  - ratings/**
  - games/static/games/house/end.js
reviewed: 2026-10-19 3f2a9c1b4d5e
---

A game’s chapter adds game: <slug>, and status: stub while it’s being written.

Live snippets and values#

A directive is a name in double braces. As a chapter is drawn, guide/directives.py replaces each one with what it names, read from the file as it is now:

Directive Becomes
{{snippet path="tables/engine.py" symbol="Game"}} A Python class, function, Class.method or module-level name (found with Python’s parser), or a JavaScript function, class or const (found by a small scanner), with the comments just above it.
{{snippet path="obstacles/daily.py" marker="code"}} The lines between guide:start code and guide:end code comments, for when a symbol is the wrong size.
{{value path="ratings/skill.py" name="BETA" format=".1f"}} A constant’s value, in a sentence. Python values are imported, so computed ones are exact; a JavaScript one must be a one-line literal.
{{value path="games/static/games/house/house.css" name="--bar-h"}} A stylesheet’s token: a custom property’s value on :root (media="…" for its value under an @media, in="…" for another selector’s).
{{table path="ratings/tables.py" name="ANCHORS"}} A dict or list, as a table.
{{tokens path="games/static/games/house/house.css" prefix="--m-"}} A stylesheet’s tokens as a table (names="--a,--b", or a prefix), with a column for each @media that changes one; media="(max-width: 640px)=Phone" picks and names the columns. The design system’s tokens are drawn this way.
{{figure name="request-flow"}} An SVG from docs/guide/figures/, in the page’s ink.
{{src tables/engine.py}}, {{adr 30}} A path or an ADR, checked to exist.

So {{value path="ratings/skill.py" name="PROVISIONAL"}} reads as 10 today, and as whatever the code says tomorrow. Here is the snippet directive, pulled in by itself, and the ast lookup it uses for Python, which knows exactly where a definition starts and ends, decorators included:

guide/directives.pysnippet · lines 298–313
def snippet(keys: dict, bare: list) -> Piece:
    rel = keys.get("path") or (bare[0] if bare else "")
    code, first, last = extract(rel, keys.get("symbol", ""), keys.get("marker", ""))
    lang = keys.get("lang") or LANGS.get(Path(rel).suffix, "text")
    what = keys.get("symbol") or keys.get("marker") or ""
    where = f"line {first}" if first == last else f"lines {first}–{last}"
    # (a long path or name may break after a slash or a dot, so a phone's narrow caption wraps it rather than cutting it off)
    path = html.escape(rel).replace("/", "/<wbr>")
    name = html.escape(what).replace(".", ".<wbr>")
    caption = (
        f'<figcaption><code class="g-path">{path}</code>'
        f'<span class="g-where">{name + " · " if what else ""}{where}</span></figcaption>'
    )
    note = f'<p class="g-code-note">{html.escape(keys["note"])}</p>' if keys.get("note") else ""
    body = f'<pre tabindex="0"><code class="hl language-{html.escape(lang)}">{highlight(code, lang)}</code></pre>'
    return Piece(html=f'<figure class="g-code" data-source="{html.escape(rel)}">{caption}{body}{note}</figure>', block=True)
guide/directives.pypy_symbol · lines 128–150
def py_symbol(text: str, symbol: str, rel: str) -> tuple[int, int]:
    """(first line, last line), 1-based, of a Python name."""
    try:
        tree = ast.parse(text)
    except SyntaxError as e:
        raise DirectiveError(f"{rel} doesn't parse: {e}") from None
    body, node = tree.body, None
    for part in symbol.split("."):
        node = None
        for n in body:
            if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and n.name == part:
                node = n
            elif isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == part for t in n.targets):
                node = n
            elif isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name) and n.target.id == part:
                node = n
        if node is None:
            raise DirectiveError(f"{rel} has no {symbol!r}")
        body = getattr(node, "body", [])
    first = min([node.lineno] + [d.lineno for d in getattr(node, "decorator_list", [])])
    lines = text.split("\n")
    first = _leading_comments(lines, first - 1, js=False) + 1
    return first, node.end_lineno

A stylesheet is read by a small parser of its own, which walks its braces and keeps each custom property with its selector and the @media round it, minding quotes and brackets (a data URL is full of ;):

guide/directives.pycss_tokens · lines 471–511
def css_tokens(text: str) -> list[tuple[str, str, str, str]]:
    """Every custom property a stylesheet declares, in order: (the @media
    it's under, "" for none; its rule's selector; its name; its value)."""
    text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
    out: list[tuple[str, str, str, str]] = []
    stack: list[str] = []
    buf: list[str] = []
    quote, depth = "", 0

    def declare(chunk: str, selector: str):
        media = " and ".join(p[len("@media"):].strip() for p in stack if p.startswith("@media"))
        for d in _split(chunk, ";"):
            name, colon, val = d.partition(":")
            if colon and name.strip().startswith("--"):
                out.append((media, selector, name.strip(), " ".join(val.split())))

    for ch in text:
        if quote:
            buf.append(ch)
            if ch == quote:
                quote = ""
            continue
        if ch in "\"'":
            quote = ch
        elif ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        elif ch == "{" and not depth:
            *before, prelude = _split("".join(buf), ";") or [""]
            if before and stack:
                declare(";".join(before), stack[-1])
            stack.append(" ".join(prelude.split()))
            buf = []
            continue
        elif ch == "}" and not depth:
            declare("".join(buf), stack.pop() if stack else "")
            buf = []
            continue
        buf.append(ch)
    return out

A snippet’s caption names the file, the symbol and the lines; on a phone a long name breaks after a slash or a dot.

A directive that can’t be resolved never crashes the page. Locally it shows a warning box saying what broke; in production, a quiet “out of date” note, and the problem is logged. check:guide fails either way, naming the chapter, the line and the reason.

Worked examples are live too. A calculation, such as a rating moving after a game, gets its numbers through {{value}} from guide/examples.py, which calls the real code, so it recomputes when the code changes. Each game’s examples have a module in guide/worked/ (guide/worked/hashi.py, guide/worked/the_register.py), with shared ones beside them: guide/worked/timed.py for a timed puzzle’s solves, guide/worked/cards.py for a kept table game. Each game’s chapter covers its own module, so changing one game’s example flags only that chapter.

Stamps: knowing when a chapter needs reading#

Snippets can’t tell when a paragraph stops matching the code. So each chapter declares what it covers: paths and globs (ratings/**, tables/*.py, a directory), with !pattern to leave something out. guide/coverage.py hashes them:

guide/coverage.pyfingerprint · lines 105–109
def fingerprint(paths: list[str]) -> str:
    h = hashlib.sha256()
    for p in paths:
        h.update(f"{blob(p)} {p}\n".encode())
    return h.hexdigest()[:12]

Each file’s hash is the one git gives it (a SHA-1 of the file as a blob). The chapter’s hash changes when a covered file is edited, added, removed or renamed, and never because of a commit elsewhere or a rebase. It counts the files git would commit (tracked ones plus new ones git doesn’t ignore), so a stamp made before git add still matches on the pull request. The stamp is the date and the hash, as in the front matter above.

When the check fails#

Say a change edits ratings/skill.py. npm run check:guide then says:

✖ ratings (docs/guide/ratings.md) covers files that changed since it was reviewed on 2026-10-19:
      ratings/skill.py  changed
    What changed: git diff 6b7a43791c -- ratings/skill.py
    Review the chapter, update its text and numbers, then run: npm run guide:stamp ratings

To find “since”, the check asks git which commit recorded the current stamp (git log -S<hash> on the chapter’s file) and prints the diff of the covered files from then to now. If the stamp isn’t committed yet, or there’s no history (a shallow clone), it lists every covered file instead.

guide/coverage.pychanged_since · lines 138–156
def changed_since(chapter: Chapter, fresh: Freshness) -> tuple[str | None, list[tuple[str, str]]]:
    """(the commit to diff from, [(path, how)]): what changed in the covered
    files since the stamp was committed. Without a commit (a stamp not yet
    committed, a shallow clone, no git), every covered file is listed."""
    commit = stamped_at(chapter)
    if commit:
        then = _git("ls-tree", "-r", "--name-only", commit) or ""
        then_files = [f for f in then.split("\n") if f]
        before = set(covered(chapter, then_files)[0])
        now = set(fresh.files)
        out = []
        diff = _git("diff", "--name-only", commit, "--", *sorted(before & now)) if before & now else ""
        for f in sorted(x for x in (diff or "").split("\n") if x):
            out.append((f, "changed"))
        out += [(f, "new") for f in sorted(now - before)]
        out += [(f, "removed") for f in sorted(before - now)]
        if out:
            return commit, out
    return commit, [(f, "covered") for f in fresh.files]

The fix is always the same: read the diff, update the chapter, then npm run guide:stamp ratings. A stamp means “I read this chapter against this code”, not “make the check pass”. Stamping without reading is the one way to defeat the system.

What else the check holds#

guide/checks.pyrun · lines 65–148
def run(today: date | None = None, freshness: bool = True) -> Report:
    from games.catalog import GAMES

    today = today or date.today()
    report = Report()
    for path in chapter_files():
        try:
            report.chapters.append(load(path))
        except FrontMatterError as e:
            report.problems.append(f"✖ {path.relative_to(ROOT)}: {e}")
    chapters = sorted(report.chapters, key=lambda c: (c.order, c.slug))
    by_slug = {c.slug: c for c in chapters}
    anchors, rendered = {}, {}
    for ch in chapters:
        r = render(ch, debug=True)
        rendered[ch.slug] = r
        anchors[ch.slug] = {s.id for s in r.sections if s.id}
        report.directives += len(re.findall(r"(?<!\\)\{\{[a-z]+", ch.body))
        for p in r.problems:
            report.problems.append(f"✖ {ch.rel}, {p}")

    # Links between chapters land somewhere.
    for ch in chapters:
        for href in rendered[ch.slug].links:
            if href.startswith("#"):
                target, anchor = ch.slug, href[1:]
            elif href.startswith("/guide/"):
                rest = href[len("/guide/"):]
                target, _, anchor = rest.partition("#")
                target = target.strip("/")
                if not target:
                    continue
            else:
                continue
            if target not in by_slug:
                report.problems.append(f"✖ {ch.rel} links to {href}, but there's no chapter {target!r}")
            elif anchor and anchor not in anchors[target]:
                report.problems.append(f"✖ {ch.rel} links to {href}, but {target} has no heading #{anchor}")

    # Every game has a chapter.
    games = {c.game: c for c in chapters if c.game}
    for c in chapters:
        if c.game and c.slug != f"games/{c.game}":
            report.problems.append(f"✖ {c.rel}: a game's chapter is docs/guide/games/{c.game}.md")
        if c.slug.startswith("games/") and not c.game:
            report.problems.append(f"✖ {c.rel}: a chapter under games/ says which game it is (`game: <slug>`)")
    for game in GAMES:
        if game.slug not in games:
            report.problems.append(
                f"✖ {game.title} ({game.slug}) has no chapter: write docs/guide/games/{game.slug}.md "
                f"(docs/guide/README.md, \"A game's chapter\")"
            )
    known = {g.slug for g in GAMES}
    for slug, c in games.items():
        if slug not in known:
            report.problems.append(f"✖ {c.rel}: there's no game {slug!r} in games/catalog.py")

    # Stubs, and every written chapter true to its code.
    files = coverage.repo_files() if freshness else []
    for ch in chapters:
        if ch.status not in ("written", "stub"):
            report.problems.append(f"✖ {ch.rel}: status is `written` (the default) or `stub`, not {ch.status!r}")
            continue
        if ch.stub:
            report.stubs += 1
            if not ch.game:
                report.problems.append(f"✖ {ch.rel}: only a game's chapter may be a stub")
            elif today > STUBS_UNTIL:
                report.problems.append(f"✖ {ch.rel} is still a stub, and stubs were allowed until {STUBS_UNTIL}: write it")
            continue
        if not ch.covers:
            report.problems.append(f"✖ {ch.rel}: a written chapter says what code it covers (`covers:`)")
            continue
        if not freshness:
            continue
        fresh = coverage.freshness(ch, files)
        if fresh.empty:
            report.problems.append(f"✖ {ch.rel}: covers {', '.join(fresh.empty)}, which match no files")
        if ch.reviewed and not re.fullmatch(r"\d{4}-\d{2}-\d{2} [0-9a-f]{12}", ch.reviewed):
            report.problems.append(f"✖ {ch.rel}: `reviewed:` is a date and a hash, set by npm run guide:stamp {ch.slug}")
        elif not fresh.fresh:
            report.stale.append(ch.slug)
            report.problems.append(_stale_message(ch, fresh))
    return report
  • Directives resolve in every chapter, stubs included.
  • Links between chapters land. /guide/ratings/#provisional must find that chapter and that heading.
  • Every game has a chapter. Every game in games/catalog.py, listed or not, needs docs/guide/games/<slug>.md with game: <slug>. Until 2026-11-30 it may be a stub (status: stub), whose directives must resolve but which covers nothing and needs no stamp. After that date a stub fails.
  • Every written chapter covers something, and its stamp matches.

There’s no CI. The check runs by hand; inside the Django tests (tests/test_guide.py), so manage.py test tests fails on a stale chapter; in npm run check:pr, the local gate before every pull request to main; and before every commit, through a Claude Code hook (.claude/hooks/check-guide.sh).

Drawing a page#

guide/render.py resolves the directives, then runs Python-Markdown with tables, footnotes, definition lists, smart quotes and heading anchors. Pygments highlights code on the server, so a page needs no script. In production a chapter is drawn once per process, since its files can’t change under a running deploy; locally, on every request, so an edit shows at once.

The pages are public and the same for everyone. They send no cookie, touch no session, and have no form, no CSRF token, no Feedback and no counting (AGENTS.md rule 6), so Cache-Control: public is safe (Accounts has how Feedback stays off). Search runs in your browser, over the text of every section sent with the page.

The guide shows unpublished server code, but nothing secret: it explains how the daily codes come from the secret key without showing a key or a code. Nothing here helps anyone post a result the referee wouldn’t accept, because the referee rebuilds the puzzle itself.

Where it’s found#

The hub stays a shelf of games (AGENTS.md rule 1), so the guide is linked quietly: “How it works” in the hub’s footer and at the foot of /account/, “How ratings work” under your ratings, and the last line of every game’s How to play (“Technical guide: how Hashi works”). The house kit draws that line:

games/static/games/house/nav.jsguideLink · lines 53–59
/** The last line of every game's How to play: a quiet link to how it works,
 * for the curious ("Technical guide: how Hashi works"). Null where the
 * game has no chapter to link to. */
export function guideLink(title, path) {
  const href = guideHref(path);
  return href ? h('p.house-guide', h('a', { href }, `Technical guide: how ${title} works`)) : null;
}

It reads the game from the page’s address, so a game served anywhere but /games/<slug>/ (The Register, also published from its own project) shows no line:

games/static/games/house/nav.jsguideHref · lines 45–51
/** Where a game's chapter of the technical guide is (docs/adr/0035): its
 * slug from this page's address, /games/<slug>/…, so a game served
 * anywhere else (The Register outside the hub) has none. */
export function guideHref(path = globalThis.location?.pathname || '') {
  const slug = /^\/games\/([a-z0-9-]+)\//.exec(path)?.[1];
  return slug ? `/guide/games/${slug}/` : null;
}

Writing a chapter#

docs/guide/README.md is for writers: the front matter, every directive, the style, and the template for a game’s chapter.