Board and party games
Bowl
A party game for two teams in one room, where everyone writes slips for a bowl and teammates race a clock to guess them from clues.
On this page
Bowl is the party game often called Celebrity or Fishbowl, played the way David’s family plays it. Four to twenty people split into two teams, in one room or on a call, each on their own phone. Everyone writes slips for a shared bowl, then players take turns drawing slips and giving clues while their team races the clock to guess.
The app does the job of the pen, paper, bowl and kitchen timer. It runs on the table service, which it stretched in four ways (ADR 0031): twenty seats, no bots (a bot can’t give or guess clues), a host who referees, and teams the service keeps.
At a glance#
| Part | Where |
|---|---|
| The rules: setup, turns, the clock, pause and the host’s calls, the view | tables/bowl/rules.py |
| The table: setup scenes, the bowl and the slip, the clock, the pause sheet, the result sheet | games/static/games/bowl/table.js |
| The bowl (one SVG) and the teams’ marks | games/static/games/bowl/bowlart.js, games/static/games/bowl/teams.js |
| Its entry into the room kit; How to play | games/static/games/bowl/app.js, games/static/games/bowl/rules.js |
| Its look | games/static/games/bowl/bowl.css (tokens, fonts), games/static/games/bowl/table.css (the table) |
| Its pages | games/templates/games/bowl.html, games/templates/bowl/page.html |
| Tests; the play check in headless Chrome | tests/test_bowl.py, tools/bowl/play.mjs |
| Design notes | docs/bowl.md |
The teams themselves live in tables/service.py (the table service).
The rules in code#
The game is a state machine (a fixed set of phases, each allowing certain moves): pick a theme, fill the bowl, then play timed turns.
Players, teams and settings#
min_players, max_players = 4, 20, bots = False and chat = False: it’s played in one room or on a call, so the host isn’t offered Chat, though emotes float as at every table (ADR 0052). When a player leaves, no bot takes their seat (the service never puts one in a game without bots, and refuses the host’s cover): the game hears note("leave") and steps the seat aside, so the table goes on. They can come back in, someone waiting to sit in can take the seat, and the next game deals without it. (A seat covered by a bot before this, at a table already playing, still steps aside the same way.)
The two teams are Red and Blue until the host renames them. A name is up to 16 characters, and a blank means the default. The two names can’t match, ignoring case.
The host sets each seat’s team in the waiting room, and the service keeps it per seat, so removing someone moves no one else. A joiner goes to the smaller team. If the teams end up two apart, the bigger team’s latest joiner moves over. The game gets the teams as _teams and checks them:
tables/bowl/rules.py _sides · lines 76–82def _sides(players: int, chosen) -> list[int]:
"""Each seat's team: the host's choice, if it's one (two teams, even or
one apart), else alternating seat by seat."""
if (isinstance(chosen, list) and len(chosen) == players and all(type(t) is int and 0 <= t < TEAMS for t in chosen)
and abs(chosen.count(0) - chosen.count(1)) <= 1):
return list(chosen)
return [team_of(s) for s in range(players)]
Anything but two teams even or one apart falls back to alternating seats (team_of). Within a team, people give clues in seat order.
tables/bowl/rules.py options · lines 62–68def options(raw: dict | None) -> dict:
"""A table's settings, from whatever the host asked for."""
return {
"mode": moves.choice(raw, "mode", MODES, DEFAULTS["mode"]),
"turn": moves.choice(raw, "turn", TURN_SECONDS, DEFAULTS["turn"]),
"rotate": moves.choice(raw, "rotate", ROTATIONS, DEFAULTS["rotate"]),
}
| Setting | Choices | Standard | Effect |
|---|---|---|---|
mode |
classic, progressive | classic | One round, or three rounds with the same slips |
turn |
60, 90, 120 | 60 | Seconds in a team’s turn |
rotate |
slip, turn | slip | The next teammate gives clues after each slip, or one person goes all turn |
The rotate standard is one line, ROTATE_DEFAULT = "slip", marked in the code as waiting for David to confirm.
The state#
One JSON object, saved whole:
tables/bowl/rules.py new · lines 105–123def new(players: int, seed: str, now: float, settings: dict | None = None) -> dict:
if not min_players <= players <= max_players:
raise Illegal(f"Bowl is for {min_players} to {max_players} players.")
rules = options(settings)
state = {
"game": slug, "n": players, "seed": seed, "seq": 0, "logged": 0, "events": [],
"mode": rules["mode"], "secs": rules["turn"], "rotate": rules["rotate"],
"rounds": 3 if rules["mode"] == "progressive" else 1,
"phase": "theme", "team": _sides(players, (settings or {}).get("_teams")),
"names": names_of((settings or {}).get("_team_names")),
"ideas": [], "runoff": False, "tally": None, "theme": None,
"slips": [], "enough": [], "bowl": [],
"round": 1, "up": 0, "next": [0] * TEAMS, "out": [], "gone": [],
"giver": None, "slip": None, "live": False, "starts": None, "ends": None, "paused": None,
"turn": 0, "oops": [], "turn_got": [], "got": [], "recap": None, "draws": 0, "finish": None,
}
log.event(state, "setup")
commit.open(state, now, kind="idea")
return state
phase runs theme → vote → slips → ready ⇄ turn → over. The other fields:
slips: every slip ever written ({id, text, by, gone}). A slip taken back stays, withgone: true, so ids never shift.bowl: the ids in play.got: the earned slips ({slip, team, round, turn, giver}).oops: the slips sent back this turn.next: the seat index each team picks up at.out: seats sitting out of clue-giving.gone: seats that left.pausedand the clock fields (starts,ends,live).
Setup: the theme#
Everyone suggests a theme in secret (up to 40 letters) or passes. commit records who has picked, not what. The pick closes when everyone has picked or the host presses Move on:
tables/bowl/rules.py _close_ideas · lines 202–232def _close_ideas(state: dict, now: float) -> None:
picks, _ = commit.take(state, lambda s: None)
ideas = []
for s in sorted(picks):
text = picks[s]
if not text:
continue
twin = next((i for i in ideas if same(i["text"]) == same(text)), None)
if twin:
twin["by"].append(s)
# (The same idea, written with a capital, reads better on the board.)
if text[:1].isupper() and not twin["text"][:1].isupper():
twin["text"] = text
else:
ideas.append({"id": len(ideas), "text": text, "by": [s]})
if not ideas:
# (Everyone skipped: no theme, the way the game began.)
ideas = [{"id": 0, "text": ANYTHING, "by": []}]
# Listed in an order of the bowl's own, not seat by seat: the vote is in
# secret, and ideas in seat order say who thought of which.
random.Random(f"{state['seed']}:ideas").shuffle(ideas)
for k, i in enumerate(ideas):
i["id"] = k
state["ideas"] = ideas
log.event(state, "ideas", count=len(ideas))
if len(ideas) == 1:
_choose(state, now, 0, {"votes": {}, "how": "only", "runoff": []})
return
state["phase"] = "vote"
state["tally"] = {"votes": {}, "how": None, "runoff": []}
commit.open(state, now, seats=_present(state), kind="vote")
same()treats ideas as equal ignoring case, spacing, quotes and a full stop, so “Movies!” and “movies” are one idea, keeping the capitalized spelling.- The ideas are shuffled with the game’s seed and renumbered, so the vote’s order doesn’t reveal who wrote what.
- One idea becomes the theme with no vote. No ideas means Anything goes.
- Otherwise everyone present votes, also in secret.
You can’t vote for your own idea, so it isn’t everyone for themselves. If every candidate is yours, any will do:
tables/bowl/rules.py vote · lines 180–191def vote(state, seat, action, now):
if state["phase"] != "vote":
raise Illegal("There's nothing to vote on right now.")
if action.get("pass"):
commit.put(state, seat, None)
return
choice = action.get("idea")
if type(choice) is not int or choice not in _candidates(state):
raise Illegal("Vote for one of the ideas.")
if choice in _own(state, seat) and any(c not in _own(state, seat) for c in _candidates(state)):
raise Illegal("Vote for someone else's idea.")
commit.put(state, seat, choice)
tables/bowl/rules.py _close_votes · lines 235–255def _close_votes(state: dict, now: float) -> None:
picks, _ = commit.take(state, lambda s: None)
candidates = _candidates(state)
votes = {str(c): 0 for c in candidates}
for choice in picks.values():
if choice is not None and str(choice) in votes:
votes[str(choice)] += 1
top = max(votes.values())
tied = [c for c in candidates if votes[str(c)] == top]
if len(tied) == 1:
_choose(state, now, tied[0], {"votes": votes, "how": "runoff" if state["runoff"] else "votes", "runoff": []})
elif not state["runoff"]:
# No clear winner: vote again between the tied ideas.
state["runoff"] = True
state["tally"] = {"votes": votes, "how": None, "runoff": tied}
log.event(state, "runoff", ideas=tied)
commit.open(state, now, seats=_present(state), kind="vote")
else:
# Still tied: the bowl decides.
pick = random.Random(f"{state['seed']}:theme").choice(tied)
_choose(state, now, pick, {"votes": votes, "how": "draw", "runoff": tied})
Most votes wins. A tie goes to a runoff between the tied ideas, and a tied runoff to a seeded random pick (random.Random(seed:theme).choice). If no one votes, every idea is “tied”, so it goes to a runoff and then the draw.
Setup: the bowl#
Everyone writes 5 slips of up to 60 letters. Only the writer sees a slip until it’s earned. Everyone sees how many each person has put in.
tables/bowl/rules.py slip · lines 270–288def slip(state, seat, action, now):
if state["phase"] != "slips":
raise Illegal("The bowl isn't open for slips right now.")
mine = _mine(state, seat)
if len(mine) >= SLIPS_EACH:
raise Illegal(f"You've put in all {SLIPS_EACH}.")
text = clean(action.get("text"), SLIP_LEN)
if not same(text):
raise Illegal("Write something on the slip.")
twin = next((s for s in state["slips"] if not s["gone"] and same(s["text"]) == same(text)), None)
if twin:
# Merged: the bowl keeps one. (Said gently, and only to its writer.)
raise Illegal("You already put that one in." if twin["by"] == seat
else "Great minds! Someone already put that one in. Try another.")
sid = len(state["slips"])
state["slips"].append({"id": sid, "text": text, "by": seat, "gone": False})
log.event(state, "slip_in", seat=seat, count=len(mine) + 1)
if _bowl_full(state):
_to_play(state)
- Duplicates are merged. A slip already in the bowl (by
same()) isn’t added, and the writer sees “Great minds! Someone already put that one in. Try another.” or “You already put that one in.” Only slips still in the bowl count, so you can re-add one you took back. unsliptakes one of yours back until the bowl closes.- Play starts once nobody owes slips (
_bowl_full). - The host alone can Start with what’s in (
close, needing at least one slip) or count a stuck player as done (enough, logged). That player keeps the slips they wrote.
Then _to_play fills the bowl, tosses a seeded coin for the first team, and readies the first turn.
A turn#
The team that’s up has a next clue-giver, skipping anyone sitting out or gone. ready waits for them, or the host, to press Start:
tables/bowl/rules.py start · lines 362–374def start(state, seat, action, now):
if state["phase"] != "ready":
raise Illegal("It isn't time to start a turn.")
if seat != state["giver"] and not action.get("host"):
raise Illegal("It's not your turn to give clues.")
if state["paused"]:
raise Illegal("The game's paused.")
state["phase"] = "turn"
state["turn"] += 1
state["starts"] = now + COUNTDOWN
state["ends"] = state["starts"] + state["secs"]
state["recap"] = None
log.event(state, "turn", no=state["turn"], team=state["up"], giver=state["giver"], round=state["round"])
The clock is the server’s. start sets starts = now + COUNTDOWN (3 s of “Ready, set, go”) and ends = starts + secs. The first tick after starts sets live and draws a slip, shown only on the clue-giver’s phone.
tables/bowl/rules.py _draw · lines 377–388def _draw(state: dict) -> bool:
"""A slip from the bowl for the clue-giver: any but those that went
back with an Oops! this turn. False if there's none."""
pool = sorted(sid for sid in state["bowl"] if sid not in state["oops"])
if not pool:
state["slip"] = None
return False
rng = random.Random(f"{state['seed']}:draw:{state['draws']}")
state["draws"] += 1
state["slip"] = rng.choice(pool)
_bump(state)
return True
The draw is seeded (seed:draw:n) and skips slips sent back with an Oops! this turn. There’s no passing.
tables/bowl/rules.py got · lines 403–417def got(state, seat, action, now):
_in_play(state, seat, action, "done")
sid = state["slip"]
team = state["up"]
state["bowl"].remove(sid)
state["got"].append({"slip": sid, "team": team, "round": state["round"], "turn": state["turn"], "giver": seat})
state["turn_got"].append(sid)
log.event(state, "got", slip=sid, text=_slip(state, sid)["text"], team=team, giver=seat)
if not state["bowl"]:
_end_turn(state, now, "empty")
return
if _late(state, now):
_end_turn(state, now, "time")
return
_after_slip(state, seat)
- Guessed! (
got): the team earns the slip. The turn ends if the bowl is empty ("empty") or time is out ("time"). - Oops! (
oops): the clue-giver said a word on the slip. It stays in the bowl but can’t be drawn again this turn. - Each tap carries the slip’s id (
action.slip) and is refused if that slip isn’t in play. This catches double taps and taps that crossed a new slip.
tables/bowl/rules.py _after_slip · lines 436–448def _after_slip(state: dict, seat: int) -> None:
"""The next slip: to the next clue-giver on the team, by the house rule.
With nothing left to draw the turn ends with `seat` still the clue-giver,
so the team picks up after them, not after a teammate who never had a
slip."""
if not _draw(state):
# Every slip left went back with an Oops! this turn.
state["giver"] = seat
_end_turn(state, None, "stuck")
return
if state["rotate"] == "slip":
_past(state, seat)
state["giver"] = _next_giver(state, state["up"])
With rotate = "slip", the next teammate takes over after each slip while the clock runs. With "turn", one person keeps going. If every slip left was sent back with an Oops!, the turn ends ("stuck"), and next time the team picks up after whoever said it.
Worked example. Six players split by seat: team 0 is seats 0, 2, 4 and team 1 is seats 1, 3, 5. Team 1 is up, starting with seat 1. After four Guessed!s, time runs out:
rotate |
Clue-giver, slip by slip | Holding the slip at time-up | Starts the next turn |
|---|---|---|---|
slip (standard) |
1 → 3 → 5 → 1 → 3 | seat 3 | seat 5 |
turn |
1 → 1 → 1 → 1 → 1 | seat 1 | seat 3 |
At time-up, the slip in play stays in the bowl and the other team goes, starting with its next clue-giver. The team that just played picks up after whoever held the slip.
The server clock and the grace window#
Every phone counts down to the server’s deadline, but the server ends the turn slightly later:
tables/bowl/rules.py tick · lines 679–697def tick(state: dict, now: float) -> bool:
changed = commit.wake(state, now)
phase = state["phase"]
if phase == "theme" and commit.due(state, now):
_close_ideas(state, now)
return True
if phase == "vote" and commit.due(state, now):
_close_votes(state, now)
return True
if phase == "turn" and not state["paused"]:
if not state["live"] and now >= state["starts"]:
state["live"] = True
if not _draw(state):
_end_turn(state, now, "stuck")
return True
if state["live"] and now >= state["ends"] + GRACE:
_end_turn(state, now, "time")
return True
return changed
tables/bowl/rules.py _late · lines 430–433def _late(state: dict, now: float) -> bool:
"""A tap that landed in the grace after 0:00 counts, but it's the last:
no one is handed another slip once the clock's out."""
return now >= state["ends"]
GRACE = 0.75 seconds. A Guessed! pressed at 0:00 is still crossing the network, and a cutoff at exactly ends would lose it. So the turn ends at ends + GRACE, and a tap in that gap counts, but is the last: _late says time is out, so no one gets another slip. deadline() returns starts during the countdown, then ends + GRACE. tick makes one transition per call, each at its own deadline(), which is how the service knows when to wake the table.
For a 60-second turn started at time 0, phones show “Ready, set, go” for 3 seconds, read 0:00 at 63, and the turn ends at 63.75 unless someone taps first.
The browser keeps its clock in step with the server’s:
games/static/games/bowl/table.js BowlTable.learnClock(snap) {
const seen = snap.now * 1000 - Date.now();
const at = Date.now();
// (Kept a couple of minutes, so a clock that's changed is learned again.)
if (this.offset === null || seen > this.offset || at - this.offsetAt > 120000) {
this.offset = seen;
this.offsetAt = at;
}
}
snap.now is the server’s time when it made the snapshot (the state it sends a phone). The offset is the largest server − local seen in the last two minutes, because network delay only makes a snapshot look older. The fastest arrival is the truest reading, so phones agree within a frame or two. left() rounds up, so 0:00 shows only when time is really gone.
Rounds#
A classic game is one round. It ends when the bowl is empty, and the team with more slips wins. Equal counts share first.
Progressive plays three rounds with the same slips, refilled each round:
- Talk: say anything but the words on the slip.
- Two words: only two words, neither on the slip.
- Charades: no words, no sounds.
tables/bowl/rules.py _end_round · lines 466–476def _end_round(state: dict) -> None:
done = state["round"]
state["recap"]["round_over"] = done
log.event(state, "round_over", round=done, scores=_round_scores(state, done))
if done >= state["rounds"]:
_finish(state, "empty")
return
# The same slips, back in the bowl; the other team starts (no time carried over).
state["round"] += 1
state["bowl"] = _in_bowl(state)
_ready(state)
When the bowl empties mid-turn, the turn ends with no time carried over, and the other team starts the next round. Totals run across rounds, with each round’s score shown too. rule(state) is the current round’s rule.
Pause, and the host’s calls#
The host can pause at any time during play (ready or turn), and so can the clue-giver. The clock stops on every screen:
tables/bowl/rules.py pause · lines 503–513def pause(state, seat, action, now):
_playing(state)
if not action.get("host") and seat != state["giver"]:
raise Illegal("The host or the clue-giver can pause.")
if state["paused"]:
return
left = None
if state["phase"] == "turn":
left = max(0.0, state["ends"] - now) if state["live"] else state["secs"]
state["paused"] = {"by": seat, "left": left, "live": state["live"]}
log.event(state, "pause", by=seat)
A pause saves the seconds left. resume sets ends = now + left, or restarts the countdown if it paused there. The host, the clue-giver or whoever paused can resume.
Host calls arrive as host('move', action). The service adds the host’s seat and "host": True to the game’s act, and refuses a player’s own move carrying that key.
| Call | Effect |
|---|---|
| Resume | The clock runs on from where it stopped. |
Restart this turn (restart; only in a turn) |
The slip goes back, the clock refills, and the same clue-giver presses Start. Slips earned this turn stay earned. |
Pass play to the other team (pass) |
Ends the turn as if time ran out (why: "pass"). Before a turn starts, that team just doesn’t go. |
Undo the last Guessed! (undo) |
For disputes: the slip goes back and the point comes off. This round only. n must match, so a double tap undoes one. |
Skip [clue-giver] (skip) |
The next teammate takes over, for someone who stepped away. The slip in play is replaced. |
Who gives clues (sit_out) |
Takes someone out of clue-giving or puts them back. They still guess. A team always keeps one clue-giver. |
End the game (end) |
Asks for a second tap, then ends the game as it stands. |
A player who leaves is stepped aside the same way, and rejoins the rotation if they return (note).
The result, and when a game counts#
tables/bowl/rules.py _finish · lines 479–488def _finish(state: dict, why: str) -> None:
state["phase"] = "over"
state["giver"] = state["slip"] = None
state["live"] = False
state["paused"] = None
totals = _totals(state)
winner = None if totals[0] == totals[1] else int(totals[1] > totals[0])
state["finish"] = {"why": why, "winner": winner}
log.event(state, "over", totals=totals, winner=winner, why=why)
_bump(state)
result gives each seat its team’s slips round by round. More slips is first; a tie shares first. A game played to the end counts. A game the host ends early counts only after a whole round:
tables/bowl/rules.py counts · lines 708–716def counts(state: dict) -> bool:
"""Whether the game goes in anyone's history and rates, if it ends as it
stands (docs/bowl.md, "Ratings"): played to the end, or the host ended
it after a whole round (progressive's second round on). Ended sooner,
little was played: a nought-all draw or a half-emptied bowl."""
done = state["finish"]
if done is not None and done["why"] == "empty":
return True
return state["round"] > 1
So a classic game ended early never counts, and progressive counts from its second round. A game that doesn’t count shows its score but isn’t kept or rated, because history.record asks counts. Ending during setup uses the room’s own End game and never counts. The result sheet then says “Ended early, so it doesn’t count”.
The contract#
| Part | Here |
|---|---|
min_players, max_players, bots |
4, 20, False |
teams, team_names |
2; Red and Blue (the service keeps them) |
options, new, act |
As above. act is moves.dispatch over 17 moves (idea, vote, slip, unslip, enough, start, got, oops, pause, resume, restart, pass, undo, skip, sit_out, end, close); a finished game refuses everything |
tick, deadline |
The turn clock and the setup picks |
deciding |
The setup picks, each seat owing slips, and the clue-giver (start, then clue), but not while paused or gone |
holds_up(state, seat, kind) |
Everything except clue, since a turn’s clock runs by itself |
view, fallback |
Below; the fallback is always legal and never scores |
note |
Steps a seat aside or back in |
counts, result, round_no |
As above. round_no is state["round"], so someone waiting can take a seat at a new round |
Your move#
The first time you’re up to give clues, the room kit’s YourMove card says Start your turn (Your move at a table). Later turns ring after a few seconds idle. Nothing rings while the clock runs.
Secrets#
- Theme ideas and votes are hidden until the pick closes.
commitshows who has picked. - Each slip is seen only by its writer until it’s earned.
- The slip in play is seen only by the clue-giver.
Earned slips are public, and at the end so is everything, including who wrote what.
tables/bowl/rules.py view · lines 766–811def view(state: dict, seat) -> dict:
phase = state["phase"]
done = phase == "over"
text = lambda sid: _slip(state, sid)["text"]
earned = [{"text": text(g["slip"]), "team": g["team"], "round": g["round"], "turn": g["turn"], "giver": g["giver"],
**({"by": _slip(state, g["slip"])["by"]} if done else {})} for g in state["got"]]
turn = None
if phase in ("ready", "turn"):
turn = {
"no": state["turn"] + (phase == "ready"), "live": state["live"], "starts": state["starts"], "ends": state["ends"],
"paused": state["paused"], "got": [text(sid) for sid in state["turn_got"]], "oops": len(state["oops"]),
}
recap = None
if state["recap"]:
r = state["recap"]
recap = {**r, "got": [text(sid) for sid in r["got"]]}
tally = None
if state["tally"]:
tally = {k: v for k, v in state["tally"].items()}
in_play = phase == "turn" and state["live"] and state["slip"] is not None
return {
"game": slug, "seq": state["seq"], "n": state["n"], "phase": phase,
"mode": state["mode"], "secs": state["secs"], "rotate": state["rotate"],
"round": state["round"], "rounds": state["rounds"], "rule": rule(state),
"team": list(state["team"]), "slips_each": SLIPS_EACH,
"pick": commit.seen(state, seat),
"ideas": [{"id": i["id"], "text": i["text"], "mine": seat in i["by"]} for i in state["ideas"]],
"runoff": state["runoff"], "tally": tally, "theme": state["theme"],
"counts": [len(_mine(state, s)) for s in range(state["n"])],
"enough": sorted(state["enough"]),
"mine": [{"id": s["id"], "text": s["text"]} for s in _mine(state, seat)] if seat is not None else [],
"bowl": len(state["bowl"]) if phase in ("ready", "turn", "over") else len(_in_bowl(state)),
"total": len(_in_bowl(state)),
"up": state["up"], "giver": state["giver"], "out": sorted(state["out"]), "gone": sorted(state["gone"]),
"turn": turn, "in_play": in_play,
"slip": {"id": state["slip"], "text": text(state["slip"])} if in_play and seat == state["giver"] else None,
"earned": earned,
"scores": [_round_scores(state, r) for r in range(1, state["round"] + 1)],
"totals": _totals(state),
"names": state.get("names") or list(team_names),
"recap": recap,
"finish": state["finish"],
"rated": counts(state),
"left": [s["text"] for s in state["slips"] if not s["gone"] and s["id"] in state["bowl"]] if done else None,
"events": log.recent(state, seat),
}
ideasis empty until the theme step closes. Then each idea carriesminefor the viewer, so you can’t vote for yours but no one learns whose is whose.earnedaddsby(the writer) only when the game isover.left(what’s still in the bowl) shows only at the end.countssays how many slips each person has put in.pick.pickedsays who has picked.
The duplicate check leaks a little. “Great minds!” tells a writer a phrase is already in the bowl, and they can try as often as they like. It’s a way to test guesses.
Bots#
None (ADR 0031). bot_action exists only for the tests, whose simulated players (level "sim") play from their seat’s view. Any other bot-held seat steps aside (fallback) and never scores.
Rating#
A counted game, kept for at least one signed-in player, is rated as two teams of people. A guest counts as an unchanging 1500 ± 350, and a seat a bot covered is frozen (Ratings). The teams are the host’s, kept with the result as _teams, so a rebuild rates the same teams:
ratings/tables.py teams_of · lines 47–65def teams_of(game: str, n: int, options: dict) -> list[int]:
"""Which side each seat plays for: partners across the table in Euchre,
alternating round it in Get In Line and Bowl (two teams, people only),
and Vibe Check's two teams (its co-op is one team, so it doesn't rate);
everyone for themselves elsewhere."""
if game in ("bowl", "vibe-check"):
# (The teams the host picked, kept with the result: not by seat.)
kept = (options or {}).get("_teams")
if isinstance(kept, list) and len(kept) == n and all(t in (0, 1) for t in kept):
return list(kept)
return [s % 2 for s in range(n)]
if game == "euchre":
return [s % 2 for s in range(n)]
if game == "get-in-line":
from tables.get_in_line import rules
teams = rules.team_count(n, rules.options(options)["teams"])
return [s % teams for s in range(n)]
return list(range(n))
A team’s skill is its members’ skills summed. Its rank is its best member’s place, so a tie ranks both first.
Worked example. Six players, all 1500 ± 80, three a side. One team wins:
| Gain | |
|---|---|
| Winners | +12.3 each |
| Losers | -12.3 each |
With seven at the table, four against three:
| Gain | |
|---|---|
| The bigger team wins | +10.2 each for the winners, -13.6 for the losers |
| The smaller team wins | +13.6 each for the winners, -10.2 for the losers |
| A tie | +0.0 for each of the four, +0.0 for each of the three |
Plain sums would make four 1500s (6000) a 99.6% favorite over three (4500), so a win by the four would be worth almost nothing. Instead, when sizes differ, each team’s sum is scaled to the average team size, making a team as strong as its average player (Ratings, “Sides of different sizes”). Equal players then make an even match: a tie moves no one, and a win is worth the same to either team, split among its players. No one has measured whether an extra guesser is worth something in Bowl.
The interface#
The room, and the waiting room#
games/static/games/bowl/app.js calls the room kit’s start() with min: 4, max: 20, bots: false, ownSeats: true (Bowl draws the waiting room’s seats itself), teams: true, its own words and art, and three house-rule settings (Game, Turns, Clue-giver). With no bots, the front door shows one panel with the game’s gather line, “Play it together: everyone in one room, or on a video call, each with their own phone”.
The waiting room shows the two teams side by side. The host can swap two players (tap one, then one on the other team), Shuffle teams, rename a team by tapping its name, or remove a player (×). With an odd number, a note says which team has one more. Each team has a color and a shape, a triangle or a square, so color isn’t the only cue (teams.js).
One screen, in fixed places#
The table is made to be read across a room: teams and scores, the clock, who’s giving clues, the round’s rule, and the bowl. Setup uses the same space for three scenes: the theme, the vote (your own idea disabled and tagged “yours”), and the bowl (your slips with take-backs, and who’s done). A scene is rebuilt only when the phase changes and otherwise patches itself in place, so what you’re typing stays.
The clue-giver’s phone shows the slip, huge, with the clock, Guessed!, Oops! and Pause. fitSlip starts the text at 42% of the card’s height, up to 150px, and shrinks it until it fits, breaking a long word rather than spilling it. The buttons are enabled only while a slip is live and the game isn’t paused, which includes the grace window past 0:00. A wake lock keeps the screen on, with an Options row to turn it off. Everyone else sees the clock, who’s giving clues, and the slips earned so far.
Keys: P pauses (for the host or the clue-giver; while paused, it opens the host’s pause menu), and ? opens How to play.
Sound and touch#
At 10 seconds left, a soft tick and a short buzz. At time’s up, a two-note fall and a longer buzz (ADR 0031). Both are on by default, with an Options switch (uo:bowl:sounds). Browsers allow sound only after the page is touched. A chime says “your go to give clues” when the tab is hidden.
Banners#
Banners such as “Time!”, “Over to Red” and “Round 2: Two words” say what’s true now. One shows only if play hasn’t moved past it (announce) and only from a current snapshot: not one held up more than 3 seconds, one that arrived while the tab was hidden, or the first after a reconnection. A newer banner replaces the old, and a new turn clears them. So a reload or a reconnection replays none.
What’s handed to you is said on your phone (ADR 0054): when the clue-giving passes to you mid-turn (the standard rotation, after each slip), your phone buzzes and says “Your slip!”, since you were guessing, eyes off it; and when a turn ends, “Time!” is followed by who’s up next (“Ada’s up next”, or “You’re up next!” with a buzz). A guessed slip lights up its team’s score, the who-line is Heading size, an undo says whose point it took, and a skipped clue-giver is told the host skipped them.
The result sheet#
The house result sheet lists each team’s earned slips round by round with each writer’s name, any slips left if the game ended early, and the winner.
Its look#
Bowl’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 #3b2a1c on its own ground; the one fill #3b2a1c; yours and your turn #ffd34d; good #2f6f37 and bad #a8301f. A family kitchen table: warm linen, the house’s turn yellow for whose turn. A light look, brown ink on linen. The two teams are apart by lightness as well as hue, each with its own shape. |
| Faces | Young Serif for titles, the theme and the winners; Nunito for everything else, readable across a room; Caveat for what’s written on the slips |
| Corners and edges | Panels 16px, buttons 12px |
| Board | The bowl, a cane-colored stoneware mixing bowl with a diamond lattice, glazed white inside, on a red gingham napkin: one SVG (bowlart.js) drawn from the look’s tokens. Folded paper slips fill it in a seeded scatter, deepest first, so a slip always sits in the same place and only the top of the pile changes. |
| Emblem and art | Its box on the shelf |
| Voice | Slips, the clue-giver, rounds; “Start my turn” |
| Payoff | The room’s celebration for a win |
| The glance | The countdown: the clock, biggest. Now: who’s giving clues, beside their team’s mark. The score: the two scores, lighting up as slips are guessed. Yours: on the clue-giver’s phone, the slip. |
| Exceptions | None |
Testing#
tests/test_bowl.py is the bar:
| Group | Proves |
|---|---|
| Setup | Four to twenty, teams alternating by default; no bots; secret ideas with public who-has-picked; duplicates merged; one idea or none; no voting for your own; votes, runoffs and the draw; five slips each and take-backs; play starts when everyone has written |
| Turns | Only the clue-giver sees the slip; countdown then clock; both rotations; Oops! for the turn; a tap in the grace counts but is the last; no passing; double taps; who picks up after time-up; classic ends when the bowl empties; ties; who wrote what at the end |
| Progressive | Three rounds with the same slips; a mid-turn empty bowl hands the next round to the other team; totals run on |
| Teams and slips | The host’s teams are dealt; uneven teams fall back to alternating; only the host counts someone done; when an ended game counts |
| Pause | Pause and resume; restart keeps earned slips; undo, skip, pass and End the game; sitting out; a leaver is stepped aside; a bot’s seat never scores |
| Contract and simulation | The shared TableGameContract; whole simulated games of 4–6 (classic) and 20 (progressive) |
| Service and lobby | Plays through the service with the host refereeing; rated as two teams; joining, removing and leaving balance the teams; teams kept for rating; an instantly ended game isn’t kept; team names are short, clean, different, and stay text |
npm run check:bowl:play (tools/bowl/play.mjs) plays fast in headless Chrome on a phone. It checks that team names fit at 320 and 390 px, that a flurry of turns leaves at most one banner, and that a reload or a long dropped connection replays none.
Changing it#
| To… | You need |
|---|---|
| Change a rule | rules.py, docs/bowl.md and How to play (rules.js) together, and a test. Live tables’ saved state doesn’t migrate, so a new key needs a default. |
| Flip who gives clues | ROTATE_DEFAULT, one line. |
| Change a time | COUNTDOWN, GRACE, TURN_SECONDS. The client’s WARN and LATE are set separately. |
| Add a host call | A move in MOVES that calls _host(action) first, and a button in the pause sheet (drawPauseMenu). |
| Change the teams | The service’s _sides, _op_teams and _op_team_names, which serve every team game. |
| Change what counts for ratings | counts (and result for places). A rebuild re-reads kept results, so old results keep their meaning. |
Known issues#
- The last Guessed! of a round or game can’t be undone. That slip is a natural dispute, but by then the next round has started or the game is over, and the host’s calls need
readyorturn. - Skip between turns lifts a pause. In the
readyphase,_skipcalls_ready, which clearspaused. Mid-turn, a skip leaves the pause alone. - “Great minds!” tells a guesser whether a phrase is in the bowl (see Secrets).
- The design notes’ six open questions are unanswered: one clue-giver per slip or per turn, voting for your own idea, who starts after time runs out mid-slip, whether Restart keeps earned slips, the End-the-game split, and uneven teams. The code uses the notes’ proposed answers.