Technical guide

Board and party games

Quiz Whiz

Six categories, six lights, one die, and a typed answer to every question.

Checked against the code on 2026-10-31. Play Quiz Whiz

On this page
  1. At a glance
  2. The rules in code
    1. Players and settings
    2. The board
    3. The state
    4. Asking and answering
    5. Checking a typed answer
    6. The contract
  3. Secrets
  4. Bots
  5. The libraries
  6. Rating
  7. The interface
    1. The room
    2. The table, in fixed places
  8. Its look
  9. Testing
  10. Changing it
  11. Known issues

Quiz Whiz is our take on the classic six-category trivia board game. You roll, move round a hexagon track and type your answer to each question; a right answer keeps your turn, and a right answer on a corner switches on that category’s light. With all six lit, reach the hub and win the final question, its category picked by the player to your left. The host’s Quick game drops the board: a light for each right answer.

It’s for two to six players, bots welcome, on the table service. What’s its own is the typed answer, judged by the app and, for a near miss, by the people at the table, and the libraries, files of questions a separate content build fills (ADR 0058).

At a glance#

Part Where
Rules: turns, moving, asking, judging, the final, view tables/quiz_whiz/rules.py
The board’s track tables/quiz_whiz/board.py (drawn by games/static/games/quiz-whiz/board.js)
Checking a typed answer tables/quiz_whiz/answers.py
The libraries tables/quiz_whiz/library.py, tables/quiz_whiz/library/ (the content build’s)
Bots tables/quiz_whiz/bots.py
The table: chips, the question, the six, Your move, the result games/static/games/quiz-whiz/table.js
The waiting room’s library picker; its menu games/static/games/quiz-whiz/setup.js; games/static/games/quiz-whiz/menu.js, written by tools/quiz-whiz/menu.py
Room kit entry; How to play games/static/games/quiz-whiz/app.js, games/static/games/quiz-whiz/rules.js
Look games/static/games/quiz-whiz/quiz-whiz.css (tokens, fonts), games/static/games/quiz-whiz/table.css
Pages games/templates/games/quiz-whiz.html, games/templates/quiz-whiz/page.html, the box games/templates/games/thumbs/quiz-whiz.html
Tests; the play check tests/test_quiz_whiz.py, tools/quiz-whiz/play.mjs (with tools/quiz-whiz/peek.py)
Design notes docs/quiz-whiz.md

The rules in code#

Players and settings#

min_players, max_players = 2, 6, bots welcome. Two settings: the library and its six (one string, library:a,b,c,d,e,f) and Quick game.

tables/quiz_whiz/rules.pyoptions · lines 85–88
def options(raw: dict | None) -> dict:
    """A table's settings, from whatever the host asked for."""
    lib, six = clean_six((raw or {}).get("six") if isinstance(raw, dict) else None)
    return {"six": f"{lib}:{','.join(six)}", "quick": moves.choice(raw, "quick", (False, True), DEFAULTS["quick"])}

A themed library plays its own six; General knowledge, any six of its categories, made good if the host’s list names one twice or one that doesn’t play:

tables/quiz_whiz/rules.pyclean_six · lines 61–82
def clean_six(raw) -> tuple[str, list[str]]:
    """A library and its six categories from "library:a,b,c,d,e,f": a
    themed library plays its own six; General knowledge, any six of its
    categories (the host's, made good: unknown or doubled ones are
    replaced from the standard six)."""
    libs = library.libraries()
    lib, _, picked = str(raw or "").partition(":")
    if lib not in libs:
        lib = "general" if "general" in libs else libs[0]
    cats = library.catalog()[lib]
    if lib != "general":
        return lib, library.default_six(lib)
    six = []
    for s in picked.split(","):
        if s in cats and s not in six:
            six.append(s)
    for s in library.default_six(lib) + list(cats):
        if len(six) >= SIX:
            break
        if s not in six:
            six.append(s)
    return lib, six[:SIX]

The board#

73 spaces: a ring of 42 (six light spaces at the corners, six spaces between each two, the third a roll again), six spokes of 5 in to the hub. A space’s category is a slot, 0 to 5:

tables/quiz_whiz/board.pycategory · lines 52–64
def category(space: int) -> int | None:
    """The slot a space asks from, or None (roll again, the hub)."""
    if space == HUB:
        return None
    if space < RING:
        k, j = divmod(space, SIDE + 1)
        if j == 0:
            return k
        if j == ROLL_AGAIN_AT:
            return None
        return (k + 1 + j) % CORNERS
    k, s = divmod(space - RING, SPOKE)
    return (k + s + 1) % CORNERS

A roll’s destinations are every space exactly that far, never straight back the way you came:

tables/quiz_whiz/board.pydestinations · lines 85–101
def destinations(start: int, steps: int) -> dict[int, list[int]]:
    """Every space a roll of `steps` can end on from `start`, each with one
    way there (the spaces passed through, ending on it): exactly `steps`
    moves, never straight back the way you just came (the box's rule), any
    branch at a junction, across the hub too."""
    nb = neighbors()
    found: dict[int, list[int]] = {}

    def walk(at, prev, left, path):
        if left == 0:
            found.setdefault(at, path)
            return
        for nxt in nb[at]:
            if nxt != prev:
                walk(nxt, at, left - 1, path + [nxt])
    walk(start, None, steps, [])
    return dict(sorted(found.items()))

board.js draws the same layout; slotOf and kindOf there must agree with these.

The state#

Key What
phase roll → move → answer (or pick first, at the hub) → roll again after a right answer, or judge for a close call, or review after a wrong one → the next player’s roll … → over
quick, lib, cats Quick game; the library and the six category slugs, in slot order
up, first, turn, rolls, asked Whose turn; who went first; counters that key every seeded draw
pos, lit Each seat’s space (none in a quick game) and its six lights
die, dests The roll and where it can land (each with a path, for the token’s hop)
q The question up: its slot, level, text, whether it’s a light or the final question, and the card’s answer, accept and fact (the one secret)
said, verdict, counted, form What was typed, the app’s verdict, whether it counted, the form it matched
judging, challenged, review_until A table vote (why: close or challenge, voters, votes, until); whether “Close enough?” is spent; when a wrong answer’s review ends
picker, pick_for Who picks a category, for the hub or the final
used, score, bots, gone, finish Questions asked per category (carried to the next game in _carry); each seat’s asked and right; the seats bots are playing; who left; how it ended

Asking and answering#

A landing asks a question of the space’s category; a right answer on an unlit light lights it:

tables/quiz_whiz/rules.pymove · lines 196–222
def move(state, seat, action, now):
    if state["phase"] != "move":
        raise Illegal("Roll first.")
    if seat != state["up"]:
        raise Illegal("It's not your turn.")
    to = action.get("to")
    path = next((p for space, p in state["dests"] if space == to), None)
    if type(to) is not int or path is None:
        raise Illegal(f"Move {state['die']} spaces: pick one of the lit spaces.")
    state["pos"][seat] = to
    state["dests"] = []
    kind = board.kind(to)
    log.event(state, "move", seat=seat, at=to, path=path, space=kind, slot=board.category(to))
    if kind == "again":
        state["phase"] = "roll"
        log.event(state, "again", seat=seat)
        return
    if kind == "hub":
        if all_lit(state, seat):
            _final_pick(state)
        else:
            state["phase"] = "pick"
            state["pick_for"] = "hub"
            state["picker"] = seat
        return
    slot = board.category(to)
    _ask(state, slot, light=kind == "light" and not state["lit"][seat][slot])

Questions come from the category in a seeded order, none repeated at a table until the category runs out:

tables/quiz_whiz/rules.py_draw · lines 241–252
def _draw(state: dict, slug_: str) -> dict:
    """A question from the category nobody at this table has had yet (since
    the category last ran out)."""
    cat = library.category(state["lib"], slug_)
    used = state["used"].setdefault(slug_, [])
    pool = [q for q in cat.questions if q["id"] not in set(used)]
    if not pool:
        used.clear()
        pool = list(cat.questions)
    q = random.Random(f"{state['seed']}:q:{slug_}:{state['asked']}").choice(pool)
    used.append(q["id"])
    return q

An answer is judged by the app; a close one goes to the people at the table, or, with no one to ask, the app decides alone:

tables/quiz_whiz/rules.pyanswer · lines 288–329
def answer(state, seat, action, now):
    if state["phase"] != "answer":
        raise Illegal("There's no question to answer.")
    if seat != state["up"]:
        raise Illegal("It's not your question.")
    q = state["q"]
    if "recall" in action:
        # (A bot's answer: whether it knows, its seat's to say; the words, the card's.)
        if seat not in state["bots"]:
            raise Illegal("Type your answer.")
        rng = random.Random(f"{state['seed']}:bot:{state['asked']}")
        text = _slip(q["answer"], rng) if action["recall"] and action.get("typo") else \
            q["answer"] if action["recall"] else _decoy(state, rng)
    else:
        text = action.get("text")
        if text is not None and not isinstance(text, str):
            raise Illegal("Type your answer.")
    text = clean(text, ANSWER_LEN)
    found = check_answer(text, q["accept"]) if text else {"verdict": "wrong", "form": q["answer"], "near": False}
    state["said"] = text
    state["verdict"] = found["verdict"]
    state["form"] = found["form"]
    log.event(state, "answer", seat=seat, text=text, verdict=found["verdict"], answer=q["answer"], slot=q["slot"])
    if found["verdict"] == "right":
        _right(state, now)
        return
    if found["verdict"] == "close":
        voters = [s for s in people(state) if s != seat]
        if not voters:
            # (No one to ask: the app judges a near miss alone.)
            log.event(state, "judged", count=found["near"], by="app", yes=0, no=0)
            if found["near"]:
                _right(state, now)
            else:
                _wrong(state, now, challenge=False)
            return
        state["phase"] = "judge"
        state["judging"] = {"why": "close", "voters": voters, "votes": {}, "until": now + JUDGE_SECS, "by": None,
                            "near": found["near"]}
        log.event(state, "close", seat=seat, text=text, answer=q["answer"])
        return
    _wrong(state, now, challenge=True)

A table vote: a near miss counts on a tie and on no votes at all; a challenge must win the table over.

tables/quiz_whiz/rules.py_resolve · lines 397–411
def _resolve(state: dict, now: float) -> None:
    j = state["judging"]
    yes = sum(1 for v in j["votes"].values() if v)
    no = len(j["votes"]) - yes
    if j["why"] == "close":
        # (A near miss: ties count, and with no votes it's the app's call.)
        count = j["near"] if not j["votes"] else yes >= no
    else:
        count = yes > no  # (a challenge must win the table over)
    state["judging"] = {**j, "until": None, "result": count}
    log.event(state, "judged", count=count, by="table", yes=yes, no=no, why=j["why"])
    if count:
        _right(state, now)
    else:
        _wrong(state, now, challenge=False)

tick ends a wrong answer’s review after 6 seconds (the next player is up) and closes a vote after 30.

Checking a typed answer#

Both sides are put in one plain form, then compared:

tables/quiz_whiz/answers.pyplain · lines 77–88
def plain(text: str) -> str:
    """The one plain form both sides are compared in."""
    s = unicodedata.normalize("NFKD", str(text or ""))
    s = "".join(c for c in s if not unicodedata.combining(c)).casefold()
    s = s.replace("&", " and ").replace("’", "'").replace("‘", "'")
    s = re.sub(r"(?<=\d),(?=\d{3}\b)", "", s)  # 1,000 → 1000
    s = re.sub(r"'", "", s)  # don't → dont, o'keeffe → okeeffe
    s = re.sub(r"[^\w\s]|_", " ", s)  # hyphens, periods, the rest: spaces
    words = s.split()
    while words and words[0] in ARTICLES and len(words) > 1:
        words = words[1:]
    return " ".join(_numbers(words))
tables/quiz_whiz/answers.pyjudge · lines 129–162
def judge(text: str, accept) -> dict:
    """{"verdict": "right" | "close" | "wrong", "form": the accepted form it
    matched or came nearest, "near": whether a close answer is near enough
    for the app to count it alone}."""
    said = plain(text)
    forms = [f for f in dict.fromkeys(str(a) for a in accept or ()) if plain(f)]
    if not said or not forms:
        return {"verdict": "wrong", "form": forms[0] if forms else "", "near": False}
    squeezed = said.replace(" ", "")
    for f in forms:
        p = plain(f)
        if said == p or squeezed == p.replace(" ", ""):
            return {"verdict": "right", "form": f, "near": True}
    best = None
    for f in forms:
        p = plain(f)
        if _digits(p) != _digits(said):
            continue  # a different number is never a near miss
        target = p.replace(" ", "")
        d = distance(squeezed, target)
        if d <= slack(len(target)):
            near = d <= near_slack(len(target))
            if best is None or d < best[0]:
                best = (d, f, near)
        # The right answer with a word or two round it.
        sw, pw = said.split(), p.split()
        if len(pw) <= len(sw) <= len(pw) + EXTRA_WORDS and len(target) > 3:
            for i in range(len(sw) - len(pw) + 1):
                if sw[i:i + len(pw)] == pw and not ({"not", "no", "never"} & set(sw)):
                    if best is None or best[0] > 0:
                        best = (0.5, f, False)
    if best:
        return {"verdict": "close", "form": best[1], "near": best[2]}
    return {"verdict": "wrong", "form": forms[0], "near": False}

How many slipped letters a near miss may have, and how many the app forgives alone:

tables/quiz_whiz/answers.pyslack · lines 109–117
def slack(n: int) -> int:
    """How many slips a near miss of an answer this long may have."""
    if n <= 3:
        return 0
    if n <= 5:
        return 1
    if n <= 9:
        return 2
    return 3 if n <= 14 else 4
tables/quiz_whiz/answers.pynear_slack · lines 120–122
def near_slack(n: int) -> int:
    """How many slips the app forgives by itself."""
    return 0 if n <= 4 else 1 if n <= 10 else 2

The contract#

As tables/engine.py asks: new, act (moves in MOVES, through tables.moves.dispatch), tick and deadline for the review and the vote, view, deciding (the player up; the picker; each voter yet to vote), holds_up (a vote closes by itself), note, round_no (the turn), result, counts, terms, idle_after (150 seconds: a question takes thought) and bot_think.

Secrets#

The card (its answer, accepted forms and fact) is no one’s until it’s answered; then it’s everyone’s, so the answerer sees “You said · The card says” and the table can vote. Everything else is public: the question is read to the whole table.

tables/quiz_whiz/rules.pyview · lines 503–532
def view(state: dict, seat) -> dict:
    phase = state["phase"]
    q = state["q"]
    shown = None
    if q:
        shown = {k: q[k] for k in ("slot", "level", "text", "light", "final", "seat")}
        if phase != "answer":
            # (Answered: the card is read out to everyone.)
            shown.update(answer=q["answer"], accept=q["accept"][:6], fact=q["fact"])
    j = state["judging"]
    me_voter = j is not None and seat is not None and seat in j["voters"]
    return {
        "game": slug, "seq": state["seq"], "n": state["n"], "phase": phase, "quick": state["quick"],
        "lib": state["lib"], "lib_title": dict(library.LIBRARIES).get(state["lib"], state["lib"]), "cats": _cats(state),
        "up": state["up"], "first": state["first"], "turn": state["turn"],
        "pos": list(state["pos"]) if state["pos"] else None, "lit": [list(r) for r in state["lit"]],
        "die": state["die"], "dests": [list(d) for d in state["dests"]],
        "kinds": {str(space): board.kind(space) for space, _ in state["dests"]},
        "q": shown, "said": state["said"], "verdict": state["verdict"], "counted": state["counted"],
        "form": state["form"] if phase != "answer" else None,
        "judging": {"why": j["why"], "voters": list(j["voters"]), "votes": dict(j["votes"]), "until": j.get("until"),
                    "by": j["by"], "result": j.get("result")} if j else None,
        "my_vote": j["votes"].get(str(seat)) if me_voter else None,
        "challengers": challengers(state), "review_until": state["review_until"],
        "picker": state["picker"], "pick_for": state["pick_for"],
        "score": [dict(s) for s in state["score"]],
        "bots": list(state["bots"]), "gone": sorted(state["gone"]),
        "finish": dict(state["finish"]) if state["finish"] else None, "rated": counts(state),
        "events": log.recent(state, seat),
    }

Bots#

A bot’s level is how often it knows the answer, by the question’s level:

tables/quiz_whiz/rules.pyKNOWS · lines 50–55
# How often a bot knows the answer, by its level and the question's.
KNOWS = {
    "easy": {"easy": 0.6, "medium": 0.35, "hard": 0.15},
    "medium": {"easy": 0.8, "medium": 0.6, "hard": 0.35},
    "hard": {"easy": 0.92, "medium": 0.8, "hard": 0.6},
}

It never sees the card. Its move says only whether it knows (and, now and then, that it slips a letter); the server fills in the words: the card’s answer, the answer with two letters swapped, or another card’s answer from the category. Only a seat a bot is playing may answer that way (bots, kept up to date by note as seats change hands). Bots move toward the lights they still need, pick categories at random, and never vote or challenge:

tables/quiz_whiz/bots.pybot_action · lines 48–67
def bot_action(state, seat, level, rng):
    v = view(state, seat)
    kind = next((k for s, k, _ in deciding(state) if s == seat), None)
    if kind in ("roll", "next"):
        return {"type": "roll"}
    if kind == "move":
        spaces = [space for space, _ in v["dests"]]
        if level == "easy" and rng.random() < 0.3:
            return {"type": "move", "to": rng.choice(spaces)}
        best = max(_score(v, seat, s, level) for s in spaces)
        return {"type": "move", "to": rng.choice([s for s in spaces if _score(v, seat, s, level) == best])}
    if kind == "pick":
        return {"type": "pick", "slot": rng.randrange(6)}
    if kind == "answer":
        q = v["q"]
        knows = rng.random() < KNOWS.get(level, KNOWS["medium"]).get(q["level"], 0.5)
        return {"type": "answer", "recall": knows, "typo": knows and rng.random() < TYPO}
    if kind == "vote":
        return {"type": "vote", "count": True}  # (never asked: bots don't vote)
    return {"type": "roll"}

The libraries#

One TOML file per category, library/<library>/<slug>.toml, its questions [[q]] with id, level, text, answer, accept and fact (the content build’s format). A seed/ folder of the same shape, if there is one, is read after library/ (the build’s seed set was deleted once the library landed). A category plays with 6 questions or more, a library with six such categories:

tables/quiz_whiz/library.pycatalog · lines 117–135
@lru_cache(maxsize=1)
def catalog() -> dict[str, dict[str, Category]]:
    """Every library's playable categories, by slug, in the menu's order."""
    out: dict[str, dict[str, Category]] = {}
    for lib, _ in LIBRARIES:
        found: dict[str, Category] = {}
        for root in ROOTS:
            folder = root / lib
            if not folder.is_dir():
                continue
            for path in sorted(folder.glob("*.toml")):
                if path.stem not in found:
                    found[path.stem] = _read(path, lib, seed=root.name == "seed")
        order = ORDER.get(lib, ())
        ranked = sorted((c for c in found.values() if len(c) >= MIN_QUESTIONS),
                        key=lambda c: (order.index(c.slug) if c.slug in order else len(order), c.title))
        if len(ranked) >= 6:
            out[lib] = {c.slug: c for c in ranked}
    return out

The waiting room’s list is the same catalog written out for the page, menu.js; uv run python tools/quiz-whiz/menu.py rewrites it, and a test fails while it’s stale.

Rating#

Like any table game kept for signed-in players: the winner first, the rest by lights lit, ties sharing (Ratings); bots at the default anchors. An ended game isn’t kept.

The interface#

The room#

games/static/games/quiz-whiz/app.js calls the room kit’s start() with min: 2, max: 6, bots, Fill with bots, and two settings: the library picker, drawn by the game itself through the room kit’s settings[].draw (the library, the six slots in their colors and shapes, General knowledge’s categories in their four groups, or a themed library’s six with a line each; pick a slot, then a category, and a category already in the six swaps into it), and Quick game Off or On.

The table, in fixed places#

The frame is the house table’s: the bar on the studio floor (Log L, How to play ?, Options, End game for the host), the board at the left as large as the height allows, and the side column: the players’ chips (seat mark, name, light badge, lights out of 6; the player up ringed in the turn color), the question (the category’s ribbon and shape, “Light question” or “Final question”, the question in 24px Lexend, then your answer field, the table’s vote, or the card’s answer and fact), The six (your lights), and the Your move card pinned at the foot. On a phone: the chips under the bar, the board full width (it steps aside while you type), the question and the card. On a phone on its side, the board at the left and the column beside it.

  • Moving. After a roll the die sits on the floor and the spaces you can reach wear the turn color’s ring; click one, or one of the Your move card’s buttons (each with its category’s shape). Every other screen sees the same spaces, dashed. With Walk tokens space by space on (a helper, on by default), a token hops along its path.
  • Answering. Enter sends; Pass is a quiet button. After a wrong answer, the Your move card offers “Close enough?” to the people who may ask.
  • Voting. A voter’s Your move card asks “Does it count?” with Doesn’t count and Count it; the question panel shows each voter’s vote as it lands and the time left.
  • The payoff. A light switching on is the house’s strong announcement (ADR 0051) in the studio’s colors, between two rows of marquee bulbs: the light badge flares, “Science & Nature: lit!”, and how many to go. Reaching the final raises one too. A win ends on the result sheet with the room’s celebration.

Its look#

Quiz Whiz’s personality, in the design system’s personality sheet.

Family Board and party: the room kit in a look of its own, bots welcome, checked on phones (Board and party games).
Palette Ink #f4f0ff on the studio’s #110828, a magenta spot up to the left and a teal one low right; the one fill and the turn #2de2c8; good #a9e689 and bad #ff9bb3. The six categories: blue #4b8dff circle, magenta #f0459b square, yellow #ffd23f triangle, violet #9b6cff diamond, lime #7bd84a star, orange #ff8a3d plus: never only the color.
Faces Bungee (titles, headings, the marquee, “Right!”), Lexend (everything you read)
Corners and edges No rail: the studio runs edge to edge; panels 12px, buttons 10px
Board The lit stage, the one hero: a chrome rim of marquee bulbs, the glossy plum floor recessed in it, raised spaces with their shapes, die icons on the roll-again spaces, the six light hexagons, the marquee hub (“QUIZ WHIZ”, “FINAL QUESTION”, its bulbs and magenta glow), raised chrome tokens each carrying its player’s light badge and seat mark
Details A teal tape X where the host stands, the “QW-86 · Studio B” plate, the spotlight that follows whoever’s up, the die on the floor after a roll
Emblem and art Its box: a token on a lit corner, three of its lights on, the die beside the board
Voice Lights, the six, the hub, the final question, Right!, close call, Close enough?, Count it
Payoff A light switching on: the badge flares in the strong band (“Science & Nature: lit!”); the room’s celebration for a win
The glance Whose move and what to do (the Your move card and the ring on their chip and token), everyone’s lights (the chips’ badges), and the question (the panel)
Exceptions The question panel scrolls within itself if a long question and its card outgrow the column, so the Your move card stays in view

Testing#

tests/test_quiz_whiz.py:

Group Proves
Board 73 spaces, six lights, six roll agains, a hub; each side and spoke mixes the categories; a roll’s exact count with no doubling back; across the hub
Answers Case, accents, punctuation, articles, numbers in words; a different number is never close; slips scaled to length; the app’s own forgiveness; a word round it; a blank
Library General knowledge and Harry Potter play; every accepted form is right; ids unique; the page’s menu is current
Settings, turns, quick game Any six; a themed library’s own six; who rolls; right keeps the turn; a light lights; roll again; the hub’s free pick; the final and the win; the card’s secret; no repeats; the carry to the next game; the quick game’s lights and final
Judging, bots A near miss to the people; bots never vote; ties and noes; the vote’s clock; Close enough? once; only a bot recalls; a leaver’s vote goes
Contract, whole games, service The shared TableGameContract; bots playing the board at two and six, and a quick game of Harry Potter, to a winner; a table through the service; the catalog entry

npm run check:quiz-whiz:play (tools/quiz-whiz/play.mjs) plays a whole game in headless Chrome: the browser hosts and plays through the page (a category picked in the waiting room, rolls, lit spaces, typed answers), Ada plays over the API, a bot fills the third seat, and both people answer from the card (tools/quiz-whiz/peek.py reads it from the site’s database). A near miss each way is voted on, a light raises its band, and the game ends on the result sheet with no errors and no sideways scroll (--quick, --reduced, --shots dir).

Changing it#

To… You need
Change a rule rules.py, docs/quiz-whiz.md and How to play (rules.js) together, and a test. State is plain JSON and a live table’s doesn’t migrate.
Change the board board.py and board.js together (their tests compare the layouts).
Loosen or tighten answer checking slack, near_slack and EXTRA_WORDS in answers.py, with tests for the cases that matter.
Add a library or a category Its TOML file in tables/quiz_whiz/library/<library>/, then uv run python tools/quiz-whiz/menu.py. A new library also needs its line in LIBRARIES.
Change how often bots know KNOWS in rules.py.

Known issues#

  • Most questions were checked by an editor’s second read, not each against a named source (checked says which); a few were looked up, and the themed libraries name a source wherever a fact was.
  • Not every category is written yet: General knowledge has 39 of its 40 (Music: Today waits on a decision); Harry Potter, Star Wars, Disney & Pixar, Hollywood, Food and Potpourri have all six.
  • No clock on answers. A stalled answer waits for the room’s idle rule.
  • The final’s category is the next player’s, not the table’s together (docs/quiz-whiz.md, open question 1).