Card games
Sixty-Six
A two-player card game where you win tricks, show off a king and queen for bonus points, and race to be the first to say you have 66.
On this page
Sixty-Six (Sechsundsechzig) is a two-player card game played with 24 cards. You win tricks that carry points, declare a king and queen of one suit for a bonus, and try to be first to announce you have 66. The site plays David’s family’s version.
The twist is that nobody counts for you. The table never shows card points during a hand, so keeping score in your head is the game, and much of the code exists to keep it that way.
The engine follows docs/sixty-six.md line for line (“if the two disagree, one of them is a bug”). It’s a table game built from pure functions (state in, new state out), drawn with the room and card kits, and rated against the bot or person across the table.
At a glance#
| What | Where |
|---|---|
| Rules: deal, draw, marriage, exchange, close, claim, scoring | tables/sixty_six/rules.py |
| Bots, three levels, with a perfect-information endgame search for hard | tables/sixty_six/bots.py |
| The package the service registers | tables/sixty_six/__init__.py |
| The table: rail, plates, stock, trick, hand, dock, result sheet | games/static/games/sixty-six/table.js |
| The front door and the one setting | games/static/games/sixty-six/app.js |
| How to play | games/static/games/sixty-six/rules.js |
| Its look | games/static/games/sixty-six/sixty-six.css |
| Its page | games/templates/games/sixty-six.html |
| Tests | tests/test_sixty_six.py, plus the contract in tests/table_contract.py |
| Design notes: David’s family rules | docs/sixty-six.md |
| Worked examples in this chapter | guide/worked/sixty_six.py, with guide/worked/cards.py |
It’s registered in tables/games.py and games/catalog.py as a two-player table game you can play solo against a bot.
The rules in code#
What the engine provides#
| Part | Sixty-Six’s |
|---|---|
min_players, max_players |
Both 2; new() refuses anything else. |
options |
One setting: Bot speed. |
terms |
A round is a “hand”; high total wins. |
bot_think |
Seconds by decision: see turn flow. |
holds_up |
Everything holds the table up except the dealer’s early exchange. |
result, round_no, note |
Shared helpers: rounds.result(high=True), hands scored so far, the log’s seat news. |
tick, deadline |
No timers. |
away, arrange |
Not defined. |
The cards and the deal#
Each suit has 9JQKTA (low to high). The cards’ points total 120:
| Rank | Points |
|---|---|
| A | 11 |
| T | 10 |
| K | 4 |
| Q | 3 |
| J | 2 |
| 9 | 0 |
tables/sixty_six/rules.py, VALUEtables/sixty_six/rules.py _deal · lines 110–128def _deal(state: dict) -> None:
"""Three each from the dealer's opponent, the trump card turned, three
each; the rest is the stock, face down over it."""
dealer = (state["first_dealer"] + state["deals"]) % 2
elder = 1 - dealer
pack = deck.shuffled(cards(), f"{state['seed']}:{state['deals']}")
hands = [[], []]
for seat in (elder, dealer):
hands[seat] += [pack.pop() for _ in range(3)]
turned = pack.pop()
for seat in (elder, dealer):
hands[seat] += [pack.pop() for _ in range(3)]
state["deals"] += 1
state.update(
dealer=dealer, hands=hands, stock=pack, trump_card=turned, trump=turned[1],
phase="play", turn=elder, leader=elder, trick=[], done=[], tricks=[0, 0], taken=[0, 0],
marriages=[], declared=None, exchanged=None, closed=None, draw=None, shown=[[], []], outcome=None,
)
log.event(state, "deal", dealer=dealer, trump_card=turned, deal=state["deals"])
The shuffle comes from the seed and the deal number. Three cards go to the dealer’s opponent (the elder), three to the dealer, one is turned up for trump, then three more each. The other 11 lie face down on the trump card: a stock of 12, six draws each. The elder leads first. The dealer alternates, and the seed picks the first.
State#
| Key | Meaning |
|---|---|
hands |
Six cards each, to start. |
stock, trump_card, trump |
The 11 face-down cards (drawn from the end); the card turned for trump, lying under the stock; the trump suit. |
phase, turn, leader |
play while a hand is on, then scored and over. |
trick, done, tricks, taken |
The trick on the table; finished tricks; tricks won per seat; card points taken in tricks per seat. |
draw |
The seat that owes a draw (the trick’s winner) before it may lead. It is a step of its own, so the winner can close first. |
marriages, declared |
Every marriage declared this hand {seat, suit, points}; the suit declared on this lead, whose king or queen must be led. |
exchanged, closed |
The exchange made, if any; who closed the stock, if anyone. |
shown |
Per seat, the cards the other player is known to hold: a marriage’s two, the trump card taken. Public. |
scores, totals, ready, outcome |
A row of game points per hand; running totals; who is ready for the next hand; how the last hand ended. |
Drawing and tricks#
A trick is two cards. While the stock lasts, you needn’t follow suit. The higher card of the suit led wins, unless a trump is played:
tables/sixty_six/rules.py beats · lines 55–59def beats(card: str, led: str, trump: str) -> bool:
"""Whether `card`, played second, takes the trick `led` opened."""
if card[1] == led[1]:
return higher(card, led)
return card[1] == trump
The winner then owes a draw (state["draw"]) before leading: winner first, then the other player. When one face-down card is left, the winner takes it and the other takes the trump card:
tables/sixty_six/rules.py _draw · lines 326–338def _draw(state: dict, first: int) -> None:
"""The winner draws, then the other; the last face-down card's taker's
opponent takes the trump card."""
got_trump = None
for seat in (first, 1 - first):
if state["stock"]:
state["hands"][seat].append(state["stock"].pop())
else:
got_trump = state["trump_card"]
state["hands"][seat].append(got_trump)
state["shown"][seat].append(got_trump)
state["trump_card"] = None
log.event(state, "draw", first=first, trump_card=got_trump, left=stock_left(state))
draw is its own move so the winner can close first. Closing clears the pending draw, so both players then hold five cards.
Strict play#
Once the stock is closed or gone, the second player must follow suit and win the trick if they can. If they can’t follow, they must trump if they can. The leader may always play anything.
tables/sixty_six/rules.py legal · lines 67–78def legal(hand: list[str], trick: list[dict], trump: str, strict: bool) -> list[str]:
"""Anything while the stock lasts. Once it's closed or gone, the second
player must follow suit and win if they can; without the suit, trump if
they can; else anything."""
if not trick or not strict:
return list(hand)
led = trick[0]["card"]
same = [c for c in hand if c[1] == led[1]]
if same:
return [c for c in same if higher(c, led)] or same
trumps = [c for c in hand if c[1] == trump]
return trumps or list(hand)
strict() is true when closed is set or nothing is left to draw.
Marriages#
When leading, after drawing, a player with the king and queen of one suit may declare a marriage, worth 20, or 40 in trump, and must lead one of the two:
tables/sixty_six/rules.py marry · lines 210–227def marry(state, seat, action, now):
_on_lead(state, seat)
suit = action.get("suit")
if state["draw"] is not None:
raise Illegal("Draw first, or close the stock.")
if state["declared"]:
raise Illegal("One marriage a lead.")
if not isinstance(suit, str) or len(suit) != 1 or suit not in SUITS:
raise Illegal("Declare a marriage in a suit.")
if suit not in marriages_held(state, seat):
raise Illegal("You need the king and queen of that suit.")
worth = 40 if suit == state["trump"] else 20
state["marriages"].append({"seat": seat, "suit": suit, "points": worth})
state["declared"] = suit
for c in (f"K{suit}0", f"Q{suit}0"):
if c not in state["shown"][seat]:
state["shown"][seat].append(c)
log.event(state, "marriage", seat=seat, suit=suit, points=worth, counts=state["tricks"][seat] > 0)
One marriage per lead (declared). Both cards go into shown[seat], so everyone knows the second is still in that hand.
A marriage counts only once you’ve won a trick:
tables/sixty_six/rules.py points · lines 133–139def points(state: dict, seat: int) -> int:
"""A seat's card points so far: its tricks, and its marriages once it has
taken a trick."""
total = state["taken"][seat]
if state["tricks"][seat]:
total += pending_marriages(state, seat)
return total
A marriage declared on the first lead stays pending (pending()) until its player takes a trick (the log says “took a first trick, so the marriage counts”). If they never do, it never counts. A player with no tricks counts as nothing for the 3-point case below, marriage or not.
Exchanging the trump 9#
tables/sixty_six/rules.py can_exchange · lines 190–194def can_exchange(state: dict, seat: int) -> bool:
"""Only at the start of a hand, before anyone has played a card, by
whoever holds the trump 9: on the lead or not."""
return (state["phase"] == "play" and not state["done"] and not state["trick"] and state["closed"] is None
and state["trump_card"] is not None and low_trump(state) in state["hands"][seat])
The holder of the trump 9 may swap it for the trump card, but only before any card is played, with the stock open. They needn’t be on lead, so can offers it to the dealer before the elder leads. The old trump card goes into the exchanger’s hand and shown. There’s one exchange at most, and the dealer loses the chance if the elder closes first.
Closing#
tables/sixty_six/rules.py can_close · lines 197–200def can_close(state: dict, seat: int) -> bool:
"""Whenever you're leading and the stock has cards: before you draw, or after."""
return (state["phase"] == "play" and state["turn"] == seat and not state["trick"] and state["closed"] is None
and stock_left(state) > 0)
On your lead, with cards left to draw, you may close before or after drawing, with no penalty. Strict play starts, and the cards left in the stock are out of play.
Claiming 66, and scoring#
On your lead, once you have a trick, you may claim. After the last card, the last trick’s winner may claim or pass (“No claim”). judge is the whole scoring rule:
tables/sixty_six/rules.py judge · lines 341–351def judge(pts, tricks, seat: int) -> tuple[int, int, str]:
"""Who wins a hand and for how many game points when `seat` claims 66:
(winner, game points, how it ended). Pure, so the bots' search can score
with it. A correct claim takes 1 game point, 2 if the other has under 33
(and a trick), 3 if the other has taken no trick at all; a claim short of
66 gives the other 1. `pts` and `tricks` are per seat."""
other = 1 - seat
if pts[seat] >= GOAL:
gp = 3 if not tricks[other] else 2 if pts[other] < SCHNEIDER else 1
return seat, gp, "claim"
return other, 1, "short"
| How the hand ended | Game points |
|---|---|
| A correct claim, the other with 33 or more card points | 1 to the claimer |
| A correct claim, the other under 33 with at least one trick | 2 to the claimer |
| A correct claim, the other with no tricks at all | 3 to the claimer |
| A claim short of 66 | 1 to the other player; the claimer gets nothing; the hand ends |
| Every card played and nobody claimed | Nothing to either: a lost hand, counted as a hand played |
_settle records the outcome with both players’ final card points, the first time anyone is sent them:
tables/sixty_six/rules.py _settle · lines 354–375def _settle(state: dict, seat: int, claimed: bool) -> None:
"""Score the hand: `seat` claimed 66 (right or wrong), or every card is
played and nobody did (`claimed` False: no one scores)."""
pts = [points(state, 0), points(state, 1)]
if claimed:
won, gp, result = judge(pts, state["tricks"], seat)
log.event(state, "claim", seat=seat, ok=won == seat)
else:
won, gp, result = None, 0, "unclaimed"
rounds.score(state, [gp if s == won else 0 for s in range(2)])
state["outcome"] = {
"winner": won, "points": gp, "how": result, "by": seat if claimed else None,
"card_points": pts, "tricks": list(state["tricks"]),
"closed": state["closed"]["seat"] if state["closed"] else None,
}
log.event(state, "hand", **state["outcome"])
state["turn"] = None
state["draw"] = None
done = max(state["totals"]) >= TARGET
if done:
log.event(state, "won", seat=won)
rounds.end(state, last=done)
The first to 7 game points wins. There’s no last-trick bonus. The only setting is bot speed (1× or 1.8× think times). A bot game runs about 8 or 9 hands, since some hands are lost and most pay 1 or 2.
Turn flow and timers#
A hand is a series of one-seat decisions, with two wrinkles. A trick’s winner decides draw next (or closes, marries, claims, or passes if the cards are gone). And a dealer holding the trump 9 may exchange before the first lead, so deciding() can return two entries:
tables/sixty_six/rules.py deciding · lines 487–503def deciding(state: dict) -> list[tuple[int, str, str]]:
phase = state["phase"]
if phase == "scored":
return rounds.ready_deciding(state)
if phase != "play":
return []
turn = state["turn"]
step = (f"{len(state['done'])}.{len(state['trick'])}.{len(state['marriages'])}"
f".{1 if state['exchanged'] else 0}{1 if state['closed'] else 0}{1 if state['draw'] is not None else 0}")
fresh = not state["done"] and not state["trick"] and not state["marriages"] and not state["exchanged"] and not state["closed"]
kind = "draw" if state["draw"] == turn else "open" if fresh else "play"
out = [(turn, kind, f"p{state['deals']}-{step}-{turn}")]
other = 1 - turn
if can_exchange(state, other):
# The dealer holding the trump 9 may exchange it before the first lead.
out.append((other, "exchange", f"p{state['deals']}-x-{other}"))
return out
holds_up says the table doesn’t wait on that early exchange. Bot think times:
| Decision | Seconds |
|---|---|
| play | 1 |
| open | 2.5 |
| exchange | 0.4 |
| draw | 0.5 |
tables/sixty_six/rules.py, bot_thinkA bot’s first move of a hand (open) takes 2.5 seconds on purpose, giving a person time to exchange the trump 9 first. There’s no clock. A bot covers a person who walks away (the service). The fallback is always legal: draw, exchange, pass if every card is played, else the first legal card.
Your move#
Two moves are easy to miss, so each gets a room-kit YourMove card the first time in a game (Your move at a table): Draw from the stock after you take a trick, and Declare a marriage when you lead with a king and queen.
Secrets#
The state holds both hands and the stock’s order. view(state, seat) is the only way they reach a browser:
- Only your seat gets your hand (sorted trump first),
legalon your turn, andcan(draw, marry, exchange, close, claim, pass). The other seat gets a card count (counts); a spectator gets no hands. - Everyone sees
shownfor both seats (a marriage’s king and queen until played, the trump card taken), the trump card even after a close, the stock’s size (stock, andstock_leftwith the trump card), and every trick and marriage with its worth. - No one sees card points while the hand is on. They arrive in the outcome once it’s over.
Nothing in the view adds anything up. tricks counts tricks and done lists their cards, so you know your points only by adding them yourself, as the bots do. The contract test scrambles what a seat shouldn’t know (the unshown hand and the stock) and requires views and bot moves to stay the same:
tests/test_sixty_six.py SixtySixContract · lines 914–928class SixtySixContract(TableGameContract, SimpleTestCase):
"""Sixty-Six meets what every table game must (tests/table_contract.py)."""
game = game
def scramble(self, state, keep, rng):
# The other hand (beyond what it's shown) and the stock's face-down
# cards: dealt out again, each pile its own size.
other = 1 - keep
shown = state["shown"][other]
hidden = [c for c in state["hands"][other] if c not in shown]
pool = hidden + state["stock"]
rng.shuffle(pool)
state["hands"][other] = list(shown) + pool[:len(hidden)]
state["stock"] = pool[len(hidden):]
ViewTests.test_no_card_points_are_sent_to_anyone_while_a_hand_is_on plays a marriage and a trick, then checks for both seats and a spectator: no points, pending, taken, card_points or outcome, and no trick, credit, draw or claim log line with points. A marriage line carries its worth, which is public since the cards are shown. A total never appears.
Bots#
A bot decides from rules.view, counting points itself from the public tricks and marriages:
tables/sixty_six/bots.py counted · lines 111–118def counted(v: dict) -> tuple[list[int], list[int]]:
"""What a careful player carries in their head: each seat's card points
(marriages only once it has a trick) and the marriage points still
waiting for its first trick."""
taken, married = _taken(v), _married(v)
pts = [taken[s] + (married[s] if v["tricks"][s] else 0) for s in range(2)]
pending = [0 if v["tricks"][s] else married[s] for s in range(2)]
return pts, pending
Every level claims as soon as its count reaches 66, since waiting never pays more. All exchange the trump 9, declare marriages (trump first) and draw when they can. choose handles those, then plays a card:
tables/sixty_six/bots.py choose · lines 46–67def choose(state: dict, seat: int, level: str, rng) -> dict:
v = rules.view(state, seat)
if v["phase"] == "scored":
return {"type": "ready"}
can = v["can"]
if can.get("claim") and _claims(v, level, rng):
return {"type": "claim"}
if can.get("exchange"):
return {"type": "exchange"}
if can.get("pass"):
return {"type": "pass"}
if can.get("draw"):
# Before drawing: a hard bot may close on the hand it holds.
if can.get("close") and level == "hard" and _close(v, level, rng):
return {"type": "close"}
return {"type": "draw"}
if not v["trick"]:
if can.get("close") and level != "easy" and _close(v, level, rng):
return {"type": "close"}
if can.get("marry"):
return {"type": "marry", "suit": can["marry"][0]}
return {"type": "play", "card": _play(v, level, rng)}
Easy and medium are human: they slip#
Easy and medium sometimes miscount and claim when 10 points short or fewer, losing the hand. On each lead where they could, they do so with probability SLIP:
| Level | Chance per lead |
|---|---|
| easy | 0.07 |
| medium | 0.035 |
| hard | 0 |
tables/sixty_six/bots.py, SLIPThe chance is per lead, so it adds up over a hand. In 200 bot games (October 2026), easy claimed short about one hand in 12, medium one in 32, hard never.
How each level plays a card#
| Easy | Medium | Hard | |
|---|---|---|---|
| Leading, stock open | A random non-trump card | The cheapest card by _cost (points, with penalties for trump, breaking a marriage, and the aces and 10s that win tricks) |
The same |
| Following, stock open | Takes in suit when it can (the lowest winner), trumps an ace or a 10, otherwise throws its cheapest | Takes the trick that reaches 66; takes in suit when worth at least 4 and keeping a marriage together; trumps an ace or 10 cheaply; saves trump while the stock lasts | The same, but takes in suit whenever it can |
| Stock closed or gone | The same rules | Cashes a sure winner (trump first), else leads its cheapest; wins as cheaply as it must | Searches (below) |
| Closing | Never | After drawing, when _sure_points reaches 66 + 6 |
Before or after drawing, when a search says closing wins |
_sure_points is medium’s guess at what it can count on after closing: its points, its marriages, its top trumps in an unbroken run from the best still out (each worth its points plus 4), and, with three of those, its aces.
The hard bot’s search#
Once the stock is closed or gone, hard searches the rest of the hand exhaustively. The opponent can hold only cards that aren’t in hard’s hand, played, the trump card, or already shown:
tables/sixty_six/bots.py _deals · lines 275–289def _deals(v: dict, rng) -> list[tuple]:
"""The other's hands this seat should play against: every card it can't
place is theirs once the stock is gone; otherwise a sample."""
other = v["opponent"]
unseen = _unseen(v)
shown = list(v["shown"][other])
need = v["counts"][other] - len(shown)
if need < 0 or need > len(unseen):
return []
if need == len(unseen):
return [tuple(sorted(shown + unseen))]
out = []
for _ in range(SAMPLES):
out.append(tuple(sorted(shown + rng.sample(unseen, need))))
return out
With the stock gone, that leaves exactly one possible hand. After a close, with the stock unseen, hard samples 10 hands. For each candidate card, it plays out each deal with both sides playing perfectly (_lead_node, _follow_node, _resolve, memoized so repeated positions aren’t recomputed), scores it in game points with rules.judge, and plays the best total:
tables/sixty_six/bots.py _searched · lines 299–311def _searched(v: dict, rng) -> str:
"""The card that scores best, summed over the other's possible hands."""
_fresh()
deals = _deals(v, rng)
if not deals:
return _follow(v, "medium") if v["trick"] else _lead(v, "medium", rng)
score = {c: 0.0 for c in v["legal"]}
for theirs in deals:
for c in v["legal"]:
score[c] += _value(v, theirs, c)
# Ties go to the cheaper card (keep the good ones for later).
hand, trump = v["mine"], v["trump"]
return max(v["legal"], key=lambda c: (score[c], -_cost(c, hand, trump), c))
Hard closes if the same search says closed play wins at least 90% of sampled deals, averaging at least 1 game point. Searching each sample separately (a “determinized” search) can favor a card that beats every sample but not the mix. In 40 games, a hard decision averaged 18 ms, 85 ms at the 95th percentile, and peaked near 250 ms (a close with nine stock cards left).
Measured strength#
tools/ratings/anchors.py, 150 games a pairing (October 2026), first seat alternating:
| Pairing | Share | Elo gap |
|---|---|---|
| Medium over easy | 89.3% | +369 |
| Hard over medium | 80.0% | +241 |
The committed anchors (300 games a pairing) are 88.3% (+352) and 80.0% (+241). Hard’s edge is the largest of any table game on the site, because it alone searches the endgame.
Rating#
A game is two one-player sides, kept and rated like any table game. The anchors use the measured gaps, which are under the 400-point cap:
| Level | Rating |
|---|---|
| easy | 1150 |
| medium | 1500 |
| hard | 1740 |
ratings/tables.py, ANCHORS.sixty-sixWith two seats, each result moves ratings a lot:
| You | Bot | Chance you win | A win | A loss | |
|---|---|---|---|---|---|
| Your first game | New, 1500 ± 350 | Easy (1150) | 71% | +90.9 | −222.6 |
| Medium (1500) | 50% | +156.8 | −156.8 | ||
| Hard (1740) | 35% | +203.5 | −110.1 | ||
| Settled | 1500 ± 70 | Easy | 87% | +3.5 | −22.8 |
| Medium | 50% | +13.2 | −13.2 | ||
| Hard | 22% | +20.6 | −5.7 |
A new player’s σ (the uncertainty in their rating) is wide, so a first game moves them tens to hundreds of points (the scale). A settled player moves about a dozen. Against easy there’s little to win and a lot to lose (a settled player against each bot uses these anchors). A seat a bot covered counts at its player’s rating but isn’t rated. A guest is an unchanging 1500 ± 350. Two signed-in people are both rated, each against the other.
The interface#
The page loads the card room’s table style and cards, the room kit and sixty-six.css (games/templates/games/sixty-six.html). app.js is a short call to the room’s start():
games/static/games/sixty-six/app.js lines 1–39// Sixty-Six: two-handed, as David's family plays it (docs/sixty-six.md),
// against a bot or a friend 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, sixtySixOptions, shareText, progress } from './table.js';
start({
game: 'sixty-six',
title: 'Sixty-Six',
kicker: 'Card game · 2 players',
blurb: 'Race to 66 card points, counted in your head. Marry a king and queen, close the stock when you’re sure, and claim 66 first.',
// Always two at the table: a bot takes the other seat if nobody does.
min: 2,
max: 2,
defaultBots: 1,
fill: true,
rules,
play,
look,
devices: 'desktop',
spectate: true,
firstRun: true,
cues: true,
progress,
shareText,
shortcuts: 'Single keys, shown on the buttons: 1 to 6 play a card, D draws, M declares a marriage, E exchanges, X closes, C claims 66, P says you have no claim, O sorts.',
options: () => sixtySixOptions(),
// The one table setting (tables/sixty_six/rules.py options()): the game is
// 24 cards, to 7, no last-trick bonus, as David's family plays it.
settings: [{
key: 'bot_pace', label: 'Bot speed', value: 'brisk',
hint: 'Relaxed is easier to follow.',
choices: [['brisk', 'Brisk'], ['relaxed', 'Relaxed']],
}],
});
defaultBots: 1 and fill: true seat a bot across a solo player. devices: 'desktop' tells phone users the game is made for a computer first.
The table#
SixtySixTable (games/static/games/sixty-six/table.js) draws a bar of tools, a rail, the table, your hand and the dock.
- The rail shows game points, trump with a line about the stock (“The 9♠ is under the stock. 7 cards to draw”), and each player’s trick count. Never a card point.
- The table has the two plates, the stock in the middle and the trick between. The stock shows at most six card backs with a “7 left” note, the trump card sideways beneath, and turns over when closed. A plate shows the name, a dealer mark, tags (“Closed the stock”, “Away”, “Bot playing”; they and the dealer’s “D” are 12px at every size), and the opponent’s known cards (“Holds K♥ Q♥”).
- The dock has a status line that briefly explains a press that did nothing, five fixed move slots (the room kit’s
Slots, so a button never moves), and Sort:
| Slot | Shows |
|---|---|
| 1, 2 | “Marriage ♥ 20” or “Marriage ♠ 40”, up to two (keys M and N) |
| 3 | “Exchange 9♠” (E) for the holder of the trump 9, before the first card; or “Draw” (D) after you take a trick |
| 4 | “Close” (X; asks “Close the stock?”), or “No claim” (P) after the last card |
| 5 | “Claim 66” (C; asks “Claim 66?”) |
Play a card by tap or with keys 1 to 6. A move you can’t make is hidden, but its slot keeps its place. Claiming asks to confirm because a short claim costs a point.
Your hand and the assists#
The hand is the card room’s Hand (the card room), sorted by suit with trump first and colors alternating (sixtySixSort), or in your own order. Two helpers are on by default, each with an Options row (uo:cards:sixty-six:assist-<key>):
games/static/games/sixty-six/table.js HELPERS · lines 52–57// The helpers, on by default (docs/design-language.md, "Options"), each with
// its own row to turn it off; and Banners, a preference (Playing).
const HELPERS = [
{ key: 'legal', label: 'Light up cards I can play', hint: 'Once the stock’s closed or gone, dims the cards you can’t play.' },
{ key: 'known', label: 'Remember what they’ve shown', hint: 'Cards you know your opponent holds (a marriage, the trump card they took) stay on their seat.' },
];
| Assist | What it computes |
|---|---|
| Light up cards I can play | Nothing. When strict play or a declared marriage limits you, it dims the cards not in the server’s legal. While the stock is open every card is legal, so nothing dims. |
| Remember what they’ve shown | Nothing. It draws the server’s public shown list for your opponent, or doesn’t. |
Nothing counts your points, by design.
Motion and banners#
The room kit’s Cues show play one move at a time, skipping ahead when 14 are queued or 7 s behind. syncTable queues each card played, a trick swept to its winner, draws (yours stays face down until it lands), an exchange, a close (the trump card turns over), and a marriage (its two cards light up). A new hand is dealt from the dealer’s seat. On your turn the room catches up (catchUp(), table motion), keeping a trick’s sweep, a held cue, and the move after it. Banners (“Marriage!” with its 20 or 40, “Ada closed the stock”, “66!”, “Short!”, “No points”, the winner) are a preference, not an assist; like Euchre’s they go through the card room’s queue (Banners), so one from a hand gone by never plays, and the result sheet waits for them. Someone else exchanging the trump nine is a toast once it’s flown (“Ada swapped the 9♥ for the K♥ under the stock.”), since it’s quick and changes the card under the stock; your turn coming doesn’t clear it. The stock’s count under the pile and the scores are the house’s Figure (ADR 0054); on a small phone the count is a tag on the stock no wider than it, the figure over its word, clear of the trump card’s corner.
games/static/games/sixty-six/table.js SixtySixTable./** The table shows the play a move at a time (room/kit/motion.js Cues):
* each card flies from its player to the trick; a finished trick is ringed
* and swept to its winner, who draws, then the other; an exchange and a
* close play out where they happen; only then comes the next lead. A first
* look, a new deal or a new game shows the table as it is (a new deal is
* dealt out). `arrived`: cards new in your hand, kept from view until the
* draw or exchange that brings them lands. */
syncTable(was, arrived) {
const v = this.v;
const now = { deal: v.deal, done: v.done.length, cards: v.trick.map(p => ({ ...p })) };
const copy = x => ({ deal: x.deal, done: x.done, cards: x.cards.map(p => ({ ...p })) });
const stockNow = () => ({ deal: v.deal, left: v.stock_left, card: v.trump_card, closed: !!v.closed });
if (!was || !this.planned || this.planned.deal !== v.deal || v.done.length < this.planned.done) {
const newDeal = !!this.planned && this.planned.deal !== v.deal;
this.cues.skip();
this.landing.clear();
this.held.clear();
this.sweeping = null;
this.shownTrick = copy(now);
this.planned = copy(now);
this.stockShown = stockNow();
this.wedShown = new Set(v.marriages.map(m => `${m.seat}${m.suit}`));
this.lastEvent = v.events.at(-1)?.id ?? 0;
// A new hand is dealt out, from the dealer's seat (not a late arrival's).
const fresh = !v.done.length && !v.trick.length;
if (fresh && (newDeal || (!was && v.hand === 0))) this.cues.add(this.dealCue());
return;
}
const plan = this.planned;
const play = p => { plan.cards.push({ ...p }); this.cues.add(this.playCue(p)); };
const mine = [...arrived];
const fresh = v.events.filter(e => e.id > (this.lastEvent ?? 0));
this.lastEvent = v.events.at(-1)?.id ?? this.lastEvent;
for (const e of fresh) {
if (e.type === 'trick' && e.no > plan.done) {
const t = v.done[e.no - 1];
if (!t) continue;
for (const p of t.cards) if (!plan.cards.some(x => x.seat === p.seat)) play(p);
this.cues.add(this.sweepCue(t, e.no - 1));
plan.done = e.no;
plan.cards = [];
} else if (e.type === 'draw') {
// The winner's card first, then the other's; yours is the one that
// arrived in your hand (or the trump card, if it came to you).
const order = [e.first, 1 - e.first];
const cards = order.map(s => {
if (!this.you(s)) return null;
if (e.trump_card && s !== e.first) return mine.includes(e.trump_card) ? (mine.splice(mine.indexOf(e.trump_card), 1), e.trump_card) : null;
return mine.length ? mine.shift() : null;
});
for (const c of cards) if (c) this.held.add(c);
this.cues.add(this.drawCue(e, order, cards));
} else if (e.type === 'exchange') {
const got = this.you(e.seat) && mine.includes(e.took) ? e.took : null;
if (got) { mine.splice(mine.indexOf(got), 1); this.held.add(got); }
this.cues.add(this.exchangeCue(e, got));
} else if (e.type === 'close') {
this.cues.add(this.closeCue(e));
} else if (e.type === 'marriage') {
this.cues.add(this.marriageCue(e));
}
}
for (const p of now.cards) if (!plan.cards.some(x => x.seat === p.seat)) play(p);
// It's your turn and there's a lot still to show: the room catches up,
// still playing the last move (and the last trick's sweep).
if (this.myTurn()) this.cues.catchUp();
}
Between hands, and the result sheet#
After the last trick the house result sheet opens in hand state. Only here do card points appear, with tricks, marriages (“20, not counted” if its player took no trick), who closed, and the game points with the reason (“Ada wins 2 game points: Bo had under 33”). “Next hand” marks you ready. Once the host is ready, “Start without waiting” appears for them.
On a phone#
It’s desktop-first but works at 390×844. Below 640 px the tools fold into “⋯”, the rail becomes small panels, and the five slots share a row. Touch targets are 44 px under pointer: coarse.
Its look#
Sixty-Six’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 | Euchre’s table turned on its side: your opponent’s plate across the felt, yours at the foot; the stock at the left (backs across the trump card, face up beneath them) and the trick in the middle; the rail with the score to 7, trump and the tricks taken. No card point is ever shown until the hand’s summary: David’s family keeps score in their heads. |
| Emblem and art | Its box on the shelf |
| Voice | Marriage, close the stock, claim 66; “Short of 66”, “Nobody claimed” |
| Payoff | Its banners (“Marriage!”, “Closed!”, “66!”), then the room’s win celebration when your place is first, else best for a rating high, behind the result sheet. |
| The glance | Now: whose turn. The countdown: the stock, a Figure under the pile (“5 left”, “Closed”). The score: the game points, a Figure. Yours: your hand. |
| Exceptions | The card room’s |
Testing#
| What | Proves |
|---|---|
tests/test_sixty_six.py: DealingTests, StockOpenTests, StrictTests |
The deal and who leads; the draw order and the last card; free play, then strict play |
MarriageTests, ExchangeTests, ClosingTests |
20 and 40, shown cards, a marriage waiting for a trick; exchange only before the first card; closing before or after drawing |
ClaimingTests, UnclaimedTests, GameTests |
1, 2, 3; a short claim; a lost hand; no last-trick bonus; first to 7; the deal alternates |
SettingsTests, ViewTests, RefusalTests |
Bot speed is the only setting; what’s hidden and offered; friendly refusals |
BotTests |
Counting from public information; claims at 66; hard never claims short and plays a known endgame perfectly; slip rates within bounds; whole mixed-bot games |
HistoryTests |
A finished game is kept and its page opens |
SixtySixContract |
The secrecy and bot contract of every table game |
check:layout, check:details |
The play screen at every window size, and the result sheet |
Run uv run python manage.py test tests.test_sixty_six (the bot tests take a few seconds). After a look change, run npm run check:layout -- --only sixty-six and npm run check:details -- --only sixty-six (testing).
Changing it#
| To change | Needs |
|---|---|
| A rule or the scoring | rules.py, docs/sixty-six.md (they must agree), rules.js (How to play) and the tests. If a finished game’s places could change, bump the ratings VERSION. |
A bot threshold, SLIP or the search |
bots.py; rerun tools/ratings/anchors.py 300 sixty-six, update ANCHORS, bump VERSION; run BotTests. |
| What a seat sees | view(), scramble() in the contract test, and the table. A new key must not carry a total. |
| A setting | options(), the settings list in app.js, docs/sixty-six.md, and a test. Today there is exactly one, and a test insists on it. |
| The look | sixty-six.css and the table; check:tokens, check:layout, check:details. |
Then reread this chapter against the change and npm run guide:stamp sixty-six.
Known issues#
- The design notes’ slip rates are stale.
docs/sixty-six.mdsays easy slips about one hand in twenty and medium one in sixty; measured, it’s one in 12 and one in 32. The test only bounds easy between 1% and 20% and medium below it, so it never noticed.SLIPor the doc should change, as David prefers. - Hard’s search is determinized (see above). Its anchor (1740, +241 over medium) is measured against bots, not against a person who signals with their leads.
- Hard’s close decision is the slowest, up to a quarter of a second of server time.
- Easy and medium ignore shown cards. Medium’s
_bossand_sure_pointsuse only its hand and what’s been played. Only hard infers, and only once play is strict. - An absent player holds up the table. There’s no
away, so a player who walks away mid-hand holds the table until a bot takes their seat.