Technical guide

Board and party games

Alphabet Roulette

Roll a letter, beat the clock, and think of what nobody else will.

Checked against the code on 2026-10-30. Play Alphabet Roulette

On this page
  1. At a glance
  2. The rules in code
    1. Players and settings
    2. The state
    3. The die and the lists
    4. The write and the server clock
    5. What counts
    6. Challenges and votes
    7. The result, and when a game counts
    8. The contract
  3. Your move
  4. Secrets
  5. Bots
  6. Rating
  7. The interface
    1. The room
    2. One screen, in fixed places
  8. Its look
  9. Content
  10. Testing
  11. Changing it
  12. Known issues

Alphabet Roulette is the classic categories game with a twenty-sided letter die. Each round, a die roll picks a letter, and everyone writes an answer starting with it for each of twelve categories before the timer runs out. Only answers nobody else wrote score. After three rounds, the highest total wins.

It’s for two to twelve people in one room or on a call, each on their own device. There are no bots (ADR 0031), because the fun is in arguing over the answers. It’s a table game on the table service, built like Bowl. What’s its own is the review, where the app strikes the obvious (blanks, wrong letters, the same answer twice) and the table votes on the rest.

At a glance#

Part Where
Rules: the die, lists, write, review, votes, view tables/alphabet_roulette/rules.py
The lists: 72 of 12 categories tables/alphabet_roulette/content/lists.toml
The table: tray, clock, pad, review, scores, result sheet games/static/games/alphabet-roulette/table.js
The die (an icosahedron, one SVG) games/static/games/alphabet-roulette/die.js
Room kit entry; How to play games/static/games/alphabet-roulette/app.js, games/static/games/alphabet-roulette/rules.js
Look games/static/games/alphabet-roulette/alphabet-roulette.css (tokens, fonts), games/static/games/alphabet-roulette/table.css
Pages games/templates/games/alphabet-roulette.html, games/templates/alphabet-roulette/page.html, the box games/templates/games/thumbs/alphabet-roulette.html
Tests; the play check tests/test_alphabet_roulette.py, tools/alphabet-roulette/play.mjs
Design notes docs/alphabet-roulette.md

The rules in code#

Players and settings#

min_players, max_players = 2, 12, bots = False and chat = False: played in one room or on a call, it offers the host no Chat, though emotes float as at every table (ADR 0052). When someone leaves, no bot takes the seat (the service puts none in a game without bots, and refuses the host’s cover). The game hears note("leave") and steps the seat aside: nothing waits on it, and its pad keeps what it wrote. They can come back in, someone waiting can take the seat, and the next game deals without it.

A game is always 3 rounds (David’s call). The host picks two things:

tables/alphabet_roulette/rules.pyoptions · lines 63–68
def options(raw: dict | None) -> dict:
    """A table's settings, from whatever the host asked for."""
    return {
        "timer": moves.choice(raw, "timer", TIMERS, DEFAULTS["timer"]),
        "bonus": moves.choice(raw, "bonus", (True, False), DEFAULTS["bonus"]),
    }
Setting Choices Standard Effect
timer 180, 120, 60 180 Seconds to write each round (the box’s sand timer is three minutes)
bonus on, off on The alliteration bonus: a point for every word that starts with the letter

The state#

One dict per table. The parts that matter:

Key What
phase roll → write → review → scored (between rounds) … → over
round, list, lists, before The round; this round’s list; this game’s lists; lists played at this table before (carried in _carry)
roller, letters, letter, roll Who rolls; letters played; this round’s; the roll’s time and landing (at, lands)
starts, ends, live, paused The write’s clock, in server time
pads, pad_v, done Everyone’s twelve answers; the last save each page sent; who’s put their pencil down
cat, ready, challenges The category in review; who has pressed Next; this round’s challenges and votes
played, scores, totals Each round as played (pads, rulings, points); round points; totals
gone Seats whose players left

The die and the lists#

The die is the box’s: ABCDEFGHIJKLMNOPRSTW, twenty letters with no Q, U, V, X, Y or Z. A roll never repeats a letter already played this game (David’s call). The roller is random for round 1 and moves round the table after, skipping anyone gone; the host can roll for an absent roller.

tables/alphabet_roulette/rules.pyroll · lines 196–209
def roll(state, seat, action, now):
    if state["phase"] != "roll":
        raise Illegal("The die's been rolled.")
    if seat != state["roller"] and not action.get("host"):
        raise Illegal("It's not your roll.")
    left = [c for c in DIE if c not in state["letters"]]
    letter = random.Random(f"{state['seed']}:roll:{state['round']}").choice(left)
    state["letter"] = letter
    state["letters"].append(letter)
    state["roll"] = {"letter": letter, "at": now, "lands": now + ROLL_SECS, "by": seat}
    state["starts"] = now + ROLL_SECS + COUNTDOWN
    state["ends"] = state["starts"] + state["secs"]
    state["phase"] = "write"
    log.event(state, "roll", round=state["round"], letter=letter, roller=state["roller"], by=seat)

Every round draws a list. A table doesn’t see a list twice until it has played them all: played lists go into _carry at the end, and the next game at the table gets them as _before.

tables/alphabet_roulette/rules.py_pick_list · lines 145–153
def _pick_list(state: dict) -> str:
    """A list this table hasn't played; once it's played them all, any this
    game hasn't."""
    rng = random.Random(f"{state['seed']}:list:{state['round']}")
    for skip in (state["before"] + state["lists"], state["lists"]):
        pool = [item["id"] for item in LISTS if item["id"] not in set(skip)]
        if pool:
            return rng.choice(pool)
    raise ValueError("No list left to play.")  # (only with fewer lists than rounds)

The write and the server clock#

After a roll, the die tumbles for 2.4 seconds on every screen, then a 3-second “Ready, set, go”, then the pads open. Pencils go down at the deadline plus a 1-second grace, so a keystroke made as a phone showed 0:00 isn’t lost in transit. If everyone presses Done, pencils go down at once. The host can pause the clock.

tables/alphabet_roulette/rules.pytick · lines 558–569
def tick(state: dict, now: float) -> bool:
    if state["phase"] != "write" or state["paused"]:
        return False
    if not state["live"] and now >= state["starts"]:
        state["live"] = True
        log.event(state, "go", round=state["round"])
        _bump(state)
        return True
    if state["live"] and now >= state["ends"] + GRACE:
        _pencils_down(state, now, "time")
        return True
    return False

A page saves its whole pad as you type (pad), numbered, so a save that arrives after a newer one changes nothing.

tables/alphabet_roulette/rules.pypad · lines 223–236
def pad(state, seat, action, now):
    """Your pad as it stands: every answer, saved as you type. `v` counts up
    with each save from the page, so one that arrives late changes nothing."""
    _writing(state, seat)
    v = action.get("v", 0)
    if type(v) is not int:
        raise Illegal("That move didn't make sense.")
    if v and v <= state["pad_v"][seat]:
        return
    answers = action.get("answers")
    if not isinstance(answers, list) or len(answers) != CATEGORIES:
        raise Illegal("That move didn't make sense.")
    state["pads"][seat] = [clean(a, ANSWER_LEN) if isinstance(a, str) else "" for a in answers]
    state["pad_v"][seat] = v

What counts#

words splits an answer at spaces and hyphens, strips marks, and skips a leading a, an or the if something follows it. So “The Beatles” is a B.

tables/alphabet_roulette/rules.pywords · lines 84–91
def words(text: str) -> list[str]:
    """An answer's words, the way the letter sees them: split at spaces and
    hyphens, marks round each taken off, and a leading a, an or the not
    counted (so long as something follows it)."""
    out = [w for w in (_bare(w) for w in _plain(text).replace("&", " and ").replace("-", " ").replace("–", " ").split()) if w]
    if len(out) > 1 and out[0] in ARTICLES:
        out = out[1:]
    return out

Two answers are the same if they match once case, spaces, marks, a leading article and a plural s are ignored (“Bananas” and “a banana”):

tables/alphabet_roulette/rules.pysame · lines 105–111
def same(text: str) -> str:
    """What makes two answers the same one: case, spaces, marks, a leading
    article and a plural "s" don't count ("Bananas" and "a banana")."""
    ws = [w.replace("'", "").replace("’", "") for w in words(text)]
    if ws and len(ws[-1]) > 3 and ws[-1].endswith("s") and not ws[-1].endswith("ss"):
        ws[-1] = ws[-1][:-1]
    return " ".join(ws)

marks does the review’s work. It gives every seat’s answer to one category a status (blank, wrong letter, voted out, same as another’s, or ok) and points. An ok answer scores 1, or with the bonus, one per word starting with the letter, unless a vote took the bonus away.

tables/alphabet_roulette/rules.pymarks · lines 299–345
def marks(letter: str, pads: list[list[str]], challenges: list[dict], bonus: bool, cat: int) -> list[dict]:
    """Every seat's answer to one category, as it stands: its status
    ("blank", "letter", "out", "same" or "ok"), its points, how many words
    start with the letter, and who else's it's the same as."""
    n = len(pads)
    texts = [pads[s][cat] for s in range(n)]
    status = []
    for s, text in enumerate(texts):
        if not text.strip() or not words(text):
            status.append("blank")
        elif not starts_with(text, letter) and not _ruled(challenges, cat, s, "appeal", True):
            status.append("letter")
        elif _ruled(challenges, cat, s, "answer", False):
            status.append("out")
        else:
            status.append("ok")
    # The same answer twice scores for no one: the same by the rule above,
    # or ruled the same by a vote.
    group = list(range(n))

    def root(s):
        while group[s] != s:
            group[s] = group[group[s]]
            s = group[s]
        return s

    keys = {}
    for s in range(n):
        if status[s] == "ok":
            k = same(texts[s])
            if k in keys:
                group[root(s)] = root(keys[k])
            else:
                keys[k] = s
    for c in challenges:
        if c["cat"] == cat and c["kind"] == "same" and c["decided"] is False and status[c["seat"]] == "ok" and status[c["other"]] == "ok":
            group[root(c["seat"])] = root(c["other"])
    out = []
    for s in range(n):
        twins = [o for o in range(n) if o != s and status[s] == "ok" and status[o] == "ok" and root(o) == root(s)]
        k = alliteration(texts[s], letter) if status[s] != "blank" else 0
        st = "same" if twins else status[s]
        points = 0
        if st == "ok":
            points = k if bonus and k > 1 and not _ruled(challenges, cat, s, "bonus", False) else 1
        out.append({"text": texts[s], "status": st, "points": points, "words": k, "twins": twins})
    return out

Challenges and votes#

Anyone may challenge an answer in the category on screen. There are four kinds (KINDS):

Kind Raised by On If the vote goes against it
answer anyone an answer that scores it scores nothing
bonus anyone an answer scoring a bonus it scores 1
same anyone two answers that each score both score nothing
appeal its writer their own wrong-letter answer it counts after all (for an appeal, “for it” is the change)

Each vote starts filled in for the people involved: the writer (both writers, for same) for it, the challenger against. A challenge clears everyone’s Next (ready), so the deciding vote can’t also move on: before, if everyone had pressed Next, the last vote decided it and moved to the next category in one move, and no one saw the ruling (ADR 0054). Everyone votes, and it’s decided when all have voted or the table moves on. Majority rules. On a tie, by the box’s rule, the challenged player’s vote is thrown out (TIE_DROPS_WRITER) and the rest decide. Still tied, the answer stays as it was.

tables/alphabet_roulette/rules.pytally · lines 445–460
def tally(c: dict, voters) -> bool:
    """How a vote comes out: majority rules; on a tie, the box throws out the
    challenged player's vote (TIE_DROPS_WRITER) and the rest decide; still
    tied, the answer stays as it was."""
    cast = {s: v for s, v in c["votes"].items() if int(s) in voters}
    status_quo = c["kind"] != "appeal"

    def count(votes):
        yes = sum(1 for v in votes.values() if v)
        no = len(votes) - yes
        return None if yes == no else yes > no

    result = count(cast)
    if result is None and TIE_DROPS_WRITER:
        result = count({s: v for s, v in cast.items() if int(s) not in c["involved"]})
    return status_quo if result is None else result

So with two people, a challenge stands unless the writer convinces the challenger, and an appeal fails unless the other agrees. With three, the third player decides.

Everyone presses Next after reading a category, and can’t while they owe a vote. When everyone has and no vote is open, the next category comes up. A category nobody answered is skipped. The host can move on without waiting, which closes open votes as they stand.

The result, and when a game counts#

After the twelfth category the round’s points are added (tables.rounds), and everyone says they’re ready for the next round (or the host starts it). After round 3 the highest total wins, and ties share a place. result is rounds.result: points by round, totals and places. A game counts (goes in history and rates) only if it reached the end of round 3:

tables/alphabet_roulette/rules.pycounts · lines 578–581
def counts(state: dict) -> bool:
    """Whether the game goes in anyone's history and rates: only played to
    the end of its third round."""
    return state["phase"] == "over"

The contract#

As tables/engine.py asks: new, act (moves in MOVES, through tables.moves.dispatch), tick and deadline for the clock, view, deciding, holds_up, ready, note, round_no, result, counts, terms, idle_after (300 seconds, because the review is talked over), and bot_action and fallback for the test suite only. deciding names the roller during a roll, each writer until done (never holding up the table, since the clock ends it), each reader who hasn’t pressed Next or owes a vote, and the ready between rounds.

Your move#

The room kit’s YourMove (Your move at a table) prompts three moments: Roll the die on your first roll (later rolls ring the die after a few idle seconds); Check the answers (“Challenge any that don’t fit, then Next category”) on your first review; and Vote on a challenge the first time you owe a vote. The write gets nothing, because its clock is running.

Secrets#

A pad is secret from everyone but its writer until pencils are down. Everyone sees how many answers each person has filled in, and who’s done. After that, answers are public category by category as the review reaches them (later categories aren’t sent), and every vote as it’s cast. A spectator sees what a player sees, without a pad.

tables/alphabet_roulette/rules.pyview · lines 622–664
def view(state: dict, seat) -> dict:
    phase = state["phase"]
    list_id = state["list"]
    reviewing = phase == "review"
    review = None
    if reviewing:
        review = {
            "cat": state["cat"],
            "ready": list(state["ready"]),
            # The categories read so far and the one on screen; the rest
            # wait their turn.
            "marks": [_mark(state, k) for k in range(state["cat"] + 1)],
            "challenges": [_challenge_view(c) for c in state["challenges"]],
        }
    played = []
    for r in state["played"]:
        per_cat = [marks(r["letter"], r["pads"], r["challenges"], state["bonus"], k) for k in range(CATEGORIES)]
        played.append({
            "round": r["round"], "letter": r["letter"], "list": list_no(r["list"]), "categories": categories(r["list"]),
            "marks": per_cat, "points": r["points"],
            "challenges": [_challenge_view(c) for c in r["challenges"]],
        })
    rolled = state["roll"]
    return {
        "game": slug, "seq": state["seq"], "n": state["n"], "phase": phase,
        "round": state["round"], "rounds": state["rounds"], "secs": state["secs"], "bonus": state["bonus"],
        "roller": state["roller"], "letters": list(state["letters"]),
        "letter": state["letter"], "roll": dict(rolled) if rolled else None,
        "starts": state["starts"], "ends": state["ends"], "live": state["live"], "paused": state["paused"],
        "list": {"id": list_id, "no": list_no(list_id), "categories": categories(list_id)} if list_id else None,
        "pad": list(state["pads"][seat]) if seat is not None and phase == "write" else None,
        "pad_v": state["pad_v"][seat] if seat is not None and phase == "write" else 0,
        "filled": [sum(1 for a in p if a.strip()) for p in state["pads"]],
        "done": list(state["done"]),
        "review": review,
        "played": played,
        "scores": [list(r) for r in state["scores"]],
        "totals": list(state["totals"]),
        "ready": list(state["ready"]) if phase == "scored" else None,
        "places": rounds.places(state["totals"]) if phase == "over" else None,
        "gone": sorted(state["gone"]),
        "events": log.recent(state, seat),
    }

Bots#

None. bot_action exists only for the test suite’s simulated people (level "sim"), which roll, write, sometimes challenge, vote and press Next from what their seat sees. Any other bot-held seat steps aside (fallback).

Rating#

A game played to the end, kept for at least one signed-in player, is rated everyone for themselves, by place, among people only. A guest counts as an unknown 1500 ± 350 who never changes (Ratings).

The interface#

The room#

games/static/games/alphabet-roulette/app.js calls the room kit’s start() with min: 2, max: 12, bots: false, spectate, the two settings as house rules, and deadline, so a sheet over the pad shows the time left.

One screen, in fixed places#

At the top is the tray: the die, the letter, a status line and the clock. Below it is your pad of twelve lines, then Done (which becomes Keep writing) and the host’s Pause. The scores sit beside it on a wide screen, or in a strip at the top on a phone.

  • The roll. die.js draws the icosahedron as one SVG, each face lit by its angle to a lamp. The server’s clock times the roll and the round seeds it, so every screen shows the same tumble at the same moment. A late arrival sees it landed; reduced motion lands it at once.
  • The write. Each answer saves 450 ms after you stop typing, when you leave the line, and as the clock runs out. Letter check (a helper, on by default) marks an answer with the wrong letter as you type.
  • The review replaces the pad, one category at a time. Each answer is a row with its points, why it doesn’t score if it doesn’t, and Challenge, which offers only what can still be raised about it. Your own wrong-letter answer has It counts? instead. A vote sits under its answer as two plainly labeled buttons. Next category reads Vote first while you owe a vote.

Keys: N presses Next, ? opens How to play. Sounds (the die landing, 10 seconds left, time’s up) are on by default, off in Options, and play only once the page has been touched (ADR 0031). Banners (“Time!” with “Pencils down.” under it, or “Everyone’s in!” when everyone pressed Done, the family’s words for those moments, as in Fictionary and Bowl, and still said when every answer was blank and the round goes straight to its score; “Round 2”, “Round 2 scored”, and each next category in the review, “4. Things that are flat”) show only while still true, from a live snapshot, never after a reload, as in Bowl. The writer of an answer the table ruled on is told in a toast (“The table ruled on your answer: doesn’t count.”). On a phone, where there’s no sidebar, the score strip says who the table is waiting on (“3 still writing”, “Waiting on 2”). The clock goes the warning red under ten seconds, and stays 44px on a phone (ADR 0054). The result sheet shows places, then every round’s answers with their points.

Its look#

Alphabet Roulette’s personality, in the design system’s personality sheet.

Family Board and party: the room kit in a look of its own, live, people only, checked on phones. Everything else is the room kit’s (Board and party games).
Palette Ink #fff6e0 on its own ground; the one fill #ffd34d; yours and your turn #ffd34d; good #8fe0a6 and bad #ff9a8e. Game night on the coffee table in the 1980s: a deep blue table, the answer pad in pale yellow with blue rules and a red margin, the die a coral icosahedron with cream letters.
Faces Big Shoulders Display (a game-show marquee) for the letter, the clock and titles; Rubik for everything you read; Caveat for the answers written on the pad
Corners and edges The rail 18px, panels 14px, buttons 12px
Board The tray with the die, which tumbles on every screen at once and lands on the letter, the clock, and your pad.
Emblem and art Its box on the shelf
Voice The die, a list, the pad, challenges and votes
Payoff The room’s celebration for a win
The glance The letter rolled, shown large, and the clock. Its glance isn’t yet named in its design notes.
Exceptions The die’s letters drawn in each face’s tilted plane (not centered, and shaded on the faces turned away, on purpose), and the rolled letter centered as a box beside its line

Content#

tables/alphabet_roulette/content/lists.toml holds 72 lists of 12 categories, 864 in all and every one different (ListsTests checks). They were written here in twelve kinds (people, places, food, nature, around the house, shows and books, sports and pastimes, “things that are…”, words, school and work, travel and clothes, wild cards), one of each kind to every list. A list’s id is fixed once shipped, because a table’s _carry names them: retire a list, never renumber it.

Testing#

tests/test_alphabet_roulette.py:

Group Proves
Words Articles and marks, the bonus, what makes two answers the same
Game Rounds, the die, rolling, the clock and grace, late saves, secret pads, Done, pause
Review What scores, skipped categories, Next and moving on, every kind of challenge, the tie rule both ways
Whole games, leaving, lists Two to twelve simulated people; lists carrying over; nothing waits for someone gone
Contract and service The shared TableGameContract; a game played through the service and kept

npm run check:alphabet-roulette:play (tools/alphabet-roulette/play.mjs) plays three rounds in headless Chrome, the browser as host and two seats over the API: the roll, the pad, a reload, the letter check, a challenge and its vote, a shared answer struck, and a page that never scrolls sideways or repeats a banner.

Changing it#

To… You need
Change a rule rules.py, docs/alphabet-roulette.md and How to play (rules.js) together, and a test. State is plain JSON and a live table’s doesn’t migrate, so a new key needs a default.
Let a tie stand TIE_DROPS_WRITER = False.
Add or retire a list lists.toml: new ids at the end; retire by removing, never renumbering. Keep every category different (ListsTests).
Change a time ROLL_SECS (and ROLL_MS in die.js), COUNTDOWN, GRACE, TIMERS. The client’s WARN, LATE and SAVE_MS are separate.
Change what’s the same same only; the server sends each answer’s status.
Change the letter check words and startsWith in table.js mirror words and starts_with; change both.

Known issues#

  • “The same” is only what the rule matches. “Mouse” and “Mice”, or “Coke” and “Coca-Cola”, aren’t matched; the table has to vote them the same.
  • No away. A player whose phone dies holds up the review (their Next, their vote) until the host moves on, and the next roll until the host rolls for them.
  • Next can’t be taken back. You can still vote and challenge after pressing it, but the category moves on once everyone has and no vote is open.
  • Lists carry only at the same table. A new table can deal a list you played yesterday.