The site
The table service
One service on the server runs every multiplayer game, and shows each player only what their own seat may see.
On this page
Every game you play with other people, or against bots at a table, runs on one part of the server: the table service, tables/. It deals, keeps every secret, runs the bots and clocks, and shows each browser only what its seat may see. A game brings only its rules.
The package is tables/, but its Django app label is still cards: it began as Contract Rummy’s table (ADR 0008) before it ran every multiplayer game (ADR 0014), and the label names the database tables, migrations and admin addresses. Keeping it avoided a migration.
tables/apps.py TablesConfig · lines 4–18class TablesConfig(AppConfig):
# The package was `cards` when card games were the only table games; the
# label stays, so the database tables, migrations and admin addresses keep
# their names (docs/adr/0014).
name = "tables"
label = "cards"
verbose_name = "Tables"
def ready(self):
from . import signals # noqa: F401
from .engine import check
from .games import GAMES
for game in GAMES.values():
check(game)
The browser side, the room kit, is in the front end; each game’s rules are in its own chapter.
At a glance#
| File | What it does |
|---|---|
tables/engine.py |
The contract a game meets, its defaults, the startup check, and advance, which runs bots and timers. |
tables/games.py |
The registry: every game the service can run, by slug. |
tables/service.py |
Making, joining and running tables: seats, bots, the host’s controls, watching, sitting in, moves, time, snapshots. |
tables/models.py |
Table, Seat and Entry (the log), and Result / ResultSeat (history). |
tables/views.py |
The JSON API and the live stream. |
tables/talk.py |
Emotes and chat: what a seat says to the table, apart from the game. |
tables/urls.py |
Its addresses, under each game at /games/<slug>/tables/. |
tables/notify.py |
Telling every open stream that a table changed. |
tables/sight.py |
Who may see what: the to filter. |
tables/history.py, tables/pages.py, tables/history_urls.py |
Kept games, and their pages at /games/<slug>/history/. |
tables/signals.py |
What a deleted account leaves in history. |
tables/commit.py, tables/reveal.py, tables/rounds.py, tables/log.py, tables/moves.py |
Helpers a game builds its rules from: sealed picks, timed reveals, rounds and scores, log lines, taking a move. |
tables/text.py, tables/content.py, tables/deck.py |
Typed text made safe, TOML content banks, playing cards as strings. |
crowd/ |
Anonymous answer tallies across every table, reached only through the service. |
tests/table_contract.py |
The tests every table game mixes in. |
tests/tablegames/odd_one_out.py |
Odd One Out, the smallest table game: the worked example. |
How a move travels#
A game with secrets can’t trust a browser with the state, so the state never leaves the server. A move arrives as a small JSON request. The service locks the table’s row, reads the clock once and hands the rules the state, seat, move and time. The rules change the state or refuse; the service saves it and announces the change. Each browser’s stream then draws its own seat’s view, so four players see four pictures and a spectator a fifth.
The rules are pure: no database, no clock, no randomness they weren’t handed. So they’re testable and replayable, and bots can play at full speed. The service does all the IO.
The engine contract#
A game is a module (in practice a package whose __init__.py re-exports its rules.py) with the right names. tables/engine.py describes them as a typing.Protocol, Game, but nothing inherits from it: the service just calls the functions.
The required parts#
tables/engine.py REQUIRED · lines 176–177REQUIRED = ("slug", "min_players", "max_players", "options", "new", "act", "tick", "deadline", "view", "deciding",
"bot_action", "fallback", "over")
| Part | What it does |
|---|---|
slug, min_players, max_players |
The game’s address and how many seats it takes. |
options(raw) |
The table’s settings, cleaned: unknown keys dropped, unaccepted values set to the default. Every setting reaches a game through this. |
new(players, seed, now, settings) |
A fresh game, dealt from the seed. |
act(state, seat, action, now) |
Apply one seat’s move, or raise Illegal (its message is shown to the player). |
tick(state, now) |
Let time pass: open a pick, close a window. True if anything changed. |
deadline(state) |
When tick next has something to do, or None. |
view(state, seat) |
What one seat may see, as plain JSON; seat=None is a spectator. The only route from the state to a browser. |
deciding(state) |
The decisions waiting now, as (seat, kind, key). The kind picks a bot’s think time; the key names the decision, so a bot’s wait survives other seats’ moves but not the decision changing. |
bot_action(state, seat, level, rng) |
The move a bot of this level makes. |
fallback(state, seat, kind) |
A move that’s always legal, for when a bot’s own choice is refused. |
over(state) |
Whether the game has ended. |
A game with "ready" decisions also needs ready(state, seat, now): the host’s Deal button calls it for everyone not yet ready.
The optional parts#
| Part | What it does |
|---|---|
result(state) |
How a finished game came out: hand_scores, totals and places per seat. Without it, a game is never kept in history. |
terms |
What the game calls a round, and whether low totals win. |
bot_think |
Seconds a bot takes over each kind of decision, laid over the defaults. |
round_no(state) |
Which round it is; when it changes, someone waiting may take a bot’s seat. |
note(state, seat, what, by=None) |
Hear about a seat changing hands, to log it. |
away, away_due, away_after |
Decide for a seat whose player has gone, so they don’t hold up the table. |
holds_up(state, seat, kind) |
Whether the table waits on this decision (one that closes by itself doesn’t). |
arrange(state, seat, keep=None) |
A careful “arrange it for me”, worked out only when asked. |
prepare(seed, settings) |
Read what the deal needs from the database, through the service (below). |
counts(state) |
Whether a finished game counts for history and ratings. |
seating |
"together" (the default) or "joined". |
bots |
False for a game only people can play (ADR 0031). |
chat |
False for a game played in one room or on a call: the host isn’t offered Chat (emotes still float; ADR 0052). |
teams, team_names |
A game played in teams: how many, and their default names. |
Checked at startup#
The site checks every registered game as it starts, so a missing part fails the deploy, not the first table:
tables/engine.py check · lines 198–207def check(game: Game) -> None:
"""Refuse a game missing something the service needs, naming it, when
the site starts rather than at the first table."""
missing = [name for name in REQUIRED if not hasattr(game, name)]
if missing:
raise ImproperlyConfigured(f"The table game {getattr(game, 'slug', game)!r} is missing {', '.join(missing)}.")
if not 1 <= game.min_players <= game.max_players <= MAX_SEATS:
raise ImproperlyConfigured(f"The table game {game.slug!r} seats {game.min_players} to {game.max_players}; tables seat 1 to {MAX_SEATS}.")
if seating(game) not in SEATINGS:
raise ImproperlyConfigured(f"The table game {game.slug!r} seats people {seating(game)!r}; say one of {', '.join(SEATINGS)}.")
check sees only the required names, the seat range and seating. It can’t know which decisions a game makes, so a missing ready is left to the game’s own tests; without one, the host’s first Deal fails. Tables seat at most 20, raised from twelve for Bowl, a party game for a whole room (ADR 0031).
The state’s conventions#
The state is one JSON object, saved whole in Table.state. Its shape is the game’s, but some keys mean something to the service:
| Key | Who writes it | What it means |
|---|---|---|
seq |
the game | Goes up with every change any seat could see. |
events, logged |
the game (through tables.log) |
The game’s log lines, {"id", "type", …}, and the last id. A line with "to": [seats] is for those seats only. |
phase |
the game | Its own name for where play is. |
_bot |
engine.advance |
Each bot’s pending decision and when it’s due. |
_pace |
the game, from its options | A multiplier on every bot’s think time (a table’s “Bot speed”). |
_since |
the service | When each seat’s current decision became theirs, for “holding up the table”. |
_logged |
the service | The last event already copied into the table’s log. |
_out, _carry |
the game | Records for the service to write, and a little to hand the next game at the table. |
_finished, ended |
the service | When the game ended; whether the host ended it early. |
When time passes with no move, browsers hear only if seq, phase, logged or the number waiting to sit in changed. A bot’s wait being noted, or an idle clock moving, is saved but isn’t news.
Time is an argument#
No rules module reads the clock. now is passed to new, act and tick, and every timer is a deadline in the state. So a test plays a whole game in milliseconds, and two bots at one table take turns at simulated times.
advance is the loop the service runs whenever it touches a table: let time pass, let any bot whose think time is up move, repeat until nothing is due.
tables/engine.py advance · lines 210–258def advance(game: Game, state: dict, bots: dict[int, str], now: float, rng: random.Random, limit: int = 200,
away=()) -> float | None:
"""Do everything that's due by `now`: the game's timers, bots' moves,
and whatever can be decided for the `away` seats (every time round, so a
buy a bot's discard opens doesn't wait on someone who's gone).
Each bot decision waits its think time after the moment it became the
bot's to make. The wait is kept in the state (`_bot`) under the
decision's key, so a stale wait never fires. Returns when to call again.
"""
for _ in range(limit):
for seat in away:
game.away(state, seat, now)
game.tick(state, now)
if game.over(state):
return None
due = state.setdefault("_bot", {})
acted = False
live = game.deciding(state)
for stale in set(due) - {str(seat) for seat, _, _ in live}:
del due[stale]
for seat, kind, decision in live:
level = bots.get(seat)
if level is None:
continue
key = str(seat)
wait = due.get(key)
if not wait or wait[0] != decision:
wait = due[key] = [decision, now + think(game, kind) * state.get("_pace", 1.0)]
if now < wait[1]:
continue
del due[key]
action = game.bot_action(state, seat, level, rng)
try:
game.act(state, seat, action, now)
except Illegal:
# A bot's mistake mustn't jam the table: make the plainest legal move.
log.exception("Bot at seat %s tried %r", seat, action)
game.act(state, seat, game.fallback(state, seat, kind), now)
acted = True
break # the state changed: look again from the top
if not acted:
break
if any(seat in bots and str(seat) not in state.get("_bot", {}) for seat, _, _ in game.deciding(state)):
return now # stopped at the limit with a bot still to move
waits = [w[1] for w in state.get("_bot", {}).values()]
timer = game.deadline(state)
times = waits + ([timer] if timer is not None else [])
return min(times) if times else None
- A bot’s think time starts when the decision became its own. The wait is stored under the decision’s key, so another seat’s move doesn’t reset it, but a changed decision drops it.
- A bot’s mistake never jams the table. A refused move is logged and the game’s
fallbackplayed instead. - One move at a time. After any bot acts the loop starts over, since that move may have opened or closed a decision.
- It returns when to call again: the soonest bot wait or deadline, kept as
Table.wake_at.
Think times are the engine’s defaults, overlaid by the game’s bot_think:
| Decision | Seconds |
|---|---|
| draw | 1.2 |
| play | 1 |
| buy | 1.2 |
| ready | 0 |
tables/engine.py, BOT_THINKAny other kind takes 1 second. Every wait is multiplied by _pace, which most games set from a “Bot speed” setting:
| Bot speed | Multiplier |
|---|---|
| brisk | 1 |
| relaxed | 1.8 |
tables/moves.py, BOT_PACEA game’s own words#
tables/engine.py TERMS · lines 168–170# What a game calls its rounds, and whether the highest total wins. Contract
# Rummy plays hands, lowest first.
TERMS = {"round": "round", "rounds": "rounds", "low_wins": False}
terms words a game’s pages: Contract Rummy plays “hands”, lowest total first; a party game plays “rounds”, highest first. seating is "together" (people side by side, bots filling the rest of the ring) or "joined" (everyone in arrival order, as in Contract Rummy). With bots = False there’s no Add a bot and no cover (the service refuses one with “This game is played with people, not bots.”), and someone who leaves mid-game gets no bot either: the game hears it through note and steps them aside, their seat waits for them (or for someone sitting in), and the next game deals without it.
Registering a game#
tables/games.py GAMES · lines 22–38GAMES = {g.slug: g for g in (
alphabet_roulette,
auction_house,
bowl,
bullshit,
contract_rummy,
euchre,
fictionary,
get_in_line,
last_one_standing,
michigan_rails,
michigan_rummy,
millbury_mansion,
quiz_whiz,
sixty_six,
vibe_check,
)}
One import and one line per game, alphabetical, so games built on separate branches merge cleanly. A game here also needs table=True in games/catalog.py; the tests check both ways. The service finds rules only through GAMES[table.game].
A whole game: Odd One Out#
tests/tablegames/odd_one_out.py is a complete table game in under two hundred lines, built only from the helpers. It’s never on the site; tests add it to GAMES themselves. Each round everyone secretly picks heads or tails, the picks come out together, and whoever is on the smaller side scores. Every party game follows this pattern (ADR 0017): open a pick, take the picks when due, score, play a reveal, open the next pick.
Settings and the deal#
tests/tablegames/odd_one_out.py options · lines 24–25def options(raw: dict | None) -> dict:
return {"timer": moves.choice(raw, "timer", TIMERS, 0), "bot_pace": moves.choice(raw, "bot_pace", tuple(moves.BOT_PACE), "brisk")}
moves.choice takes a setting only if it’s exactly one of the allowed values (so True isn’t taken for 1), else the default. Whatever a host or saved house rules send, the game sees only this.
tests/tablegames/odd_one_out.py new · lines 28–39def new(players: int, seed: str, now: float, settings: dict | None = None) -> dict:
if not min_players <= players <= max_players:
raise Illegal(f"Odd One Out is for {min_players} to {max_players} players.")
settings = options(settings)
state = {
"game": slug, "n": players, "seed": seed, "timer": settings["timer"], "_pace": moves.BOT_PACE[settings["bot_pace"]],
"seq": 0, "logged": 0, "events": [], "round": 0, "phase": "pick",
"scores": [], "totals": [0] * players, "ready": [False] * players,
}
log.event(state, "round", round=0)
_ask(state, now)
return state
The deal puts everything the game needs in the state: the seat count n (every helper reads it), the settings, the counters the service watches (seq, logged) and the scores. Then it logs the first round and asks:
tests/tablegames/odd_one_out.py _ask · lines 42–45def _ask(state: dict, now: float) -> None:
# The pick waits for the last reveal to finish playing.
last = state.get("reveal")
commit.open(state, now, seconds=state["timer"] or None, after=last["ends"] - now if last else 0.0)
commit.open starts a sealed pick for every seat. Untimed, it waits as long as it takes. after keeps it closed until the last reveal has finished, so no one is asked while results are on screen.
Taking a move#
tests/tablegames/odd_one_out.py MOVES · line 62MOVES = {"pick": pick, "ready": ready_move}
tests/tablegames/odd_one_out.py act · lines 65–66def act(state: dict, seat: int, action: dict, now: float) -> None:
moves.dispatch(state, seat, action, now, MOVES, tick=tick)
A move is a dict with a type. moves.dispatch does what every game repeats:
tables/moves.py dispatch · lines 12–29def dispatch(state: dict, seat: int, action, now: float, moves: dict, tick=None) -> None:
"""The engine's `act`, for a game whose moves are a table of handlers
{type: handler(state, seat, action, now)}: time passes first (`tick`, the
game's own), so a move can't land in a window that's closed; a request
that isn't a move, or that a handler chokes on, is refused politely; and
a move that's taken bumps `seq`. (A refused move changes nothing that's
kept: the service saves only moves that go through.)"""
if tick is not None:
tick(state, now)
if not isinstance(action, dict) or not isinstance(action.get("type"), str) or action["type"] not in moves:
raise Illegal("That isn't a move in this game.")
if not isinstance(seat, int) or not 0 <= seat < state["n"]:
raise Illegal("That seat isn't at this table.")
try:
moves[action["type"]](state, seat, action, now)
except (TypeError, ValueError, AttributeError, KeyError, IndexError, StopIteration):
raise Illegal(GARBLED)
state["seq"] = state.get("seq", 0) + 1
It runs tick first, so a move can’t land in a window that already closed. It refuses unknown moves and seats, turns a handler choking on a malformed request (a missing key, a wrong type) into a polite refusal instead of a server error, and bumps seq once the move is taken. A refused move changes nothing: the service saves only after act returns.
commit.put does the pick handler’s work: it checks the seat is being asked and the pick is open, and replaces any earlier pick.
tests/tablegames/odd_one_out.py pick · lines 50–55def pick(state, seat, action, now):
if state["phase"] != "pick":
raise Illegal("There's nothing to pick right now.")
if action["side"] not in SIDES:
raise Illegal("Pick heads or tails.")
commit.put(state, seat, action["side"])
When the picks are in#
tests/tablegames/odd_one_out.py tick · lines 69–74def tick(state: dict, now: float) -> bool:
changed = commit.wake(state, now)
if state["phase"] == "pick" and commit.due(state, now):
_reveal(state, now)
changed = True
return changed
tick runs on every move and whenever something’s due. It opens a pick whose after has passed, and once the pick is due (everyone has picked, or a timer ran out) it scores the round:
tests/tablegames/odd_one_out.py _reveal · lines 77–91def _reveal(state: dict, now: float) -> None:
picks, filled = commit.take(state, lambda seat: "heads")
sides = {side: [s for s in range(state["n"]) if picks[s] == side] for side in SIDES}
small = min(SIDES, key=lambda side: len(sides[side]))
scorers = [] if len(sides["heads"]) == len(sides["tails"]) or not sides[small] else sides[small]
rounds.score(state, [int(s in scorers) for s in range(state["n"])])
alone = [seats[0] for seats in sides.values() if len(seats) == 1]
log.event(state, "picks", round=state["round"], heads=sides["heads"], tails=sides["tails"], scored=scorers, filled=filled)
beats = [{"cue": "flip", "ms": 1200, "say": f"Heads: {len(sides['heads'])}. Tails: {len(sides['tails'])}.", **sides}]
for s in alone:
log.event(state, "alone", to=[s], seat=s, side=picks[s])
beats.append({"cue": "alone", "ms": 600, "say": f"You were the only one on {picks[s]}.", "to": [s]})
beats.append({"cue": "score", "ms": 800, "say": "No one scores." if not scorers else f"{len(scorers)} scored.", "scored": scorers})
reveal.play(state, beats, now)
rounds.end(state, last=state["round"] == ROUNDS - 1)
Say Ada and Basil pick heads and Clara tails. commit.take returns the picks, filling in heads for anyone who missed a deadline. Tails is the smaller side, so Clara scores (two against two, or everyone together, scores no one). The picks go in a public log line. Clara, alone on her side, also gets a private line and beat, both with "to": [2]. reveal.play then starts the script: a flip, Clara’s beat, the score. The beats’ ms add up to the reveal’s length, and every browser plays each beat at the same moment by the server’s clock. Finally rounds.end marks the round scored, or the game over after the last of 3.
Between rounds, everyone says they’re ready:
tests/tablegames/odd_one_out.py ready · lines 94–95def ready(state: dict, seat: int, now: float) -> None:
rounds.ready(state, seat, now, _next_round)
tests/tablegames/odd_one_out.py _next_round · lines 98–102def _next_round(state: dict, now: float) -> None:
state["round"] += 1
state["phase"] = "pick"
log.event(state, "round", round=state["round"])
_ask(state, now)
When the last seat is ready, the next pick opens with after set to the reveal’s end, if it’s still playing. The pick exists but isn’t live: deciding is empty and deadline says when it opens. The service keeps that as wake_at, and the first stream to look after it ticks the table: the pick goes live, seq moves, every browser hears.
What a seat sees#
tests/tablegames/odd_one_out.py view · lines 115–121def view(state: dict, seat) -> dict:
return {
"game": slug, "seq": state["seq"], "n": state["n"], "round": state["round"], "of": ROUNDS, "phase": state["phase"],
"scores": state["scores"], "totals": state["totals"], "ready": list(state["ready"]),
"pick": commit.seen(state, seat), "reveal": reveal.view(state, seat),
"events": log.recent(state, seat),
}
Each part comes through a helper that knows what’s secret. commit.seen shows whose picks are in (so the roster can tick people off) but only this seat’s own pick. reveal.view and log.recent drop beats and lines for other seats. A spectator, seat=None, gets everything public and nothing addressed to anyone.
Who’s deciding, and bots#
tests/tablegames/odd_one_out.py deciding · lines 124–125def deciding(state: dict) -> list[tuple[int, str, str]]:
return commit.deciding(state) + rounds.ready_deciding(state)
While a pick is open, every seat that hasn’t picked is deciding ("pick", keyed by the pick’s id); between rounds, every seat that isn’t ready ("ready"). A bot thinks 1 second over a pick, a kind neither the engine nor the game times, and readies at once (the engine’s think time for "ready" is 0 seconds).
tests/tablegames/odd_one_out.py bot_action · lines 149–158def bot_action(state, seat, level, rng):
"""Easy bots toss a coin. The others go against last round's crowd."""
v = view(state, seat)
if v["phase"] == "scored":
return {"type": "ready"}
last = next((e for e in reversed(v["events"]) if e["type"] == "picks"), None)
if level == "easy" or not last:
return {"type": "pick", "side": rng.choice(SIDES)}
crowd = "heads" if len(last["heads"]) >= len(last["tails"]) else "tails"
return {"type": "pick", "side": "tails" if crowd == "heads" else "heads"}
The bot decides from view(state, seat), never the state, so it can’t peek; the contract tests check this. An easy bot tosses a coin. The others pick against the side the crowd took last round, read from the last public picks line.
The rest handles people who’ve gone:
tests/tablegames/odd_one_out.py away · lines 136–139def away(state, seat, now):
"""Someone away gets heads, so an untimed pick doesn't wait on them."""
if away_due(state, seat):
commit.put(state, seat, "heads", auto=True)
An untimed pick waits for everyone, so a closed tab would stall the table. The service calls away for anyone whose page hasn’t checked in for a while, and the game makes the safe pick for them, marked auto so it counts as missed. A timed pick closes by itself, so it never holds up the table (holds_up).
The end#
tests/tablegames/odd_one_out.py round_no · lines 161–164def round_no(state: dict) -> int:
"""Which round it is: someone waiting to sit in may take a bot's seat
as the next one starts (the bot's points go with it)."""
return state["round"]
tests/tablegames/odd_one_out.py result · lines 167–168def result(state: dict) -> dict:
return rounds.result(state, high=True)
round_no lets someone waiting take a bot’s seat when a round starts. result is what history keeps: each seat’s score by round, totals and places, ties sharing a place (rounds.places gives 1, 1, 3).
The service#
tables/service.py is everything between a request and the rules. Every function that changes a table has the same shape: open a transaction, lock the table’s row, check who’s asking, do the work, _bump, commit, notify.publish.
Making a table#
tables/service.py create · lines 65–90def create(game: str, name: str, solo: bool = False, bots=(), options=None) -> tuple[Table, Seat, str]:
rules = GAMES[game]
name = clean_name(name)
bots = [b for b in bots if b in BOT_LEVELS]
if (solo or bots) and not has_bots(rules):
raise Illegal("This game is played with people, not bots: start a table and share the link.")
if solo and not rules.min_players - 1 <= len(bots) <= rules.max_players - 1:
raise Illegal(f"Play against {rules.min_players - 1} to {rules.max_players - 1} bots.")
Table.objects.filter(updated__lt=timezone.now() - IDLE_TABLES_KEPT).delete()
with transaction.atomic():
for _ in range(20):
try:
with transaction.atomic():
table = Table.objects.create(code=new_code(), game=game, solo=solo, options=_options(rules, options, solo))
break
except IntegrityError:
continue
seat = Seat(table=table, index=0, name=name, host=True, seen=time.time())
secret = seat.give_secret()
seat.save()
for level in bots:
_add_bot(table, level)
if solo:
_start(table)
notify.publish(table.code)
return table, seat, secret
A code is 6 random characters from an alphabet without the ones people misread or mistype (no 0/O, no 1/I/L): ABCDEFGHJKMNPQRSTUVWXYZ23456789. If one is taken, the insert fails on the unique constraint inside a savepoint and the service tries another.
The table’s maker is seat 0 and the host. Their browser gets a secret; the database keeps only its hash:
tables/models.py Seat.def give_secret(self) -> str:
secret = secrets.token_urlsafe(24)
self.token = digest(secret)
return secret
That secret is the seat: no account, cookie or session is involved. Losing it means watching, unless the seat is linked to an account, which can take it back on any device (below). A solo table (one person against bots) deals at once; a table for friends waits in its lobby.
Joining, and names#
tables/service.py join · lines 151–170def join(code: str, name: str) -> tuple[Table, Seat, str]:
with transaction.atomic():
table = _locked(code)
if table.status == Table.CLOSED:
raise Illegal("That table has closed.")
if table.status != Table.LOBBY:
raise Illegal("That game has already started.")
rules = GAMES[table.game]
if table.seats.count() >= rules.max_players:
raise Illegal("That table is full.")
name = clean_name(name)
_bots_give_way(table, name)
seat = Seat(table=table, index=table.seats.count(), name=_unique(table, name), seen=time.time())
secret = seat.give_secret()
seat.save()
_regroup(table)
seat.refresh_from_db()
_bump(table)
notify.publish(code)
return table, seat, secret
You can join only in the lobby, while a seat is free. Names are cleaned first:
tables/text.py clean · lines 33–41def clean(text, max_len: int) -> str:
"""Compatibility forms folded (NFKC: full-width letters, ligatures),
control and format characters dropped (so no bidi override can turn a
name round, nor a zero-width space make two names look alike; only a
joiner between two emoji stays, so 👨👩👧 and 👩🏽💻 are still one each),
runs of spaces made one, and cut to `max_len`."""
text = unicodedata.normalize("NFKC", str(text or ""))
words = (_word(word) for word in text.split())
return " ".join(w for w in words if w)[:max_len].strip().rstrip(ZWJ)
NFKC folds full-width letters and ligatures into ordinary ones. Dropping control and format characters means no bidirectional override can reverse a name and no zero-width space can make two names look alike. Only what holds an emoji together stays: a zero-width joiner between two emoji (so a family, a skin-toned coder or a rainbow flag is one picture, not three) and the tags after a black flag (a subdivision’s flag); variation selectors were never dropped. Two people can’t share a name at a table (the second Ray is “Ray 2”), compared without joiners (same), since two emoji side by side can be drawn like two joined; and a person keeps theirs: a bot with the same name takes another (_bots_give_way).
Bots#
Bots come in easy, medium and hard, each with a random unused name from a short list (_bot_name; in development UO_BOT_NAMES=ordered takes the first free name, so the layout and details checks see the same names every run). A bot is a Seat with bot set to its level and no token. What a level means is each game’s bot_action’s business.
A bot can also play a person’s seat. Someone who leaves mid-game keeps their token (to come back), and the seat gets a bot level and covered, for history (in a game without bots, covered only: no bot plays it). Only the host can hand a seat to a bot (cover; David’s call after the family playtest of 2026-10-29: it had been anyone’s, once a player was stalled). The table tells the host who’s stalled: away 45 seconds (no check-in from their page) or sitting on a decision the table waits on for 60 seconds, or the game’s own idle_after for turns meant to be thought over (Michigan Rails waits 180):
tables/service.py _holders · lines 857–876def _holders(table, now=None) -> set[int]:
"""Seats that have had a move the table waits on for idle_after or more.
Each seat's clock is its own (`_since`): someone else moving doesn't
reset it. A timed buy doesn't count (the game's holds_up says so), as it
closes anyway and naming who hasn't decided would give away who has passed."""
if not table.state or table.status != Table.PLAYING:
return set()
rules = GAMES[table.game]
now = time.time() if now is None else now
since = table.state.get("_since", {})
out = set()
for s, kind, key in rules.deciding(table.state):
if not _holds(rules, table.state, s, kind):
continue
mark = since.get(str(s))
# A table saved before `_since` was kept goes by its last change.
start = mark[1] if mark and mark[0] == _clock(rules, table.state, s, kind, key) else table.updated.timestamp()
if now - start >= idle_after(rules):
out.add(s)
return out
Each seat’s idle clock is its own (_since, under the decision’s key), so someone else moving doesn’t reset it. A timed decision never counts: it closes anyway, and naming who hasn’t made a secret pick would tell the table who has.
For a game with away, a gentler path comes first: each time the table advances, the service decides what it can for anyone away (passing on a buy in Contract Rummy, the safe pick in a party game). away_after lets a game count someone away sooner than the room does.
Seating#
tables/service.py _regroup · lines 526–534def _regroup(table) -> None:
"""In a game that seats people together (engine.seating), people sit
side by side, in the order they came, and bots fill the rest of the
ring, until the host arranges the seats themselves."""
_keep_teams(table)
if seating(GAMES[table.game]) != "together" or _room(table).get("arranged"):
return
seats = list(table.seats.order_by("index"))
_reorder(table, [s for s in seats if s.person] + [s for s in seats if not s.person])
In a game that seats people together, each joiner is moved so people sit side by side and bots fill the rest of the ring. Otherwise people who joined after the host added bots sat beyond them, and a game about your neighbors paired people with bots (ADR 0018). Once the host arranges seats, no one is regrouped again. A seat’s index is its place in the ring; a reorder first moves every seat aside so no two share an index mid-move.
In a team game, each seat’s team is kept by the seat’s own id in Table.room, so removing a seat in the waiting room moves no one else across, and a newcomer joins the smaller team.
Starting a game#
tables/service.py _start · lines 735–772def _start(table: Table) -> None:
rules = GAMES[table.game]
_regroup(table)
n = table.seats.count()
if not rules.min_players <= n <= rules.max_players:
raise Illegal(f"This game needs {rules.min_players} to {rules.max_players} players.")
now = time.time()
seed = secrets.token_hex(8)
# The rules are told which seats bots are playing (a game where bots
# don't do everything people do needs to know), and given whatever
# they asked to read before the deal (the IO edge, below).
settings = {**(table.options or {}), "_bots": sorted(s.index for s in table.seats.all() if s.bot)}
sides = teams_by_seat(table)
if sides is not None:
settings["_teams"] = sides
settings["_team_names"] = team_names(table)
# A game may leave a little for its next game at the same table (its
# state's "_carry": questions it has asked, say), handed over as "_before".
carry = (table.state or {}).get("_carry") if table.state else None
carry = carry if carry is not None else _room(table).get("carry")
if carry is not None:
settings["_before"] = carry
prepare = getattr(rules, "prepare", None)
if prepare:
try:
settings["_data"] = prepare(seed, settings)
except Exception:
# (The rules deal without it: a game's data is a nicety, never a gate.)
logger.exception("Couldn't prepare a %s game", table.game)
table.state = rules.new(n, seed, now, settings)
table.status = Table.PLAYING
_room(table).pop("round", None)
table.room.pop("left", None)
# A new game: covered only if a bot's still playing for someone who left.
table.seats.update(covered=False)
table.seats.exclude(token="").exclude(bot="").update(covered=True)
_advance(table, now)
_bump(table)
Every game gets a random seed. Its settings are the table’s options plus what only the service knows: which seats are bots (_bots), the teams, what the last game here left (_before) and what prepare read (_data). A failing prepare is logged and the game deals without it: content is a nicety, never a gate.
Taking a move#
tables/service.py play · lines 996–1019def play(code: str, secret, action: dict) -> None:
with transaction.atomic():
table = _locked(code)
me = seat_for(table, secret)
if not me:
raise Illegal("You're not at this table.")
if table.status != Table.PLAYING:
raise Illegal("The game isn't on.")
if me.bot:
raise Illegal("A bot is playing for you. Take your seat back first.")
if me.index in (table.room or {}).get("left", []):
raise Illegal("You left this game. Come back in first.")
if isinstance(action, dict) and "host" in action:
# (The host's moves come through host(…, "move"), which says so.)
raise Illegal("That isn't a move in this game.")
rules = GAMES[table.game]
now = time.time()
_advance(table, now)
rules.act(table.state, me.index, action, now)
me.seen = now
me.save(update_fields=["seen"])
_advance(table, now)
_bump(table)
notify.publish(code)
_locked is select_for_update() on the table’s row, so two moves at once queue instead of racing (SQLite has no row locks, so locally every transaction opens IMMEDIATE, taking the write lock up front). The table advances before the move, so anything due happens first, and after it, so bots respond. A player’s move may never carry "host", the key that marks a host’s move (below), so no player can forge one.
Letting time pass#
There’s no background worker or scheduler. Timers and bots run when someone next looks after wake_at: a stream, a poll or a move.
tables/service.py tick · lines 1022–1048def tick(code: str) -> bool:
"""Let time pass at a table whose timer is due. True if anything moved."""
with transaction.atomic():
try:
table = Table.objects.select_for_update().get(code=code)
except Table.DoesNotExist:
return False
if table.status != Table.PLAYING or table.wake_at is None or table.wake_at > time.time():
return False
# What anyone could see is marked by these three (tables.engine): a
# bot's wait or an idle clock moving on is kept, but isn't news.
# (Someone sitting in at a new round changes the seats: news too.)
seen = lambda: (table.state.get("seq"), table.state.get("phase"), table.state.get("logged"), len((table.room or {}).get("waiting", [])))
before = seen()
_advance(table, time.time())
changed = before != seen()
if changed:
_bump(table)
else:
# Nothing anyone can see moved: keep the next wake (and the
# state's own clocks) without a new version or a new `updated`.
out = _take_out(table)
table.save(update_fields=["wake_at", "state", "room"] if out else ["wake_at", "state"])
_spill(table.game, out)
if changed:
notify.publish(code)
return changed
tick takes the lock, checks again that the table is due (another stream may have got there first) and advances it. If nothing visible changed, it saves the state and next wake without bumping the version or updated, so browsers aren’t woken for nothing. An unwatched table simply stops; when someone returns, advance catches it up, 200 moves a call, asking to be called again at once if bots still have moves due.
tables/service.py _advance · lines 1051–1082def _advance(table: Table, now: float) -> None:
rules = GAMES[table.game]
seats = list(table.seats.all())
bots = {s.index: s.bot for s in seats if s.bot}
left = set((table.room or {}).get("left", []))
people = [s for s in seats if s.person and not s.bot and s.index not in left]
# Whatever can be decided for someone who's away (passing on a buy) is,
# so they don't hold up everyone else.
# (A game may count someone away sooner than the room does: away_after.)
after = getattr(rules, "away_after", ONLINE)
gone = [s.index for s in people if s.seen is None or now - s.seen >= after] if hasattr(rules, "away") else []
wake = advance(rules, table.state, bots, now, random.Random(), away=gone)
# Nothing else may be due (the upcard has no timer), so wake when someone
# who owes a decision would count as away. Each is still in the future:
# anyone already gone was just decided for.
due = getattr(rules, "away_due", None)
if due and not rules.over(table.state):
wakes = [s.seen + after for s in people if s.index not in gone and due(table.state, s.index)]
wake = min([w for w in (wake, *wakes) if w is not None], default=None)
table.wake_at = wake
_track(rules, table.state, now)
_sit_in_at_new_round(table, rules)
if rules.over(table.state):
if table.status != Table.OVER:
# Counted here, not in a view: a bot's move can end the game too.
# The count stays anonymous; the game is kept only for the
# signed-in players at it (docs/adr/0010).
Event.objects.create(kind="table_finished", game=table.game, mode="solo" if table.solo else "together")
table.state.setdefault("_finished", now)
history.record(table)
table.status = Table.OVER
table.wake_at = None
The end of a game is noticed here, not in a view, because a bot’s move can end it too. The anonymous table_finished count is recorded, the end time noted, and the game kept in history if anyone at it is signed in.
What changes, and who hears#
tables/service.py _bump · lines 392–405def _bump(table: Table) -> None:
table.version += 1
fresh = []
if table.state:
# Move the game's new events into the table's log.
last = table.state.get("_logged", 0)
fresh = [e for e in table.state.get("events", []) if e["id"] > last]
if fresh:
table.state["_logged"] = fresh[-1]["id"]
out = _take_out(table)
table.save()
Entry.objects.bulk_create(
[Entry(table=table, game=table.games_played, n=e["id"], data=e) for e in fresh], ignore_conflicts=True)
_spill(table.game, out)
Table.version goes up with every change browsers should hear about. _bump also copies new log lines into Entry rows (one per line, for the whole game; the state keeps only the last 400) and writes what the game left in _out, in the same transaction as the state.
The host’s controls#
Everything the waiting room and the table’s menu can do goes through service.host(code, secret, op, **args). Most ops are the host’s alone:
| Op | What it does |
|---|---|
add_bot, bot_level, remove |
Seat a bot, change its level, or remove a seat (lobby only). |
arrange, teams, team_names |
Seat everyone in a given order; put them in teams; name the teams (lobby only). |
settings |
Change the table’s options, cleaned by the game’s options. |
start, close |
Deal; or close the waiting room, sending everyone back to the lobby. |
cover |
Let a bot play someone’s seat (never in a game without bots). |
next |
Deal the next round without waiting for everyone to be ready. |
end |
Stop a game early: it doesn’t count, and the table can play again. |
rematch |
Back to the waiting room with the same seats (a solo table deals again at once). |
give |
Give a bot’s seat, or the seat of someone who left, to someone waiting to sit in, now. |
move |
A move only the host may make (Bowl’s pause): the game’s act, with "host": True. |
Anyone at the table can leave, reclaim (take their seat back from a bot, or from having left a game without bots), pass_for (pass on a buy for someone stalled, gentler than a bot) and unreserve (give up a place at the next game). cover is the host’s alone: anyone else is told “Only the host can do that.” A host whose own seat a bot is playing takes it back first.
tables/service.py _op_move · lines 971–985def _op_move(table, me, action=None):
"""A move only the host may make (a party game's pause, say): the
game's own `act`, told it's the host's by `"host": True` (engine.Game).
The host's seat makes it, even with a bot playing for them."""
if table.status != Table.PLAYING:
raise Illegal("The game isn't on.")
if not isinstance(action, dict):
raise Illegal("That move didn't make sense.")
if me.bot:
raise Illegal("A bot is playing for you. Take your seat back first.")
rules = GAMES[table.game]
now = time.time()
_advance(table, now)
rules.act(table.state, me.index, {**action, "host": True}, now)
_advance(table, now)
Who hosts#
There’s always someone who can run the table. A host who leaves a game hands it on, and so does a host who goes quiet without pressing Leave (a closed tab, a phone gone to sleep, even on their own turn) once they’ve been away 60 seconds (David’s call, 2026-10-29). Both go by one rule: hosting passes to the person at the table seen most recently, whether or not a bot is covering their seat, never to someone who left:
tables/service.py _heir · lines 641–648def _heir(table, host: Seat, seats=None) -> Seat | None:
"""Who hosting passes to when the host leaves or goes quiet: the person
at the table seen most recently, whether or not a bot is covering their
seat (but not anyone who's left the game). One rule for both."""
left = set((table.room or {}).get("left", []))
seats = table.seats.all() if seats is None else seats
people = [s for s in seats if s.person and s.pk != host.pk and s.index not in left]
return max(people, key=lambda s: (s.seen or 0, -s.index), default=None)
The quiet host is noticed as everyone else’s browser checks in: each check-in from anyone but the host looks (one small query), and the first to find the host gone 60 seconds passes hosting under the table’s lock:
tables/service.py _pass_host · lines 655–675def _pass_host(table_id: int) -> bool:
"""The host has been away HOST_AWAY (a closed tab, a phone gone to
sleep, without pressing Leave): hosting passes to whoever's at the table
(`_heir`), so the table never freezes on them. They don't get it back by
returning. True if it passed."""
if not Seat.objects.filter(_host_gone(), table_id=table_id).exists():
return False
with transaction.atomic():
table = Table.objects.select_for_update().filter(pk=table_id).first()
if not table or table.status == Table.CLOSED or table.solo:
return False
# (Looked at again under the lock: someone else's check-in may have passed it.)
host = table.seats.filter(_host_gone()).first()
heir = host and _heir(table, host)
if not heir or heir.seen is None or time.time() - heir.seen >= ONLINE:
return False
host.host, heir.host = False, True
host.save(update_fields=["host"])
heir.save(update_fields=["host"])
_bump(table)
return True
The new host has the powers at once, cover among them, and every browser’s next snapshot shows who hosts; the room toasts it (“You’re the host now.”, in every game, hostNews). An old host who comes back is a player: hosting doesn’t go back to them by itself. With no one left to host, a host leaving ends the game; in the waiting room the host can’t leave, only close the table. The host’s own snapshot names who’d host if they left (heir), so the Leave dialog can say so.
Watching#
Anyone with a table’s link can watch (ADR 0018), seeing view(state, None) and the public log. Spectators are counted, never named: the page makes up an id and sends it as X-Watch, and Table.room keeps only each hashed id’s last check-in.
tables/service.py watch · lines 233–256def watch(table: Table, key: str) -> None:
"""A browser without a seat is watching. Written down at most every
WATCH_EVERY seconds; a new watcher is news (the count shows)."""
if not key:
return
now = time.time()
seen = (table.room or {}).get("watching", {}).get(key)
if seen and now - seen < WATCH_EVERY:
return
with transaction.atomic():
locked = Table.objects.select_for_update().filter(pk=table.pk).first()
if not locked:
return
seen = _room(locked).setdefault("watching", {})
new = key not in seen or now - seen[key] >= ONLINE
seen[key] = now
for gone in [k for k, at in seen.items() if now - at >= ONLINE]:
del seen[gone]
for extra in sorted(seen, key=seen.get)[:-WATCHERS_KEPT]:
del seen[extra]
locked.save(update_fields=["room"])
table.room = locked.room
if new:
notify.publish(table.code)
Check-ins are written at most every 20 seconds, only the latest 40 are kept, and a new watcher is news (everyone’s count changes).
Sitting in at the next game#
Someone who arrives after the deal can hold a place under a name:
tables/service.py reserve · lines 259–280def reserve(code: str, name) -> tuple[Table, str]:
"""Hold a place at a table's next game for someone who arrived late.
They watch until then; the place is a seat when the host plays again
(or a bot's seat at the next round, in a game that allows it)."""
with transaction.atomic():
table = _locked(code)
rules = GAMES[table.game]
if table.status == Table.CLOSED:
raise Illegal("That table has closed.")
if table.solo:
raise Illegal("That's a game against bots. Start a table of your own.")
if table.status == Table.LOBBY and table.seats.count() < rules.max_players:
raise Illegal("There's a seat free: take it.")
name = clean_name(name)
waiting = _room(table).setdefault("waiting", [])
if table.seats.exclude(token="").count() + len(waiting) >= rules.max_players:
raise Illegal("There's no place left at the next game.")
secret = secrets.token_urlsafe(24)
waiting.append({"name": _unique(table, name, waiting=True), "token": digest(secret), "at": time.time()})
_bump(table)
notify.publish(code)
return table, secret
The place’s secret is made and hashed like a seat’s, and the seat it becomes takes that hash as its token, so the reserving browser finds itself seated. That happens when the host plays again (a free seat, else the seat of someone who left, else one a bot has to itself), or, in a game with round_no, when a new round starts:
tables/service.py _sit_in_at_new_round · lines 1085–1096def _sit_in_at_new_round(table: Table, rules) -> None:
"""A game with `round_no` lets someone waiting to sit in take a bot's
seat as a new round starts (the bot's score goes with it)."""
round_no = getattr(rules, "round_no", None)
if round_no is None or rules.over(table.state):
return
room = _room(table)
now = round_no(table.state)
if room.get("waiting") and room.get("round") is not None and now != room["round"]:
for seat in _seat_waiting(table, bots_only=True):
_note(table, seat, "sit_in")
room["round"] = now
Taking a bot’s seat takes its score (_hand_over): index and points stay; name, token, bot level and account change. Seats of someone who left come first (a bot playing them or, in a game without bots, not), so a person’s place goes back to a person. Places are capped at the game’s most seats minus the people seated.
Your seat on another device#
A signed-in player whose seat is linked to their account can take it in another browser. take gives the seat a new secret and remembers the old hash as moved (the last 24), so the old browser is told its seat moved and watches from then on. If a bot was playing the seat, or they’d left a game without bots, it’s theirs again.
What’s kept beside the game: Table.room#
Table.room is the service’s own notes on a table, a JSON object apart from the game’s state (null until there’s something to keep):
| Key | What it holds |
|---|---|
watching |
Each spectator’s hashed id and when it last checked in. |
waiting |
Places at the next game: a name, a token hash, when it was held. |
left |
Seats whose players left this game (a bot plays for them, in a game with bots). |
handed |
Who had a seat before someone sat in, so the table can be told. |
moved |
Hashes of secrets whose seat was taken on another device. |
arranged, teams, team_names |
The host’s own seating, teams and team names. |
round |
The last round seen, to spot a new one. |
carry |
What the last game left for the next one, across a rematch. |
tallied |
How many crowd answers this table has counted today. |
talk |
What’s been said (below): a line counter, the lines, and each seat’s recent send times for the rate. |
The snapshot#
A browser receives a snapshot: the table as its seat sees it.
tables/service.py snapshot · lines 1142–1203def snapshot(table: Table, seat: Seat | None, secret=None) -> dict:
"""The table as this seat sees it; None is a spectator, who sees only
what's public. `secret`, for a browser without a seat: whether it's
holding a place at the next game."""
rules = GAMES[table.game]
now = time.time()
seats = list(table.seats.all())
# Who's holding up the table: a decision they've sat on for a minute.
waiting = _holders(table, now)
shown = {s.index: _presence(s, now, waiting) for s in seats}
left = set((table.room or {}).get("left", []))
sides = _sides(table) if table.status in (Table.LOBBY,) else None
return {
"table": {
"code": table.code, "game": table.game, "status": table.status, "solo": table.solo, "version": table.version,
"game_no": table.games_played, "ended": bool(table.state and table.state.get("ended")),
"min": rules.min_players, "max": rules.max_players, "options": table.options,
# Whether bots can sit here at all (engine.has_bots; docs/adr/0031).
"bots": has_bots(rules),
# What a game in teams calls them (the host may rename them).
**({"team_names": team_names(table)} if getattr(rules, "teams", None) else {}),
# Whether the host has seated everyone themselves.
"arranged": bool((table.room or {}).get("arranged")),
"seats": [{
"index": s.index, "name": s.name, "bot": s.bot, "person": s.person, "host": s.host,
**({"team": sides[s.pk]} if sides else {}),
"online": shown[s.index][0], "holding": shown[s.index][1],
# Left the game (a bot plays for them), rather than just away;
# picks missed in a row (tables.commit).
"left": s.index in left, "missed": commit.missed(table.state, s.index),
# Whose seat this was, if it was handed to someone sitting in this game.
"took": (table.room or {}).get("handed", {}).get(str(s.index)),
} for s in seats],
"you": seat.index if seat else None,
"host": bool(seat and seat.host),
# Who'd host if the host left now (_heir), for the host's Leave.
**({"heir": getattr(_heir(table, seat, seats), "index", None)} if seat and seat.host else {}),
# Whether this seat's game goes in an account's history, and,
# once it has, the result's shareable page (docs/adr/0010).
"linked": bool(seat and seat.user_id),
"result": _result_url(table) if seat and table.status == Table.OVER else None,
# What the game did to this seat's rating, once kept: this seat's
# own, so only its view says it (docs/ratings.md).
"rating": _rating(table, seat) if seat and seat.user_id and table.status == Table.OVER else None,
# How it came out, once it's over (public then), in the game's
# own words (engine.terms): for the room's share.
"standings": _standings(table, rules),
"terms": terms(rules),
# Browsers without a seat (a count, no more), and who's waiting
# to sit in at the next game; `reserved`: this browser is.
"watching": watching(table, now),
"waiting": [w["name"] for w in (table.room or {}).get("waiting", [])],
"reserved": bool(not seat and waiting_for(table, secret)),
# The secret sent holds nothing here (any more): a place given
# up, or a seat removed, so the browser can forget it.
"lost": bool(secret and not seat and not waiting_for(table, secret)),
# ... because it was taken on another device (the player's account).
"moved": bool(not seat and moved(table, secret)),
},
"game": rules.view(table.state, seat.index if seat else None) if table.state else None,
"now": now,
}
Who’s seated, online, holding things up, gone, watching or waiting is public to anyone at the table; the game is view(state, seat). A few fields are this browser’s alone: you, host (and, for the host, heir), linked, rating, reserved, and lost and moved, which say its secret no longer holds a seat. now is the server’s clock, which the browser corrects its own by, so reveals and timers play in step everywhere.
The models#
tables/models.py Table · lines 31–57class Table(models.Model):
# CLOSED: the host closed it before dealing; nothing more happens there.
LOBBY, PLAYING, OVER, CLOSED = "lobby", "playing", "over", "closed"
code = models.CharField(max_length=CODE_LENGTH, unique=True)
game = models.CharField(max_length=40)
status = models.CharField(max_length=10, default=LOBBY)
solo = models.BooleanField(default=False, help_text="One person against bots.")
options = models.JSONField(default=dict, blank=True, help_text="The table's settings, like how long buying stays open.")
state = models.JSONField(null=True, blank=True)
version = models.PositiveIntegerField(default=0, help_text="Goes up with every change, so browsers know when to redraw.")
wake_at = models.FloatField(null=True, blank=True, help_text="When a timer or bot is next due (Unix time).")
games_played = models.PositiveIntegerField(default=0)
# What the service keeps about the room beyond the game (tables.service):
# who's watching (counted, never named), who's waiting to sit in at the
# next game, seats moved to another device, the host's own seating. Null
# until there's something to keep, so code from before it can still
# make tables while a deploy rolls out.
room = models.JSONField(null=True, blank=True, help_text="Spectators, people waiting to sit in, moved seats.")
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True, db_index=True)
class Meta:
ordering = ["-updated"]
def __str__(self):
return f"{self.game} {self.code} ({self.status})"
A table runs lobby → playing → over, back to lobby on a rematch; closed is a waiting room the host closed. games_played tells one game at the table from the next in the log and history.
A Seat is a person (a token), a bot (a level, no token) or a person a bot is covering (both). Constraints allow one seat per index and one per account at a table. seen is the browser’s last check-in. user is set only by linking, never by joining.
Entry is one log line, unique on table, game number and event id; written with ignore_conflicts, a line is never kept twice.
The admin lists tables and seats but leaves out state entirely: everyone’s hands aren’t for browsing, even by staff.
The API#
The service is mounted under each table game:
tables/urls.py urlpatterns · lines 7–22urlpatterns = [
path("", views.create, name="create"),
path("house-rules/", views.house_rules, name="house_rules"),
path("<str:code>/", views.state, name="state"),
path("<str:code>/join/", views.join, name="join"),
path("<str:code>/reserve/", views.reserve, name="reserve"),
path("<str:code>/act/", views.act, name="act"),
path("<str:code>/arrange/", views.arrange, name="arrange"),
path("<str:code>/log/", views.log, name="log"),
path("<str:code>/host/", views.host, name="host"),
path("<str:code>/link/", views.link, name="link"),
path("<str:code>/take/", views.take, name="take"),
path("<str:code>/say/", views.say, name="say"),
path("<str:code>/talk/", views.talk, name="talk"),
path("<str:code>/stream/", views.stream, name="stream"),
]
A code is upper-cased on the way in, so a typed abc234 finds ABC234.
Under /games/<slug>/tables/ |
Method | Who | What |
|---|---|---|---|
| (itself) | POST | anyone | Make a table: {name, solo, bots, options}. Returns the code, seat, secret and a snapshot. |
house-rules/ |
GET, POST | signed in | The player’s saved settings for this game. |
<code>/ |
GET | anyone | A snapshot, for polling. Checks in or counts a watcher, and ticks if due. |
<code>/join/ |
POST | anyone | Take a free seat: {name}. Returns a secret. |
<code>/reserve/ |
POST | anyone | Hold a place at the next game: {name}. Returns a secret. |
<code>/act/ |
POST | X-Seat | A move. Returns the new snapshot. |
<code>/host/ |
POST | X-Seat | {op, …}: the host’s controls and the few anyone can use. |
<code>/log/ |
GET | X-Seat, optional | The current game’s whole log as this seat may see it. |
<code>/arrange/ |
GET | X-Seat | A game’s “arrange it for me”, with seq. |
<code>/link/ |
POST | X-Seat + session | Keep this seat’s games in the signed-in player’s history. |
<code>/take/ |
GET, POST | session | Which seat here is the account’s; move it to this browser. |
<code>/say/ |
POST | X-Seat | Say something to the table: {emote} or {chat}. Too fast is a 429. |
<code>/talk/ |
GET | anyone | What’s been said after ?after=n, for a browser that’s polling. |
<code>/stream/ |
GET | X-Seat or X-Watch | The live stream; ?talk=n, the last line of talk this browser has heard. |
X-Seat, and no cookies#
tables/views.py _secret · lines 54–55def _secret(request):
return request.headers.get("X-Seat", "")
The seat’s secret travels in an X-Seat header, the API’s only credential. A browser never attaches a custom header to another site’s request, so these calls need no CSRF protection (csrf_exempt), and the client sends them with credentials: 'omit', so no cookie goes along.
Only link (ADR 0010), take (ADR 0018) and house-rules (ADR 0021) read the session. Each needs a signed-in player, so each is csrf_protected like any form, and link needs the seat’s secret too. Creating or joining never looks at who’s signed in, so playing by link works the same with or without an account.
An Illegal becomes a 400 with {"error": "…"}, the rules’ own words. The host route passes only the arguments it knows (level, seat, options, order, action, teams, names) and answers a malformed request with “That request didn’t make sense.” instead of crashing.
Live updates#
The stream#
The site runs as ASGI (uvicorn workers under gunicorn) so every browser can hold a stream open without tying up a worker. The stream is server-sent events (SSE), read with fetch() because EventSource can’t send X-Seat.
tables/views.py stream · lines 323–383async def stream(request, slug, code):
"""Server-sent events: the table's snapshot each time it changes. A
fetch() stream, not EventSource, so it can carry the X-Seat header."""
_game(slug)
code = str(code).upper()
secret = _secret(request)
watch = _watch(request)
# What's been said that this browser has heard (?talk=n): none given,
# it hears from now on.
heard = _after(request)
# Listen before the first look, so a change in between isn't missed.
queue = notify.subscribe(code)
try:
first = await _look(slug, code, secret, True, watch=watch, heard=heard)
except BaseException:
notify.unsubscribe(code, queue)
raise
if first[0] is None:
notify.unsubscribe(code, queue)
raise Http404("No such table")
async def events():
try:
shown, snap, wake, said = first
yield f"data: {json.dumps(snap)}\n\n"
heard = said["n"]
if said["said"]:
yield f"event: talk\ndata: {json.dumps(said)}\n\n"
started = last_check_in = time.time()
while time.time() - started < STREAM_LIFE:
wait = KEEPALIVE if wake is None else max(0.05, min(KEEPALIVE, wake - time.time() + 0.02))
try:
await asyncio.wait_for(queue.get(), wait)
except TimeoutError:
pass
check_in = time.time() - last_check_in > KEEPALIVE
if check_in:
last_check_in = time.time()
try:
seen, snap, wake, said = await _look(slug, code, secret, check_in, known=shown, watch=watch, heard=heard)
except DatabaseError:
logger.exception("Couldn't read table %s; trying again", code)
await asyncio.sleep(1)
continue
if seen is None:
return
if said["said"]:
yield f"event: talk\ndata: {json.dumps(said)}\n\n"
heard = said["n"]
if seen != shown:
shown = seen
yield f"data: {json.dumps(snap)}\n\n"
elif not said["said"]:
yield ": still here\n\n"
finally:
notify.unsubscribe(code, queue)
response = StreamingHttpResponse(events(), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
It subscribes before its first look, so no change slips in between, then waits for a notification, 10 seconds or wake_at, whichever comes first, and looks again:
tables/views.py _look · lines 288–320@sync_to_async
def _look(slug, code, secret, check_in, known=None, watch="", heard=None):
"""Check in, tick if due, then this seat's snapshot and when to look again.
Returns (what's shown, snapshot, wake time, talk). "What's shown" is
service.presence: the table's version plus who's at the table and who's
holding it up, so someone leaving, coming back or going quiet is news
even though nothing in the game moved. When it's still `known`, there's
nothing to tell, and the table isn't drawn (snapshot None): a keepalive
on a quiet table costs a couple of queries, not a view. "Talk" is
what's been said since line `heard` (tables.talk), told apart from the
table, so an emote never redraws it."""
try:
table = Table.objects.filter(game=slug, code=code).first()
if not table:
return None, None, None, None
# Check in first: arriving mustn't count as having been away.
seat = service.seat_for(table, secret)
if seat and check_in:
service.check_in(seat)
elif not seat and check_in:
service.watch(table, service.watch_key(table, secret, watch))
if table.wake_at and table.wake_at <= time.time() and service.tick(code):
table.refresh_from_db()
shown = service.presence(table)
said = talking.since(table, heard)
if known is not None and shown == known:
return shown, None, table.wake_at, said
return shown, service.snapshot(table, seat, secret), table.wake_at, said
finally:
# A stream lasts minutes; holding a connection that long for every
# open tab would use up a small database's allowance.
connection.close()
Each look checks the seat in (at most once per keepalive), ticks the table if due, gathers what’s been said since the stream’s cursor (talk, sent as an event: talk of its own, never a redrawn table), and computes presence: the version, each seat’s online and holding-up flags, and the watcher count. Only a changed presence draws a snapshot, the expensive part; presence counts because someone going quiet should gray out on every roster. Otherwise it sends : still here, an SSE comment that keeps proxies from closing an idle connection (Heroku’s limit is 55 seconds).
A stream ends after 900 seconds and the browser reconnects, which keeps a dyno’s connections turning over. Each look closes its database connection, so open tabs don’t use up a small database’s connection limit.
Telling every stream#
tables/notify.py publish · lines 51–59def publish(code: str) -> None:
"""Call after the change is committed."""
_deliver(code)
if connection.vendor == "postgresql":
try:
with connection.cursor() as cursor:
cursor.execute("SELECT pg_notify(%s, %s)", [CHANNEL, code])
except Exception:
log.exception("Couldn't announce a change to table %s", code)
publish runs after the transaction commits. It wakes this process’s streams directly through their asyncio queues (with call_soon_threadsafe, as the service runs in a worker thread), and on Postgres sends pg_notify on one channel with the table’s code.
tables/notify.py _listen_forever · lines 71–91def _listen_forever() -> None:
import time
import psycopg
keys = ("dbname", "user", "password", "host", "port", "sslmode")
params = {k: v for k, v in connection.get_connection_params().items() if k in keys and v}
# TCP keepalives, and a query every half minute, so a connection that's
# silently gone is noticed and replaced rather than listened to forever.
params.update(keepalives=1, keepalives_idle=30, keepalives_interval=10, keepalives_count=3)
while True:
try:
with psycopg.connect(**params, autocommit=True) as conn:
conn.execute(f"LISTEN {CHANNEL}")
while True:
for note in conn.notifies(timeout=30):
_deliver(note.payload)
conn.execute("SELECT 1")
except Exception:
log.exception("Table notifications dropped; reconnecting")
time.sleep(2)
Each process holds one LISTEN connection on a daemon thread, started by its first stream. TCP keepalives and a query every half minute catch a connection that died silently. One connection per process, not per player, lets a small Postgres plan carry many tables. A process hears its own notifications too, so local streams wake twice; the second look finds nothing and costs a couple of queries.
SQLite has no LISTEN, so locally streams are live only within one process: run one. The tests set CARDS_LISTEN = False, since a listening connection would keep the test database from being dropped.
When a stream won’t stay open#
The browser falls back to polling GET <code>/ when a stream is slow or keeps failing, retries the stream now and then, and reopens one that’s gone quiet, as a sleeping phone’s can (the room kit’s games/static/games/room/kit/net.js, in the front end). A poll gets the same snapshot, check-in and tick, so a table behaves the same, just less promptly.
Talk at a table#
Emotes at every table, and chat when the host turns it on (ADR 0052), are tables/talk.py. Neither is part of the game: a seat says something with POST say/, and the service keeps it in Table.room["talk"], never in the state, the log, the version or history.
tables/talk.py say · lines 69–116def say(code: str, seat, kind: str, what) -> dict:
"""Seat `seat` says something: kind "emote" (one of EMOTES) or "chat"
(a line, cleaned). Returns what was said, as browsers get it."""
if seat is None or not seat.person:
raise Illegal("Take a seat to say something.")
if kind == "emote":
if not isinstance(what, str) or what not in EMOTES:
raise Illegal("That isn't one of the emotes.")
text = what
elif kind == "chat":
# (Only words: anything else would be shown as Python wrote it.)
if not isinstance(what, str):
raise Illegal("Type something to send.")
text = clean(what, CHAT_LEN)
if not text:
raise Illegal("Type something to send.")
else:
raise Illegal("That request didn't make sense.")
now = time.time()
with transaction.atomic():
table = Table.objects.select_for_update().filter(pk=seat.table_id).first()
if table is None:
raise Illegal("There’s no table with that code.")
if table.status == Table.CLOSED:
raise Illegal("That table has closed.")
if seat.index in (table.room or {}).get("left", []):
# Gone from the game (they pressed Leave): gone from its talk too.
raise Illegal("You've left this game.")
# (options() never turns it on in a game that doesn't offer it.)
if kind == "chat" and not (table.options or {}).get("chat"):
raise Illegal("Chat is off at this table.")
talk = _talk(table)
by = talk["by"].setdefault(f"{kind[0]}{seat.index}", [])
by[:] = _within(by, SEAT_EMOTES if kind == "emote" else SEAT_CHAT, now)
if len(by) >= (SEAT_EMOTES if kind == "emote" else SEAT_CHAT)[0]:
raise TooFast("Slow down a moment.")
if kind == "emote":
recent = [s for s in talk["said"] if s["kind"] == "emote" and now - s["at"] < TABLE_EMOTES[1]]
if len(recent) >= TABLE_EMOTES[0]:
raise TooFast("Slow down a moment.")
by.append(now)
talk["n"] += 1
line = {"n": talk["n"], "at": now, "kind": kind, "seat": seat.index, "name": seat.name, "text": text}
talk["said"].append(line)
_prune(talk, now)
table.save(update_fields=["room", "updated"])
notify.publish(table.code)
return line
Only a seat with a person may speak, and not once they’ve pressed Leave (“You’ve left this game.”): a spectator (anyone with the link) has no name or seat mark to send with, and sees the talk all the same. What’s said must be a string (anything else is refused rather than shown as Python would print it). An emote must be one of the tray’s eight (EMOTES, which the browser’s tray repeats; a test holds the two together); a line of chat is cleaned like a name and cut to 200 characters, and is refused while chat is off. A seat may send 6 emotes in any 10 seconds and 5 lines of chat in 10, and a whole table 20 emotes in 10; past that, TooFast, which the view answers with a 429 (“Slow down a moment.”).
The table keeps its last 100 lines of chat, and emotes for 10 seconds, long enough for a browser that’s reconnecting. Saying something announces the table like any change (notify.publish) but leaves the version alone, so each stream’s look finds the same presence and sends only the new lines:
tables/talk.py since · lines 129–142def since(table: Table, after: int | None, now: float | None = None) -> dict:
"""What's been said after line `after`, for a browser: {"n": the latest
line's number, "said": [...]}. Emotes only while FRESH (one arriving
late shouldn't float up out of nowhere); chat only while it's on. None
for `after`: nothing, just where the talk has got to."""
now = time.time() if now is None else now
talk = (table.room or {}).get("talk") or {}
n = talk.get("n", 0)
if after is None or after >= n:
return {"n": n, "said": []}
chat = bool((table.options or {}).get("chat"))
said = [{k: s[k] for k in ("n", "at", "kind", "seat", "name", "text")} for s in talk.get("said", [])
if s["n"] > after and (now - s["at"] < FRESH if s["kind"] == "emote" else chat)]
return {"n": n, "said": said}
A browser opens its stream from line 0, so the chat so far comes with it; an emote older than 6 seconds isn’t sent. The talk goes with the table: when the host turns chat off it’s dropped at once (chat_off), and a table deleted for idleness takes it all.
Whether chat is on is a table setting the service owns, merged with the game’s own (so the game’s options() needn’t know of it), and said only when it’s on, so a table’s settings and a host’s house rules are as they were:
tables/service.py _options · lines 93–96def _options(rules, raw, solo: bool = False) -> dict:
"""A table's settings: the game's own, cleaned by its rules, and the
room's (whether chat is on: tables.talk)."""
return {**rules.options(raw), **talk.options(rules, raw, solo)}
tables/talk.py options · lines 49–55def options(rules, raw, solo: bool = False) -> dict:
"""The table setting this module owns: `chat`, off unless the host turns
it on, and never at a solo table or in a game that doesn't offer it.
Merged into the game's own settings by the service; said only when on,
so a table's settings (and a player's house rules) are as they were."""
on = isinstance(raw, dict) and raw.get("chat") is True and not solo and offers_chat(rules)
return {"chat": True} if on else {}
The browser side, the smiley, the tray and the Chat tab, is the room kit’s kit/talk.js (the front end).
Secrecy#
The state never leaves the server, and each seat sees only view(state, seat). Three mechanisms make that hold.
to#
tables/sight.py sees · lines 11–13def sees(item: dict, seat: int | None) -> bool:
to = item.get("to")
return not isinstance(to, list) or (seat is not None and seat in to)
Anything in a game’s state, a log line or a beat can carry "to": [seats]: only those seats see it, never a spectator. Only a list counts (Contract Rummy’s joker-swap line names a meld as "to", which is just data). Views, snapshots and the log endpoint all filter through sight, so a thing marked private once stays private everywhere:
tables/service.py log · lines 1240–1244def log(table: Table, seat: Seat | None = None) -> dict:
"""The current game's whole log as this seat may see it, for a browser
that's just arrived: a line addressed to other seats is left out."""
entries = table.entries.filter(game=table.games_played).values_list("data", flat=True)
return {"game_no": table.games_played, "entries": sight.visible(entries, seat.index if seat else None)}
Bots see what their seat sees#
A bot’s bot_action reads view(state, seat), not the state. ADR 0008 promised bots “can’t peek”, and the contract tests check it.
The contract tests#
tests/table_contract.py is a mixin every table game’s tests use. A game supplies scramble(state, keep, rng), which changes in place every secret seat keep has no right to: other hands, the stock, other seats’ sealed picks, lines and beats for other seats. Odd One Out’s:
tests/test_table_service.py OddOneOutContract.def scramble(self, state, keep, rng):
c = state.get("commit")
if c:
for s in list(c["picks"]):
if s != str(keep):
c["picks"][s] = rng.choice(odd_one_out.SIDES)
for e in state["events"]:
if isinstance(e.get("to"), list) and keep not in e["to"]:
e["side"] = rng.choice(odd_one_out.SIDES)
for beat in (state.get("reveal") or {}).get("beats", []):
if isinstance(beat.get("to"), list) and keep not in beat["to"]:
beat["say"] = "Something else entirely."
Bots play whole games at the fewest and most seats, one seat moving a couple of seconds behind so others have made secret moves it hasn’t. Every few steps the tests scramble the state for each seat and require the seat’s view, and every bot level’s move from it, to come out exactly the same.
tests/table_contract.py TableGameContract.def test_a_seat_sees_nothing_that_depends_on_another_seats_secrets(self):
rng = random.Random(7)
def look(state):
for seat in range(state["n"]):
other = copy.deepcopy(state)
self.scramble(other, seat, rng)
self.assertEqual(self.game.view(other, seat), self.game.view(state, seat), f"seat {seat} saw a secret")
for n in self.seats():
self.play(n, f"leak-{n}", steps=self.steps, look=look)
tests/table_contract.py TableGameContract.def test_a_bot_decides_only_from_what_its_seat_can_see(self):
rng = random.Random(11)
g = self.game
def look(state):
for seat, kind, _ in g.deciding(state):
for level in self.levels:
other = copy.deepcopy(state)
self.scramble(other, seat, rng)
mine = g.bot_action(copy.deepcopy(state), seat, level, random.Random(3))
blind = g.bot_action(other, seat, level, random.Random(3))
self.assertEqual(mine, blind, f"a {level} bot at seat {seat} used a secret deciding {kind}")
for n in self.seats():
self.play(n, f"bots-{n}", steps=self.steps, look=look)
The mixin also checks that bots of every level finish, a seed replays the same game, every view is plain JSON (no Infinity, which Postgres refuses), no line for other seats reaches a view, result scores every seat every round, and check names each missing part. Testing has how they run.
The party-game helpers#
Most party games are built from the same five modules, all used by Odd One Out.
Sealed picks: commit#
tables/commit.py keeps the pick in play in state["commit"]: id, kind, seats asked, their picks, when it opens and closes, whether it’s live. Who has picked is public; what they picked isn’t, until the game takes the picks (ADR 0017). A game calls open to ask, wake from its tick, put from a move and take once due; deciding, deadline and holds_up answer the engine while a pick is open.
tables/commit.py take · lines 85–100def take(state: dict, fill) -> tuple[dict[int, object], list[int]]:
"""End the pick: everyone's picks by seat, with `fill(seat)` for anyone
who didn't pick, and who those were."""
c = state.pop("commit")
filled = [s for s in c["seats"] if str(s) not in c["picks"]]
picks = {s: c["picks"][str(s)] if str(s) in c["picks"] else fill(s) for s in c["seats"]}
# Picks missed in a row, by seat (public: who timed out is): the host
# is offered a bot for someone who keeps missing them.
missed = state.setdefault("_missed", {})
for s in c["seats"]:
if s in filled or s in c.get("auto", []):
missed[str(s)] = missed.get(str(s), 0) + 1
else:
missed.pop(str(s), None)
_bump(state)
return picks, filled
take also counts each seat’s run of missed picks (timed out, or made while away). The count is public, and the room offers the host a bot for someone who keeps missing.
Timed reveals: reveal#
tables/reveal.py play · lines 21–32def play(state: dict, beats: list[dict], now: float) -> float:
"""Start a reveal of these beats now. Returns when it ends (Unix time)."""
at, timed = 0, []
for beat in beats:
if not beat.get("say"):
raise ValueError(f"A beat needs a line to say: {beat!r}")
timed.append({**beat, "at": at})
at += int(beat["ms"])
state["_reveals"] = state.get("_reveals", 0) + 1
state["reveal"] = {"id": state["_reveals"], "starts": now, "ends": now + at / 1000, "beats": timed}
state["seq"] = state.get("seq", 0) + 1
return state["reveal"]["ends"]
A reveal is a script of beats, each with a cue (what the browser plays), a length, a line for screen readers (required) and optionally to. starts and each beat’s at are on the server’s clock, so every browser plays a beat at the same moment, and a late arrival skips finished beats to how they end.
Rounds and scores: rounds#
tables/rounds.py keeps a score row per round and running totals, ends a round ("scored") or the game ("over"), collects ready, and builds the engine’s result with places:
tables/rounds.py places · lines 45–51def places(totals, high: bool = True, tiebreak=None) -> list[int]:
"""1 for first. Seats level on total (and on `tiebreak`, a second number
per seat that goes the same way) share a place, and the next place is
skipped: 1, 1, 3."""
keys = [(t, tiebreak[s] if tiebreak else 0) for s, t in enumerate(totals)]
better = (lambda a, b: a > b) if high else (lambda a, b: a < b)
return [1 + sum(better(other, mine) for other in keys) for mine in keys]
Log lines: log#
tables/log.py event · lines 12–21def event(state: dict, kind: str, to=None, **data) -> dict:
"""Log a line. `to`: the seats it's for, if it's private."""
state["logged"] = state.get("logged", 0) + 1
line = {"id": state["logged"], "type": kind, **data}
if to is not None:
line["to"] = sorted(set(to))
events = state.setdefault("events", [])
events.append(line)
del events[:-EVENTS_KEPT]
return line
Each line gets the next id; a private one, a sorted to. The state keeps the last 400 and a view shows the last 40; Entry rows keep the whole game. log.note is a ready-made engine note: a seat changing hands, as a log line.
The rest#
moves:dispatchandchoice(above), and the bot paces.text:clean, for anything people type.content: a content bank, a TOML file of[[q]]tables (or another table name) with stable ids, read with the standard library and cached per process (ADR 0019). Ids are never renamed once shipped, since counts about an item are kept under its id.Bank.pickdeals a seeded selection and can keep near-twins apart. Fictionary’s word bank is one.deck: playing cards as short strings shared with the browser,"QH0"(queen of hearts from deck 0) and"JK1", and a shuffle that’s the same for the same seed, so a game replays from its seed and moves.
tables/content.py Bank.def pick(self, rng: random.Random, n: int, exclude=(), apart=None) -> list[dict]:
"""`n` items at random, none of them in `exclude` (ids). `apart(a, b)`,
if given, says two items mustn't be dealt together (near-twins): an
item is passed over if it's apart from one already picked. Raises
ValueError if the bank can't supply that many."""
skip = set(exclude)
pool = [item for item in self.items if item["id"] not in skip]
rng.shuffle(pool)
picked = []
for item in pool:
if len(picked) == n:
break
if apart and any(apart(item, other) for other in picked):
continue
picked.append(item)
if len(picked) < n:
raise ValueError(f"The bank has only {len(picked)} items to pick from, not {n}.")
return picked
House rules#
A player’s house rules are their default table settings for a game (ADR 0021). Signed in, they’re kept in User.house_rules, a JSON object keyed by game slug; a guest’s stay in their browser.
tables/service.py house_rules · lines 105–109def house_rules(user, game: str) -> dict | None:
"""This player's saved settings for the game, cleaned again by its rules
(so a setting the game has since dropped goes), or None if they have none."""
saved = (user.house_rules or {}).get(game)
return _options(GAMES[game], saved) if isinstance(saved, dict) else None
The service stores and cleans them but never applies them, since creating a table doesn’t look at who’s signed in. The page fetches them (house-rules/) and sends them as the new table’s options, which options() cleans as usual. They’re cleaned on the way in and out, so a setting a game drops just disappears, and any game with settings gets house rules for free. Saving locks the user’s row, so two games’ rules saved at once can’t overwrite each other.
Crowd tallies at the IO edge#
Some games want the world’s answers: Called It, the game crowd/ was built for, scores an answer double if fewer than a third of people give it (“Only 22% pick the duck”). The service is the rules’ IO edge (ADR 0019). Before the deal it passes prepare(seed, settings)’s result to new as settings["_data"]; after any change it writes what the game left in state["_out"], in the state’s transaction:
tables/service.py _spill · lines 437–450def _spill(game: str, out: list) -> None:
"""Write a game's `_out` records. {"sink": "crowd", "topic", "choice",
"seed"?}: one anonymous answer to add to its question's tally."""
tallies, seeds = {}, {}
for record in out:
if not isinstance(record, dict) or record.get("sink") != "crowd":
logger.warning("A %s table left a record the service can't write: %r", game, record)
continue
topic, choice = str(record["topic"]), str(record["choice"])
tallies.setdefault(topic, Counter())[choice] += 1
if "seed" in record:
seeds.setdefault(topic, {})[choice] = float(record["seed"])
for topic, counts in tallies.items():
crowd.add(game, topic, counts, seeds.get(topic))
The one sink today is the crowd: an anonymous answer added to a question’s tally. A table counts at most 2000 answers a (site) day, so one busy table can’t swamp a question (_take_out keeps the count in Table.room).
crowd/__init__.py blend · lines 46–54def blend(seed: dict, live: dict, prior: float = PRIOR) -> dict:
"""The share for each choice: the seed counted as `prior` answers, then
the live ones. Pure."""
choices = sorted(set(seed) | set(live))
total = sum(live.get(c, 0) for c in choices)
weight = prior + total
if not choices or weight <= 0:
return {}
return {c: (prior * seed.get(c, 0.0) + live.get(c, 0)) / weight for c in choices}
A share blends the designer’s seed with the counts, as if the seed were 20 answers already in: a new question starts at the designer’s guess and drifts to the truth. A tally is a count per answer, never who, which table or when (beyond its last update), per the site’s anonymous-counts rule. No game uses prepare or _out yet: Called It isn’t registered.
A smaller edge is _carry: a little JSON a game leaves for the next game at its table (Fictionary remembers words it has used), passed to the next deal as settings["_before"].
History#
A finished game is kept, as a Result with a ResultSeat per seat, if the rules say it’s over, the host didn’t end it early, the game counts, and at least one seat was linked to an account (ADR 0010):
tables/history.py record · lines 27–49def record(table: Table) -> Result | None:
"""Keep the table's game if the rules say it's over and someone at it is
signed in. Called with the table's row locked, so it's kept once; calling
it again returns what was kept."""
rules = GAMES[table.game]
summarize = getattr(rules, "result", None)
state = table.state
if summarize is None or not state or state.get("ended") or not rules.over(state):
return None
# (A game may end by its rules and still not count: Bowl ended at once.)
if not getattr(rules, "counts", lambda state: True)(state):
return None
# In a savepoint: a failure here is logged and the game ends anyway,
# rather than its last move being refused.
try:
with transaction.atomic():
return _keep(table, summarize)
except IntegrityError:
# Someone else kept it first (the row lock makes this unlikely).
return saved(table)
except DatabaseError:
log.exception("Couldn't keep the game at table %s", table.code)
return None
It’s written once, under the row lock and backed by a unique constraint on table and game number, in a savepoint: a failure to keep it is logged and never refuses the move that ended the game. A Result outlives its table (on_delete=SET_NULL) and keeps the options and each seat’s kind, bot level, covered flag, name, scores by round, total and place. Keeping a game also rates it (ratings).
Someone who signs in after their game, while the table still exists, links their seat, and history.attach keeps the game or adds them to it.
A kept game’s page is /games/<slug>/history/<key>/, the key 12 random letters and digits (about 71 bits): the link is its only lock. It shows names (an account’s current display name, or a guest’s typed name, marked), never emails. A signed-in player’s /games/<slug>/history/ sums up their games: played, wins, average total, and their record against each signed-in player and bot level. Both pages (games/templates/tables/history.html, games/templates/tables/result.html) use the game’s look and terms.
tables/history.py account_summary · lines 147–159def account_summary(user, game) -> dict | None:
"""A card game on the hub's account page (docs/adr/0011): the history
page's totals (games, wins, average score), counted in one query."""
t = ResultSeat.objects.filter(user=user, result__game=game.slug).aggregate(
played=Count("id"), wins=Count("id", filter=Q(place=1)), average=Avg("total"))
if not t["played"]:
return None
played, wins = t["played"], t["wins"]
return {
"lines": [f"{played:,} game{'s' if played != 1 else ''} · {wins:,} win{'s' if wins != 1 else ''}",
f"Average score {round(t['average']):,}"],
"link": ("Your games", reverse("history:games", args=[game.slug])),
}
The account page shows each table game’s account_summary line, counted in one query (accounts). When an account is deleted, signals.forget_player turns its seats and results into “Former player” and deletes kept games no remaining signed-in player was in.
Cleanup#
Each time anyone creates a table, every table unchanged for 14 days is deleted with its seats and log (Entry cascades). Kept results stay. Check-ins, watching and quiet ticks don’t touch updated, so a tab left open on a finished game doesn’t keep its table alive.
Changing it#
- A new game touches its own package, one line in
tables/games.pyand one in the catalog, and needs no migration: everything it keeps lives in its JSON state. AGENTS.md’s “Adding a multiplayer game” is the checklist, Odd One Out the pattern. - A change to the contract (a new optional part, a new state convention) goes in
tables/engine.py’s docstrings, incheckif required, and in the contract tests if it can be checked for every game. - A change to the service changes every game: run the whole suite, including each game’s contract case.
- Anything private must carry
toor stay out ofview. The contract tests catch leaks only as well as each game’sscramble.