Card games
Contract Rummy
A rummy game over seven hands, where each hand sets the melds you must lay down before you can play on the table.
On this page
Contract Rummy is a rummy game for three to eight players, over seven hands. Each hand has a contract: the groups of cards (melds) you must lay down before you can play on the table, such as two sets of a rank, or a set and a run in a suit. When someone throws away a card you want, you can buy it out of turn, at the cost of an extra card. Lowest total after seven hands wins.
It’s also called Liverpool or Shanghai. The engine follows the family’s rules in docs/contract-rummy.md line for line (“if the two disagree, one of them is a bug”). The server does a lot for the player: it works out how to go down, what can be played where, and which cards are worth keeping.
It’s the oldest table game (ADR 0008), and the service’s default think times and idle-seat rules were written for it. Its screens use the room and card kits.
At a glance#
| What | Where |
|---|---|
| Rules: deal, draw, buy, go down, lay off, swap, discard, going out, scoring, what each seat sees | tables/contract_rummy/rules.py |
| Sets, runs, jokers, swapping | tables/contract_rummy/melds.py |
| The planner: how far a hand is from the contract, how to go down, what can be played where | tables/contract_rummy/plan.py |
| Bots, three levels | tables/contract_rummy/bots.py |
| The package the service registers | tables/contract_rummy/__init__.py |
| The table: seat tiles, piles, melds, hand, dock, building a contract, buying, the result sheet | games/static/games/contract-rummy/table.js |
| The client’s copy of the meld rules | games/static/games/contract-rummy/melds.js |
| The front door and the table’s settings | games/static/games/contract-rummy/app.js |
| How to play | games/static/games/contract-rummy/rules.js |
| Its look | games/static/games/contract-rummy/rummy.css |
| Its page | games/templates/games/contract-rummy.html |
| Tests | tests/test_contract_rummy.py, plus the contract in tests/table_contract.py |
| Design notes: the family’s rules | docs/contract-rummy.md |
| Worked examples in this chapter | guide/worked/contract_rummy.py, with guide/worked/cards.py |
It’s registered in tables/games.py and listed in games/catalog.py; its anchors are in ratings/tables.py.
The rules in code#
What the engine provides#
| Part | Contract Rummy’s |
|---|---|
min_players, max_players |
3 and 8. |
seating |
"joined": seats in the order people came (the host can still rearrange the waiting room). |
terms |
A round is a “hand”; low wins. |
options |
How long buying stays open, and bot speed. |
tick, deadline |
The buy window’s clock: tick closes a window whose time is up, and deadline says when. |
away, away_due, holds_up |
An away seat passes on any open buy. The upcard’s untimed buy holds the table up; a timed one doesn’t. |
arrange |
The careful “Go down” layout, worked out only when asked (below). |
note |
A seat changing hands, as a log line. |
result |
Scores hand by hand, totals and places. It predates the shared rounds helper and doesn’t use it. |
bot_think |
Not defined. The engine’s defaults (1.2 s to draw, 1 to play, 1.2 on a buy) were set for this game. |
round_no |
Not defined (known issues). |
Decks, the deal and the seven contracts#
There’s one joker fewer than players, and more decks for bigger tables. decks_for and jokers_for give these, with the stock left after hand 1’s deal (ten cards each, plus the upcard) and hand 5’s (twelve each):
| Players | Decks | Jokers | Cards | Stock after hand 1's deal | Stock after hand 5's deal |
|---|---|---|---|---|---|
| 3 | 2 | 2 | 106 | 75 | 69 |
| 4 | 2 | 3 | 107 | 66 | 58 |
| 5 | 3 | 4 | 160 | 109 | 99 |
| 6 | 3 | 5 | 161 | 100 | 88 |
| 7 | 4 | 6 | 214 | 143 | 129 |
| 8 | 4 | 7 | 215 | 134 | 118 |
guide/worked/contract_rummy.py, TABLESThe seven contracts (CONTRACTS, as (sets, runs) pairs) and the cards dealt (DEALS):
| Hand | Contract | Cards dealt |
|---|---|---|
| 1 | 2 sets | 10 |
| 2 | 1 set + 1 run | 10 |
| 3 | 2 runs | 10 |
| 4 | 3 sets | 10 |
| 5 | 2 sets + 1 run | 12 |
| 6 | 1 set + 2 runs | 12 |
| 7 | 3 runs | 12 |
guide/worked/contract_rummy.py, CONTRACTSThe first dealer is the seed read as a number, and the deal passes left each hand. Cards are dealt one at a time from the dealer’s left, the next card is turned up to start the discard pile, and the player left of the dealer goes first.
tables/contract_rummy/rules.py _deal · lines 99–117def _deal(state: dict, hand: int, now: float) -> None:
n = state["n"]
dealer = (state["first_dealer"] + hand) % n
stock = deck.shuffled(deck.build(decks_for(n), jokers_for(n)), f"{state['seed']}:{hand}")
hands = [[] for _ in range(n)]
for _ in range(DEALS[hand]):
for k in range(1, n + 1): # one at a time, starting left of the dealer
hands[(dealer + k) % n].append(stock.pop())
state.update(
hand=hand, dealer=dealer, hands=hands, stock=stock, discard=[stock.pop()],
melds=[], next_meld=1, down=[False] * n, turn=(dealer + 1) % n,
phase="draw", went_down=False, window=None, out=None, ready=[False] * n,
down_at=[None] * n, laid=[], leftovers=None,
)
state["turns"] += 1
_event(state, "deal", hand=hand, dealer=dealer, card=state["discard"][-1], contract=contract_text(hand))
# The upcard can be bought like any discard; it isn't anyone's to protect.
# No one's racing a clock while they look at a new hand.
_open_window(state, None, now, timed=False)
Each hand shuffles from the seed and the hand number, so a whole game replays from its seed (tables/deck.py). Besides hands, stock, discard, scores and totals, the state holds:
melds, each{id, owner, kind, cards, …}, a run withsuitandlotoo;down(who has gone down) andwent_down(who went down this turn);phase:draw,play,scoredorover;window(the open buy) andprefs(each seat’s buying choice);down_atandlaid, so browsers can say “just went down”, andleftovers.
Melds#
A set is three or more cards of one rank, any suits. A run is four or more in sequence in one suit. The ace is high (J-Q-K-A) or low (A-2-3-4) but can’t turn the corner (no K-A-2), so a run is at most ace to ace. Jokers stand for any card, with no limit per meld. A meld can even be all jokers, and the player says what it stands for.
A run is {suit, lo, cards}, with cards[i] at position lo + i, so stands_for reads a joker’s meaning from where it sits.
tables/contract_rummy/melds.py make_run · lines 32–76def make_run(cards: list[str], low_jokers: int = 0, as_suit=None, as_lo=None) -> dict:
"""Arrange cards into a run. Jokers fill the gaps first; any left over go
on the ends, `low_jokers` of them below the lowest card (as room allows)
and the rest above."""
naturals = [c for c in cards if not is_joker(c)]
jokers = sorted(c for c in cards if is_joker(c))
low_jokers = max(0, low_jokers)
if len(cards) < 4:
raise Illegal("A run needs at least four cards.")
if len(cards) > ACE_HIGH:
raise Illegal("A run can't be longer than ace to ace.")
if not naturals:
s = as_suit if isinstance(as_suit, str) and as_suit in SUITS else "S"
lo = as_lo if isinstance(as_lo, int) and not isinstance(as_lo, bool) else ACE_LOW
lo = max(ACE_LOW, min(lo, ACE_HIGH - len(cards) + 1))
return {"kind": "run", "suit": s, "lo": lo, "cards": jokers}
if len({suit(c) for c in naturals}) > 1:
raise Illegal("A run's cards must all be the same suit.")
best = None
# An ace can sit low or high: try each way the aces could go.
for spots in product(*(positions(c) for c in naturals)):
if len(set(spots)) < len(spots):
continue
lo, hi = min(spots), max(spots)
extra = len(jokers) - ((hi - lo + 1) - len(naturals))
if extra < 0:
continue
below = min(low_jokers, lo - 1, extra)
above = extra - below
if hi + above > ACE_HIGH:
below += hi + above - ACE_HIGH
above = ACE_HIGH - hi
if lo - below < ACE_LOW:
continue
# Prefer the arrangement that puts the jokers where they were asked for.
score = abs(below - low_jokers)
if best is None or score < best[0]:
best = (score, dict(zip(spots, naturals)), lo - below, hi + above)
if best is None:
raise Illegal("Those cards don't make a run.")
_, at, lo, hi = best
spare = iter(jokers)
ordered = [at[p] if p in at else next(spare) for p in range(lo, hi + 1)]
return {"kind": "run", "suit": suit(naturals[0]), "lo": lo, "cards": ordered}
make_run tries every place the aces could go and fills gaps with jokers first. Spare jokers go on the ends: the player’s low_jokers below the lowest card, the rest above. The server clamps low_jokers to what the run can hold and refuses anything else.
Going down#
Once per hand, on your turn after drawing, you may lay down your contract: exactly the melds it asks for. Sets are exactly three cards and runs exactly four; extra cards wait for a later turn. You must keep a card to discard.
tables/contract_rummy/rules.py go_down · lines 216–261def go_down(state, seat, action, now):
_turn(state, seat, "play")
if state["hand"] == LAST:
raise Illegal("In the last hand you go out all at once.")
if state["down"][seat]:
raise Illegal("You've already gone down this hand.")
hand = list(state["hands"][seat])
table = copy.deepcopy(state["melds"])
freed, swapped = [], []
for sw in action.get("swaps") or []:
_take(hand, sw.get("card"))
run = _find(table, sw.get("meld"))
freed.append(melds.swap(run, sw["card"]))
swapped.append({"card": sw["card"], "owner": run["owner"]})
hand += freed
sets, runs = CONTRACTS[state["hand"]]
specs = action.get("melds") or []
made = []
for spec in specs:
cards = spec.get("cards") or []
for c in cards:
_take(hand, c)
m = melds.make(spec.get("kind"), cards, _low(spec), spec)
size = SET_SIZE if m["kind"] == "set" else RUN_SIZE
if len(m["cards"]) != size:
raise Illegal(f"Going down, each {m['kind']} is exactly {size} cards; the rest wait for a later turn.")
made.append(m)
if [m["kind"] for m in made].count("set") != sets or [m["kind"] for m in made].count("run") != runs:
raise Illegal(f"This hand's contract is {contract_text(state['hand'])}.")
if any(j in hand for j in freed):
raise Illegal("A joker you swap out has to go into your contract.")
if not hand:
raise Illegal("You need a card left to discard.")
for m in made:
m.update(id=state["next_meld"], owner=seat)
state["next_meld"] += 1
state["melds"] = table + made
state["hands"][seat] = hand
state["down"][seat] = True
state["went_down"] = True
_down_at(state, seat)
for sw in action.get("swaps") or []:
_laid(state, seat, sw["card"], sw["meld"])
_event(state, "down", seat=seat, melds=[_logged(m) for m in made], swaps=swapped)
A going-down move can include swaps. If a run on the table holds a joker and you hold the natural card it stands for, you put your card in and take the joker. The joker must go into the contract you’re laying down. The move is all-or-nothing: it works on copies of the hand and table, and the service saves only an accepted move.
Laying off, and swapping once down#
On turns after the one you went down (went_down blocks the same turn), you may add cards to any meld on the table, yours or anyone’s: the right rank on a set, or the next card at either end of a run. You can’t start new melds.
tables/contract_rummy/rules.py lay_off · lines 312–339def lay_off(state, seat, action, now):
"""One card, or several as `plays`: [{card, meld, end}, ...], played in
order (so a 7 then the 8 that goes on it works) and all or none. The
player says where every card goes; nothing is chosen for them."""
_can_play_on_table(state, seat)
plays, strict = action.get("plays"), "plays" in action
if not strict:
plays = [{k: action.get(k) for k in ("card", "meld", "end")}]
hand = list(state["hands"][seat])
if not isinstance(plays, list) or not plays or len(plays) > len(hand):
raise Illegal("That move didn't make sense.")
table = copy.deepcopy(state["melds"])
done = []
for p in plays:
card = p.get("card")
_take(hand, card)
meld = _find(table, p.get("meld"))
if strict:
_named_end(meld, card, p.get("end"))
melds.lay_off(meld, card, p.get("end"))
done.append((card, meld))
if not hand:
raise Illegal("Keep a card to discard: you go out by discarding.")
state["melds"] = table
state["hands"][seat] = hand
for card, meld in done:
_laid(state, seat, card, meld["id"])
_event(state, "lay_off", seat=seat, card=card, meld=meld["id"], owner=meld["owner"], meld_kind=meld["kind"])
The plays list is applied in order, so a 7 then the 8 that goes on it works, and it’s all or none. Each card names the end of the run it goes on; a card that fits both ends but names none is refused. You can’t lay off your last card, because you go out by discarding.
Once down you may also swap a joker out of a run if you hold the natural, but you must lay that joker off at once. A joker in a set can’t be swapped out.
Buying#
When a card is discarded, the next player may take it free as their draw, at any moment. Anyone else may say they’ll buy it, and if the next player draws from the stock instead, the buyer takes it plus one penalty card from the stock. If several want it, the one nearest the next player’s left gets it. Only the top discard can be bought, and only once. There’s no limit on buys per hand, and you can buy after going down.
The stock stays shut until buying closes (David’s call after the family’s playtest of 2026-10-29, when people played whole turns before others had bought or passed):
tables/contract_rummy/rules.py draw_stock · lines 152–165def draw_stock(state, seat, action, now):
"""The stock opens once buying the discard has closed (docs, "Buying"):
then whoever bought it gets it, after this player's draw."""
_turn(state, seat, "draw")
if stock_shut(state):
raise Illegal("The stock opens once everyone's said whether they'll buy the discard.")
card = _draw(state)
if card is None:
return
state["hands"][seat].append(card)
state["phase"] = "play"
_event(state, "draw", seat=seat)
if state["window"]:
_settle_buy(state)
A buy is a window on the top discard:
tables/contract_rummy/rules.py _open_window · lines 446–464def _open_window(state: dict, discarder, now: float, timed: bool = True) -> None:
"""Buying opens on the top discard. An untimed one (the upcard) stores
closes None, never infinity: the state is JSON, and Postgres refuses
Infinity. `shut` is buying closed, early or on time: the stock opens."""
state["windows"] += 1
state["window"] = {
"id": state["windows"], "card": state["discard"][-1], "from": discarder,
"claims": [], "passed": [], "declined": False, "shut": False,
"closes": now + state.get("buy_seconds", BUY_WINDOW) if timed else None, "untimed": not timed,
}
card = state["window"]["card"]
for s in buyers(state):
pref = state.get("prefs", {}).get(str(s), "ask")
# Once you're down, buying only adds cards you must get rid of, so
# "only if it could help" doesn't ask.
helps = not state["down"][s] and could_help(state["hands"][s], card)
if pref == "never" or (pref == "helps" and not helps):
state["window"]["passed"].append(s)
_settle(state, now)
It opens at every discard, for the seconds the table chose (10 to 60; standard 30). It also opens on the upcard at the deal, with no timer. There closes is None, never infinity, because the state is JSON and Postgres refuses Infinity. Who may buy:
tables/contract_rummy/rules.py buyers · lines 530–536def buyers(state: dict) -> list[int]:
"""Who may say "I'll buy that" right now: not the player whose turn it is
(they take it free) and not whoever threw it."""
w = state.get("window")
if not w or state["phase"] not in ("draw", "play"):
return []
return [s for s in range(state["n"]) if s not in (state["turn"], w["from"])]
A buy is a claim, said out loud; a pass stays private until buying closes (Secrets). Buying closes (shut, which opens the stock) once time is up or the buy is decided:
tables/contract_rummy/rules.py _settled · lines 500–518def _settled(state: dict) -> bool:
"""Whether the buy is decided without waiting any longer: going round
from the next player's left, the first person who wants it has it once
everyone ahead of them has passed; nobody wants it once all have.
The upcard waits for everyone, even behind someone buying it: with no
timer, a quick bot mustn't close it before people have looked."""
w = state["window"]
eligible = set(buyers(state))
if _untimed(w):
return all(s in w["claims"] or s in w["passed"] for s in eligible)
for s in priority(state):
if s not in eligible:
continue
if s in w["claims"]:
return True
if s not in w["passed"]:
return False
return True
tables/contract_rummy/rules.py _settle · lines 521–527def _settle(state: dict, now: float) -> None:
"""Close buying once everyone's decided or time's up, which opens the
stock. Who gets the card is worked out when the next player draws: until
then they can still take it free."""
w = state["window"]
if w and not w.get("shut") and (_closed(w, now) or _settled(state)):
w["shut"] = True
Going round from the next player’s left (priority), the first who wants the card gets it once everyone ahead has passed, without waiting out the clock. The upcard waits for everyone, even behind a buyer, so a quick bot can’t close it before people have looked. Who gets the card is worked out when the next player draws (_settle_buy), so a whole turn always starts with buying settled; nothing waits behind it. Seats whose preference skips the card pass the moment it’s thrown, so a table where no one could want it opens the stock at once. A bot that doesn’t want the discard says so straight away (decline) and waits; a person who takes the seat back before its draw can still take the card, since the bot’s no thanks wasn’t theirs (note, on “back” or “sit_in”). “No thanks” once buying has closed, early or on time, is refused out loud (“Buying’s closed on that card.”) rather than answered with nothing: closing is public, so saying so gives nothing away.
A worked buy. Four players. Seat 0 throws the 8♥, then seats 3 and 2, in that order, say “I’ll buy that” while seat 1, next, waits. Priority from the next player’s left is seat 2, seat 3, seat 0.
| After | The stock | Hands (seats 0 to 3) | Stock |
|---|---|---|---|
| The deal (10 each) | 10, 10, 10, 10 | 66 | |
| Seat 3 buys | shut: seat 2 is ahead of seat 3 and hasn’t said | ||
| Seat 2 buys | open at once: seat 2 is first in line and wants it | ||
| Seat 1 draws from the stock | 10, 11, 12, 10 | 64 |
Seat 2 gets the 8♥ and a penalty card, for twelve. Seat 3 gets nothing for asking. If the stock runs dry while drawing a penalty card, the buyer keeps the bought card and the hand ends. If the next player takes the discard instead, at any point, the window and every claim on it are gone.
Going out#
In hands 1 to 6 you go out when your last card is your discard; you can’t go out by laying off every card. In hand 7 no one goes down early. You go out by laying your whole hand at once as exactly three runs of at least four, every card included (any you bought too), with no discard:
tables/contract_rummy/rules.py go_out · lines 390–415def go_out(state, seat, action, now):
"""The last hand: the whole hand at once, as exactly three runs, no discard."""
_turn(state, seat, "play")
if state["hand"] != LAST:
raise Illegal("Only in the last hand do you go out all at once.")
hand = list(state["hands"][seat])
specs = action.get("runs") or []
if len(specs) != CONTRACTS[LAST][1]:
raise Illegal("Go out with exactly three runs.")
made = []
for spec in specs:
cards = spec.get("cards") or []
for c in cards:
_take(hand, c)
made.append(melds.make_run(cards, _low(spec), spec.get("suit"), spec.get("lo")))
if hand:
raise Illegal("Every card has to go into your three runs, with no discard.")
for m in made:
m.update(id=state["next_meld"], owner=seat)
state["next_meld"] += 1
state["melds"] += made
state["hands"][seat] = []
state["window"] = None
_down_at(state, seat)
_event(state, "down", seat=seat, melds=[_logged(m) for m in made], swaps=[], all=True)
_end_hand(state, seat)
The hand ends as soon as someone goes out. When the stock is empty, _draw shuffles the discards under the top card into a new stock. If there’s nothing under it, the hand ends and everyone counts what they hold.
Scoring#
tables/contract_rummy/rules.py points · lines 60–64def points(card: str) -> int:
if is_joker(card):
return 25
r = card[0]
return 15 if r == "A" else 10 if r in "TJQK" else int(r)
Cards left in your hand count against you: 2 to 10 at face value, J, Q and K ten, an ace 15, a joker 25. Going out scores 0. A hand left holding an ace, a king, a 7, a joker and a 3 (5 cards) scores 60. After seven hands the lowest total wins, and tied players share the win. There are no bonuses, and no penalty for discarding a playable card.
tables/contract_rummy/rules.py _end_hand · lines 607–616def _end_hand(state: dict, out) -> None:
scores = [0 if s == out else sum(points(c) for c in h) for s, h in enumerate(state["hands"])]
state["scores"].append(scores)
state["totals"] = [t + s for t, s in zip(state["totals"], scores)]
state["out"] = out
state["window"] = None
state["ready"] = [False] * state["n"]
state["phase"] = "over" if state["hand"] == LAST else "scored"
state["leftovers"] = leftovers(state, out)
_event(state, "out" if out is not None else "stock_out", seat=out, scores=scores)
The settings#
tables/contract_rummy/rules.py options · lines 75–81def options(raw: dict | None) -> dict:
"""A table's settings, from whatever the host asked for."""
raw = raw or {}
buy = raw.get("buy_seconds")
pace = raw.get("bot_pace")
return {"buy_seconds": buy if buy in BUY_CHOICES else int(BUY_WINDOW),
"bot_pace": pace if pace in BOT_PACE else "brisk"}
Setting (key) |
Default | Choices | What it does |
|---|---|---|---|
Time to decide on buying (buy_seconds) |
30 s | 10, 20, 30, 45 or 60 s | How long a (non-upcard) buy stays open. |
Bot speed (bot_pace) |
brisk | brisk, relaxed | Multiplies bots’ think times (1 or 1.8). |
Each seat also has a buying preference: which discards it wants to be asked about. It’s ask, helps or never (every discard; only those that could help before it has gone down; never). It lives in the player’s browser and is sent as a buy_pref move. A discard it skips counts as a pass straight away, so it never keeps the stock shut.
Turn flow and timers#
Every decision belongs to one seat, except buying, which is many at once. deciding() returns the player to draw or play, every buyer who hasn’t decided on the open window (kind buy), and every seat that owes a ready between hands:
tables/contract_rummy/rules.py deciding · lines 930–948def deciding(state: dict) -> list[tuple[int, str, str]]:
out = []
phase = state["phase"]
w = state["window"]
if phase == "draw":
# While the stock's shut the next player can only take the discard
# (or say they won't, and wait): a new decision once it opens.
if not stock_shut(state):
out.append((state["turn"], "draw", f"d{state['turns']}"))
elif not w["declined"]:
out.append((state["turn"], "draw", f"d{state['turns']}w"))
elif phase == "play":
out.append((state["turn"], "play", f"p{state['seq']}"))
if w and not w.get("shut") and phase in ("draw", "play"):
decided = set(w["claims"]) | set(w["passed"])
out += [(s, "buy", f"w{w['id']}") for s in buyers(state) if s not in decided]
if phase == "scored":
out += [(s, "ready", f"r{state['hand']}") for s in range(state["n"]) if not state["ready"][s]]
return out
The play decision’s key includes seq, so it changes after any move. While the stock is shut the next player’s draw is a decision of its own (take the discard, or decline and wait), and a new one once it opens, so a bot thinks again. tick closes buying once time is up, opening the stock, and deadline is when (none while the upcard waits on people, though a bot’s own wait still counts). Bots answer a buy in 0.8 s (times the table’s pace), since the stock waits on them. tick also settles a table saved before the stock waited on buyers: its next player had drawn while buying was open, and may have held back a discard. A person whose page has gone quiet doesn’t hold up buying:
tables/contract_rummy/rules.py away · lines 970–974def away(state, seat, now):
"""Its player is away: pass on any open buy for them (one whose time
is up just closes, as the next tick would have it)."""
if away_due(state, seat) and not _closed(state["window"], now):
act(state, seat, {"type": "pass"}, now)
holds_up decides what the table can pass someone for:
tables/contract_rummy/rules.py holds_up · lines 977–988def holds_up(state, seat, kind) -> bool:
"""Whether the table waits on this decision. A timed buy doesn't (it
closes by itself), and naming who hasn't decided would give away who
has passed. The upcard has no timer, and the stock waits on it: someone
sitting on it for a minute can be passed for. And the next player
waiting for the stock to open isn't holding anyone up."""
w = state.get("window")
if kind == "draw":
return not stock_shut(state)
if kind != "buy":
return True
return _untimed(w)
A timed buy closes by itself, and naming who hasn’t decided would reveal who has passed, so it holds no one up. The untimed upcard does, from the deal (the first player’s stock waits on it), and after a minute anyone at the table can pass for the slow seat, and the host can hand it to a bot (the host only, as in every game, since the 2026-10-29 playtest). That names who’s still deciding, and so, by what’s left, who has passed. The review of 2026-10-29 kept it: the upcard closes only once everyone has said and claims are said aloud, so every pass is public when it closes anyway; every seat’s clock starts at the deal, so a minute in an instant automatic pass looks like a considered one; and naming the slow seat is what lets the table move on. The next player waiting on buyers holds no one up. fallback is the always-legal move: draw from the stock (or, while it’s shut, decline), pass, say ready, or discard the first non-joker.
Your move#
New players most often miss that their hand can go down. The first time in a game it can (the server’s suggest), the room kit’s YourMove (Your move at a table) shows Lay down your contract (in the last hand, Go out with your hand), shrinking into the Go down button, which rings. It waits for the hand intro, the deal (introPlaying, dealPending) and moves still sliding in. After that the status says it each turn. Buying gets no card, since its clock is running.
Secrets#
The state holds every hand and the stock, and view(state, seat, aids=True) is the only way they leave. Every log line is public (a draw says only that a seat drew), so the secrets are the hands, the stock and the passes.
| Own seat | Other seats | Spectator | |
|---|---|---|---|
| Hand | Its cards | A count only | No hands |
| The stock | A count | A count | A count |
| The discard pile | The top card, and how many | Same | Same |
| Melds, who is down, which cards were just laid | Public | Public | Public |
| An open buy: claims | Public (said out loud) | Public | Public |
| An open buy: passes | Your own only | Only their own | None |
| Whether you are the last one deciding | Yes (only_you) |
||
suggest, useful_swaps, plays, keepers |
For the seat whose turn it is, from its own hand and the public table | None/[] |
None/[] |
| After the hand | Everyone’s remaining cards and how close each came to the contract (revealed, leftovers) |
Same | Same |
The pass is private on purpose: “an instant automatic pass would tell the table the card can’t help that hand”. A player set to “only if it could help” passes instantly on every discard that couldn’t, which would read as a signal. So _window_view shows each seat only its own pass, and the screen shows everyone else as “deciding” until buying closes:
tables/contract_rummy/rules.py _window_view · lines 719–734def _window_view(state: dict, seat) -> dict:
"""The open buy, as one seat may see it. Claims are said out loud, but a
pass is private until buying closes: an instant automatic pass would tell
the table the card can't help that hand. A seat sees its own pass, and
whether it's the only one still deciding (the next player waits on it).
`shut`: buying has closed and the stock is open; who gets the card is
settled when the next player draws."""
w = state["window"]
undecided = [s for s in buyers(state) if s not in w["claims"] and s not in w["passed"]]
return {
"id": w["id"], "card": w["card"], "from": w["from"], "claims": list(w["claims"]),
"passed": [seat] if seat is not None and seat in w["passed"] else [],
"only_you": seat is not None and not w.get("shut") and undecided == [seat],
"declined": w["declined"], "shut": bool(w.get("shut")), "closes": w["closes"], "untimed": _untimed(w),
"priority": priority(state),
}
leftovers is worked out once as the hand ends, shown only with the hands, and never logged. The contract tests’ scramble re-deals every hand and the stock, and requires views and bot moves to stay unchanged:
tests/test_contract_rummy.py ContractRummyContract · lines 1231–1250class ContractRummyContract(TableGameContract, SimpleTestCase):
"""Contract Rummy meets what every table game must (tests/table_contract.py)."""
game = game
every = 9 # its views work out a player's aids: look now and then
def scramble(self, state, keep, rng):
# While a hand's on, other hands and the stock are secret: deal them
# out again, each hand its own size. (Once it's scored, the hands are
# shown to everyone.)
if state["phase"] not in ("draw", "play"):
rng.shuffle(state["stock"])
return
others = [s for s in range(state["n"]) if s != keep]
pool = [c for s in others for c in state["hands"][s]] + state["stock"]
rng.shuffle(pool)
for s in others:
size = len(state["hands"][s])
state["hands"][s], pool = pool[:size], pool[size:]
state["stock"] = pool
Bots decide with view(..., aids=False), because the person’s helps (below) are heavy to compute and a bot works out its own.
The planner#
The server’s thinking on the player’s behalf is all in tables/contract_rummy/plan.py. It’s pure (no side effects) and works on faces (“8H”) rather than cards (“8H1”), since two decks’ eights of hearts are interchangeable.
best(hand, sets, runs) finds the fewest cards still missing from the contract, counting jokers, and the partial melds that get there. It searches every set rank and run window, pruning, and caches by hand shape. Bots take or buy a card when it lowers Plan.missing.
arrange(hand, sets, runs, table, seen, decks) finds the best way to go down. It lists every complete way to make the contract (_complete, within a budget), imagines going down and discarding the best card, and weighs the hand that’s left:
tables/contract_rummy/plan.py arrange · lines 492–551def arrange(hand: list[str], sets: int, runs: int, table=(), seen=(), decks: int = 2,
extra_jokers=(), budget: int = 20_000) -> list[dict] | None:
"""The best way to go down with this hand, as go_down melds, or None if
it can't be done. `extra_jokers` are jokers being swapped in from the
table; they must go into the contract.
Every complete plan is tried by imagining going down, discarding the
best card, and weighing the hand that's left (lowest wins): the points
of cards that can't be laid off next turn (on the table or the new
melds), a fifth of the points of those that can, 8 more for each joker
still in hand, and 4 for each joker in a new run whose natural card
might still turn up for someone to swap in (judged only from cards this
player has seen: melds on the table, the discards, their own hand).
Ties go to more open run ends. A hand rich enough to run past the
search budget gets plan.best's plan instead."""
pool = list(hand) + list(extra_jokers)
faces, jokers = split(pool)
if len(pool) - sets * SET_SIZE - runs * RUN_SIZE < 1:
return None # nothing left to discard
plans = _complete(faces, jokers, sets, runs, [budget], len(extra_jokers))
if plans is None:
p = best(pool, sets, runs)
return contract_melds(list(hand), p, extra_jokers) if not p.missing else None
if not plans:
return None
shapes = tuple(shape(m) for m in table)
seen_faces = Counter(face(c) for m in table for c in m["cards"] if not is_joker(c))
seen_faces.update(face(c) for c in seen if not is_joker(c))
seen_faces.update(faces)
counts = Counter(faces)
scored = []
for n, groups in enumerate(plans):
used, placed, new, open_ends, live = Counter(), 0, [], 0, 0
for kind, key, picks in groups:
used.update(f for f in picks if f)
placed += (SET_SIZE if kind == "set" else RUN_SIZE) - sum(1 for f in picks if f)
if kind == "set":
new.append((-1, "set", "A" if key == ("~",) else key[0], 0, 0))
continue
s, lo = ("S", 1) if key == ("~",) else key
new.append((-1, "run", s, lo, lo + RUN_SIZE - 1))
open_ends += (lo > 1) + (lo + RUN_SIZE - 1 < 14)
live += sum(1 for p, f in zip(range(lo, lo + RUN_SIZE), picks)
if f is None and seen_faces[FACE_AT[p - 1] + s] < decks)
left = counts - used
spare = jokers - placed
fit, fit_jokers = _fitted(left, spare, shapes + tuple(new))
cost = sum(points(f) * (0.2 if i < fit[f] else 1) for f in left for i in range(left[f]))
# The discard: the costliest card that won't lay off, else the
# costliest that will; a joker only when there's nothing else.
if left:
d = max(left, key=lambda f: (fit[f] < left[f], points(f), f))
cost -= points(d) * (1 if fit[d] < left[d] else 0.2)
else:
spare -= 1
fit_jokers = min(fit_jokers, spare)
cost += 25 * (0.2 * fit_jokers + (spare - fit_jokers)) + 8 * spare + 4 * live
scored.append((round(cost, 6), -open_ends, n))
groups = plans[min(scored)[2]]
return contract_melds(list(hand), Plan(0, jokers, groups), extra_jokers)
Lower is better. The weights:
- Points that can’t be laid off next turn count in full; those that can, a fifth.
- Each joker kept costs.
- A joker in a new run costs a little more if its natural card might still turn up for someone to swap in, judged only from what this player has seen.
- Ties go to more open run ends.
A hand so rich it runs past the budget falls back to best’s plan. Because it’s slow, it runs only when asked.
lay_offs(hand, table) finds what can go on the table once down: an order that works played one after another, placing the most cards (then points), jokers last and onto sets where they can’t be swapped back. A card with two equally good places is the player’s choice (choose). It holds back the cheapest spare card, since you go out by discarding.
go_out_runs(hand) checks, for the last hand, whether every card fits into exactly three runs, spending jokers on gaps and short runs. final_cost says how far a hand is from going out, and progress and final_groups what it came closest to.
“Arrange it for me”#
view() carries a quick suggest: the melds that make the contract, if the hand already does, swapping in a table joker if that’s what it takes. When a person presses Go down, the browser asks GET …/arrange/, which runs the careful arrange:
tables/views.py arrange · lines 143–161@require_GET
def arrange(request, slug, code):
"""The melds "Go down" lays out for this seat, worked out with
care on request: {"melds", "swaps"} like the view's quick `suggest`
({"runs"} in the last hand), with the game's `seq` so a browser can tell
if the table's moved on; null when there's nothing to arrange. `?keep=`
(JSON): melds already built by hand, kept; only the rest come back."""
table = _table(_game(slug), code)
seat = service.seat_for(table, _secret(request))
if not seat:
return _refuse(Illegal("You're not at this table."))
keep = None
if request.GET.get("keep"):
try:
keep = json.loads(request.GET["keep"])
except ValueError:
return _refuse(Illegal("That isn't a list of melds."))
found = service.arrange(table, seat, keep)
return JsonResponse(found and {**found, "seq": table.state["seq"]}, safe=False)
?keep= passes melds the player has built; the server keeps them and returns only what finishes the contract (or nothing, if they aren’t a fair start). The answer carries seq, so a browser can tell if the table moved on; it asks once more, then falls back to the quick suggestion. A test keeps arrange quick for hands with hundreds of ways down.
Bots#
A bot decides from view(state, seat, aids=False) through bots.choose: a buy if it isn’t its turn, a draw or a play if it is.
| Easy | Medium | Hard | |
|---|---|---|---|
| Takes the discard | If it lowers missing, but refuses 35% of useful ones; a joker always; once down, if it lays off |
If it lowers missing |
The same |
| Buys | A quarter of the time it looks; if it lowers missing and the hand isn’t too big |
If it lowers missing and the hand stays under deal + 3 |
Deal + 4, and not when someone is close to going out |
| Goes down | The first complete plan (contract_melds) |
plan.arrange, swapping in a table joker if needed |
The same |
| Lays off | Highest points first | In lay_offs order, one card a move so people can follow |
The same |
| Swaps jokers | Never | To go down, and once down to play a joker it can lay off | The same |
| Discards | 30% a random loose card; else as medium | The card whose loss leaves missing least, then least connected (connection), then costliest |
As medium, but avoids feeding what others wanted (_danger), and sheds its costliest cards once someone is down to two or fewer |
| Memory | None | None | _wants: what each player took, bought or claimed this hand |
In the last hand every level plays differently. Bots never buy, since bought cards must all go out together. They go out with go_out_runs when it finds three runs, and otherwise discard the card that leaves final_cost lowest. _wants reads the view’s events, which hold only the latest 40 lines, so hard’s memory is that short.
Measured strength#
The committed anchors come from tools/ratings/anchors.py at three seats, 300 games a pairing: medium beat easy 72.7% (+170 Elo) and hard beat medium 50.4% (+3). A 150-game rerun (October 2026) gave 71.5% and 52.3%. Hard isn’t measurably better than medium.
In 30 games of one bot of each level at three seats, totals averaged easy 241, medium 174, hard 200, and wins were 6, 14 and 10. Every one of 210 hands ended with someone going out. A hand averaged 1.5 buys, two players going down, four cards laid off, and a joker swap one hand in nine. The slowest bot decision took about 190 ms, the mean 11 ms.
Rating#
A game is a field of 3 to 8 sides, one seat each, ranked by place (lowest total first, ties sharing). It’s rated as any table game: each seat meets every other once, each meeting counting 2/k for k seats (fields). The anchors:
| Level | Rating |
|---|---|
| easy | 1330 |
| medium | 1500 |
| hard | 1500 |
ratings/tables.py, ANCHORS.contract-rummyEasy is 1330, the measured gap (under the 400-point cap). Hard is level with medium at 1500: it wins only 50 to 52 percent against medium, and a hard bot that plays no worse shouldn’t be rated below it.
Worked example. A settled 1500 ± 70 player at a table of four with an easy, a medium and a hard bot, by where they finish (the bots in order of strength around them):
| You finish | Change |
|---|---|
| 1st (lowest total) | +17.0 |
| 2nd | +3.8 |
| 3rd | −9.4 |
| 4th | −22.6 |
A tie counts as a draw in that meeting: level with the medium bot for second, the same player moves −2.8. A game the host ended early is never kept, a seat a bot covered for part of the game isn’t rated, and guests count as 1500 ± 350 and never move. Kept games are ADR 0010.
The interface#
app.js calls the room’s start(). Two things are particular to Contract Rummy:
games/static/games/contract-rummy/app.js lines 1–36// Contract Rummy: the family rules (docs/contract-rummy.md), played against
// bots or friends at a shared table. The lobby and waiting room are the room
// kit's (../room/kit/), the cards and table styles the card room's
// (../cards/kit/); this game brings its rules and its table.
import { start } from '../room/kit/room.js';
import { look } from '../cards/kit/looks.js';
import { rules } from './rules.js';
import { play, rummyOptions } from './table.js';
start({
game: 'contract-rummy',
title: 'Contract Rummy',
kicker: 'Card game · 3 to 8 players',
blurb: 'Seven hands, seven contracts, and the eternal question: who’s buying that?',
min: 3,
max: 8,
rules,
play,
look,
devices: 'desktop',
// How to play opens by itself once, on a first visit, before the deal.
firstRun: true,
cues: true,
progress: game => `Hand ${game.hand + 1} of ${game.of}`,
options: () => rummyOptions(),
settings: [{
key: 'buy_seconds', label: 'Time to decide on buying', value: 30,
hint: 'How long everyone gets to buy a discard.',
choices: [[10, '10s'], [20, '20s'], [30, '30s'], [45, '45s'], [60, '60s']],
}, {
key: 'bot_pace', label: 'Bot speed', value: 'brisk',
hint: 'Relaxed is easier to follow.',
choices: [['brisk', 'Brisk'], ['relaxed', 'Relaxed']],
}],
});
Only the host can hand an away seat to a bot, as in every game (it had been anyone’s here, coverAnyone, until David’s playtest of 2026-10-29). There’s no spectate, so a watcher gets the room’s own watch screen, a seat list, instead of the table (known issues). devices: 'desktop' makes the front door say, on a phone, that the game is made for a computer first.
The table#
RummyTable (games/static/games/contract-rummy/table.js) draws a bar, a tile for every player round a table, the stock and discard piles in its middle (the two big buttons when it’s your draw), your hand along the bottom, and the dock. Tiles are sized from the player count and the window alone, so nothing moves when someone goes down.
The table is a ring (ringSeats, ADR 0053): your tile at the bottom middle, just above your hand, the player on your left next, up the left, across the top and down the right, with the stock and discard side by side in the middle. Where a corner beside a tile is empty the tile spans the row, so your tile runs the width of the table up to six players, and at four so does the one across from you. ringFit gives every tile one card size, worked out for a one-cell tile. A tile that spans a row lays its melds out in one row, centered under the middle. Up to five players the middle column is only as wide as the piles, so the tiles beside them get the room:
games/static/games/contract-rummy/table.js ringFit · lines 148–164/** The table ring (kit/seats.js ringSeats) in a W×H space, the middle
* column `midW` wide where it's narrow: one card size for every tile, sized
* for a one-cell tile, and a tile that spans a row (yours, or the one across
* from you) laying its melds out in one row where they fit. */
function ringFit(n, W, H, kinds, cw, midW) {
const ring = ringSeats(n);
const colW = ring.narrow ? (W - midW - 2 * T.gap) / 2 : (W - (ring.cols - 1) * T.gap) / ring.cols;
const fit = tileFit(colW * ring.cols + (ring.cols - 1) * T.gap, H, kinds, cw, [[ring.cols, ring.rows]]);
const bodyH = fit.tileH - T.padY - (fit.dense ? 0 : fit.narrow ? T.head2 : T.head);
const one = slotFit(kinds, 1, W - T.padX - (fit.dense ? T.dense : 0), bodyH, Math.round(cw * 1.1));
// (centered under the middle of the table: the last meld keeps a run's
// worth of room to grow, not the whole row)
const wide = one[fit.mode] >= fit.w
? one.rows(fit.mode, fit.w).map(row => row.map(slot => ({ ...slot, width: Math.min(slot.width, Math.floor(2 * UNIT.run * fit.w)) })))
: fit.rows;
return { ...fit, ring, wide };
}
With everyone down to a set and two long runs, other players’ cards measure 70, 64, 50 and 50 px at 3, 4, 6 and 8 players on a 1440×900 window, and 58, 49, 38 and 38 px at 1180×820. In the last hand the tiles keep any hand’s size (the contract’s three runs), everyone else’s holding their cards face down, as many as they hold (lastBody), until someone goes out and their runs land in their tile. (They had been headers only over one open space, which left thin bars and a big empty box on a wide screen: the review of 2026-10-29.) On a phone the last hand keeps that open space (spreading()).
A tile shows the player, their cards in hand and their score, as the house’s Figures (the glance, ADR 0054: the count’s word gives way first, never the numbers), and, once down, their melds, condensed where there isn’t room (a set as one card on a stack, a run as its ends). Recently laid cards are marked “new” until that player’s next turn ends. A seat’s badge says “buying”, “deciding”, “bought”, “passed” (your own only), “ready” or “away”.
The dock has a two-line status and two move buttons. A press that does nothing says why in the status line; the log is the bar’s (ADR 0046). The left button takes a card or lays down. The right one is the safe or turn-ending move (Draw from stock, Discard 7♥, No thanks), so a late click lands on something harmless. Buying uses keys B and N.
Each hand opens with a hand intro showing its contract with example melds, once per hand in this browser; it can be skipped, switched off, or replayed by clicking the contract.
Going down#
Go down asks the server for the best layout (arrange/) and stages it, one panel per meld, for Confirm. Or pick cards and use Make a set / Make a run (“Move joker” moves a spare joker to the other end; an all-joker meld asks what it stands for), and Go down finishes what you built. Each staged meld shows “✓” or why not, and the status says what’s missing; its Undo takes it back to your hand (Go down then finishes the rest again), and Cancel at the tray’s end, or Escape, takes them all back, swaps too (cancelStaging). The tray floats over your own tile; in the ring (one grid column, the piles inside it) it’s placed in that column, where it had landed past the window’s right edge after the ring arrived, out of sight and so out of reach (the family’s game of 2026-10-29). In the last hand the flow reads Go out. The client’s copy of the meld rules (games/static/games/contract-rummy/melds.js) says what picked cards would make; the server decides.
Laying off, and swapping#
Once down, Play all I can (plays from the view) plays every card that has one place, and leaves picked any that could go in two. Wherever a picked card could go is drawn in place (slotsFor, slotEl): a card-sized slot at the end of the run (or set) it would join, dashed (it isn’t there yet), ringed in the turn color with a soft glow, and naming the card in its corner. Pressing one plays the card there. A slot takes a card’s place in the fan, so the meld keeps its width and nothing around it moves; a condensed meld’s slots take its bridge’s place. A meld with one place is pressable as a whole, and a joker that fits both ends of a run goes to the end nearer the tap. These replaced the buttons that used to float over the meld and hide it (the family’s playtest of 2026-10-29). Swapping a joker (David’s pick, A of three designs, 2026-10-29) starts from your card. A card in your hand that can win a run’s joker (swapsFor: once you’re down, any run’s joker; before, only one the server’s useful_swaps says brings the contract closer) wears a ★ chip (markWins, with the glow’s Options row). Pick it, and the run draws your card in the joker’s place (ghostIn, ghostEl): dashed and ringed like a spot, with a “★ out” chip. Once you’re down, every place the joker could then go is lit as a ★ spot at once (always: these spots aren’t a helper) (slotsFor on the run as it would be, swapped), and pressing one sends the whole swap (swapTo: the card in, the joker onto that meld) in one move: two taps in all. Before going down, or when the card could win more than one joker, the card in the run is itself the button (“Take ★”): it takes that joker, into your hand for the contract (as before) or on to its lit spots, and the status line alone says what’s next, read out once: “Joker taken. Pick a lit ★ spot for it, or Cancel the swap to keep your 10♥.” (No toast repeats it.) On a phone each spot is pressed anywhere in the 44px round it (.house-reach). A joker taken for the contract that Go down would take too leaves Go down live (takenAsSuggested), which lays everything out.
Buying, in the interface#
When you may buy, the dock reads Buy it / No thanks, the status gives your place in line, and a clock counts down (not on the upcard). After passing (“Passed ✓”) you can still change your mind until it closes, while others show “deciding”. The “Ask me about buying” option sets which discards ask you; the rest are passes. The next player sees the same clock, counting to the stock (“Stock opens within 8s”, then “Stock open”). Neither of their buttons is filled then, and neither is shut: Take works at once, and Draw from stock (or the stock pile, or D) locks their draw in (drawStock: “Drawing ✓”, the stock “Drawing next”, the status “Waiting for others to decide before drawing.”), drawn the moment buying closes (drawWhenOpen, on the update that opens it). Pressed again it’s let go, and Take drops it. Once the stock’s open, Draw from stock is the filled button; Take never is, so nothing nudges anyone toward the discard (David, 2026-10-29). A toast says if someone ahead buys or the next player takes the card, and so does one to a player who hadn’t decided yet when it went (“Ada bought the 7♥.”), so the clock never just vanishes (ADR 0054). Cards someone else plays on your melds are a toast too, in the room kit’s one sentence (playedOnYours, games/static/games/room/kit/onyours.js), held until that player’s turn is over however many updates its plays came in, and said once they’ve landed (“Ben played the 8♥ and 9♥ on your run.”); your tile lights up, and until you act your turn’s status line leads with it (“Ben played the 8♥ on your run. Your turn: take the 10♣ or draw.”, toldLead). Your turn coming clears no toasts, and the cues catch up (catchUp(), never skip()), so the move just before yours always plays (ADR 0054).
Assists#
Contract Rummy has two assist rows, both on by default:
| Assist | What it computes |
|---|---|
Check my discards (uo:cards:rummy:check-discards) |
The server sends keepers: for the seat on turn, the reasons a card may be worth keeping. It is a joker, it swaps a run’s joker, it could be laid off right now, it sets back the contract (the contract can’t spare it), or it fits a table meld. The screen asks “Discard the 7♥ anyway?” once (a second press discards) and says why: “it goes on Ada’s run”. |
Light up cards I can play (uo:cards:rummy:show-plays) |
Nothing on the server: once you’re down and may play on the table, each card in your hand that fits a meld now (fits, the client’s copy of the rules) wears a turn-colored ring and glow (the hand’s decorate), the same as the slot it would land in, and a card that can win a joker wears a ★ (markWins). Not the last card, which you go out with. The same name as every card game’s. |
The other helps (Go down, Play all I can, the “new” marks, the buy preferences) aren’t switches. Their server side is _aids/_helps, cached so repeated views cost next to nothing.
Between hands, and the result sheet#
When a hand ends, the house’s result sheet opens (state hand) after a moment to see the last play. It shows the scores hand by hand (12px even on a 320px phone, where the table scrolls under its total) and how close everyone came (leftovers): the cards left in hand for anyone down, and for anyone not, their closest melds with dashed ghosts for the missing cards (in the last hand, the closest three runs). “Next hand” says you’re ready, and the host can start without waiting once they’re ready. The final sheet adds a chart of totals hand by hand.
On a phone#
It’s desktop-first but works at 390×844. Below 640 px the ring becomes a sliding row of seats, from the player on your left round to you, over the piles and a list of who is down. Each chip holds the glance’s two figures, the count and then the score a size smaller. Six or more seats slide (four, on a 320px phone); your chip, the last, stays pinned at the row’s end with the others passing under it (its shadow says there’s more), and the turn scrolled to keeps clear of it. The list keeps the order they went down, so a new tile only adds to the end, and your own tile is last, pinned at the foot just above your hand. The staging tray spans the screen (its cards shrink to 24 px before it scrolls), and the bar’s tools fold into “⋯”. Touch targets are 44 px under pointer: coarse.
Its look#
Contract Rummy’s personality, in the design system’s personality sheet.
| Family | Cards: the room kit and the card room. Everything else is the card room’s (Card games). |
| Palette | None of its own: the table style the player picks in Options (Clubhouse to begin with). |
| Faces | The table style’s |
| Corners and edges | The table style’s |
| Board | A table ring of seat tiles (you alone at the bottom middle, the others up the left, across the top and down the right) round the stock and discard pile. A tile is a one-line header (the seat’s mark, name, card count, one badge) over that player’s melds, drawn as full fans in fixed slots. On a phone, a row of seat chips over the list of who’s down. |
| Emblem and art | Its box on the shelf |
| Voice | The family’s words, kept: go down, go out, buy, the contract (“2 sets”) |
| Payoff | The room’s win celebration when your place is first, else best for a rating high, behind the result sheet. |
| The glance | Now: the tile’s turn ring and the status line, which leads with what was played on your melds since your last turn. The countdown: the contract, in the bar’s title. The score: each seat’s cards in hand and score, Figures in every tile’s header. Yours: your hand. |
| Exceptions | Shared with the card room: the dealer’s chip on a seat mark’s corner, a fanned hand’s cards under 44px, a seat’s name giving way first, the dock’s line kept to two lines. Its own: the score chart’s small ticks on a phone (David’s call), and the score sheet’s Total column staying put while hands scroll under it. |
Testing#
| What | Proves |
|---|---|
tests/test_contract_rummy.py: DealingTests, MeldTests |
Decks, jokers and deals by player count and seed; sets, runs, aces, all-joker melds. |
GoingDownTests, BuyingTests, JokerSwapTests, UpcardTests |
Exactly the contract; no lay-off the turn you go down; the free take and the nearest buyer; windows closing early; prefs; the upcard; swaps. |
LastHandTests, ScoringTests |
Going out all at once; face values; ties; the empty-stock reshuffle. |
ViewTests, SuggestionTests, SwapSuggestionTests, DiscardCheckTests |
A pass is private until closing; suggest, keepers and plays appear only in your own view on your turn. |
PlayingSeveralCardsTests, PlanningPlaysTests, ArrangeTests |
All-or-none batches; the planner’s order and held-back card; arrange quick on rich hands and keeping what you built. |
AfterTheHandTests, AfterTheLastHandTests |
leftovers hidden until scored, never logged, cleared at the next deal. |
BotTests, BotSafetyTests, ContractRummyContract |
Bots finish games; a refused bot move becomes a legal one; the shared secrecy and bot contract. |
check:layout, check:details |
The play screen at every window size, and the finished game’s result sheet. |
Run uv run python manage.py test tests.test_contract_rummy (a full run takes a while, as bots play whole games), and npm run check:layout -- --only rummy after a look change (testing).
Known limitations and known issues#
- Watchers don’t see the table.
app.jshas nospectate: true(Euchre, Sixty-Six, Michigan Rummy and Bullshit do), so a spectator gets the room’s seat list. The server already sends a spectator everything public (view(state, None)), andRummyTablehandlesyou === nullin places, but never draws the table for one. - No sitting in at the next hand. With no
round_no, someone waiting can take a bot’s seat only when the host plays again. - Hard is no better than medium, so its anchor is level. Its memory (
_wants) sees only the last 40 lines, and its other adjustments are small. - It has its own log, not
tables.log._eventappends events andviewsends them as stored, with noto. That’s fine while every event is public, but a private line would needtables.logandsight, or it would leak to every seat. - Drawing from an empty stock ends the hand. With nothing under the top discard, the stock button stays live, and pressing it ends the hand. That’s the documented rule and almost never happens (none of 210 bot hands), but a person can end a hand by pressing the wrong pile.
- The buy preference defaults differ. The server starts every seat at “ask”; the browser’s default is “only if it could help”, sent as a
buy_prefmove when the page first sees the game. A seat that never loads the page stays at “ask” and is waited on, or passed for after a minute. - The talk check can’t run here.
tools/room/talk.mjs --game contract-rummystops at “the game is up, the smiley in its bar”, because it opens a watcher and Contract Rummy has no watcher view (above). Emotes and chat are covered by the Euchre run.
Changing it#
| To change | Needs |
|---|---|
| A rule | rules.py or melds.py, docs/contract-rummy.md (they must agree), rules.js, and the client’s melds.js for a meld rule; the tests. If a finished game’s places could change, bump the ratings VERSION. |
| A setting | options() in rules.py, the settings list in app.js, the doc, and a test. |
| What a seat sees | view() and scramble(). Anything computed for a seat (suggest, plays, keepers) must read only that seat’s hand and the public table. A pass must stay private until buying closes. |
| The planner | plan.py: PlanningPlaysTests, ArrangeTests and the bots’ tests. The server’s suggestions and every bot use it. |
| A bot | bots.py; rerun tools/ratings/anchors.py 300 contract-rummy (about twenty minutes), update ANCHORS, bump VERSION. |
| The look | rummy.css and the table; check:tokens, check:layout, check:details. |
Then reread this chapter against the change and npm run guide:stamp contract-rummy.