Technical guide

Card games

Euchre

A card game for four players in two teams, played with the family’s house rules against friends or bots.

Checked against the code on 2026-10-30. Play Euchre

On this page
  1. At a glance
  2. The rules in code
    1. The engine contract, as Euchre meets it
    2. The cards and the bowers
    3. State
    4. Dealing
    5. Bidding
    6. Play
    7. Scoring
    8. The house rules
  3. Turn flow and timers
  4. Your move
  5. Secrets
  6. Bots
    1. Judging a hand
    2. Bidding
    3. The dealer’s discard
    4. Playing
    5. Measured strength
  7. Rating
  8. The interface
    1. The table
    2. Your hand
    3. Motion
    4. Assists and helpers
    5. Between hands, and the result sheet
    6. The waiting room
    7. On a phone
  9. Its look
  10. Testing
  11. Known limitations and known issues
  12. Changing it

Euchre is a card game for four players in two teams, with partners sitting across from each other. In each trick, everyone plays one card and the highest takes it. Each hand, one team names a trump suit, the strongest suit for that hand, and tries to take at least three of the five tricks. The first team to ten points wins.

docs/euchre.md is David’s rules page, and the engine follows it line for line (“if the two disagree, one of them is a bug”). “The family’s rule” below means a choice recorded there. Euchre is a table game: the table service runs its rules, and cards leave the server only as each seat’s own view. Its screens use the room and card kits, and results are rated with partners as one side.

At a glance#

What Where
Rules: deal, bidding, play, scoring tables/euchre/rules.py
Bots, all three levels tables/euchre/bots.py
The package the service registers tables/euchre/__init__.py
The table: seats, trick, hand, dock, banners, result sheet games/static/games/euchre/table.js
The front door and the host’s settings games/static/games/euchre/app.js
How to play games/static/games/euchre/rules.js
The hand’s sort order (also run under Node) games/static/games/euchre/sort.js
Its look games/static/games/euchre/euchre.css
Its page games/templates/games/euchre.html
Tests tests/test_euchre.py, and the contract in tests/table_contract.py
Design notes: David’s rules and answers docs/euchre.md
Worked examples in this chapter guide/worked/euchre.py, with guide/worked/cards.py

It’s registered in tables/games.py and listed in games/catalog.py with players=(2, 4): two to four people, since bots fill a table’s empty seats. Its rating anchors are in ratings/tables.py. Nothing runs on a clock.

The rules in code#

The engine contract, as Euchre meets it#

Euchre meets the required parts of the contract and these optional ones:

Part Euchre’s
min_players, max_players Both 4. new() refuses anything else.
terms A round is a “hand”; high total wins.
result rounds.result(state, high=True).
round_no Hands scored so far. A waiting player may take a bot’s seat (and its team’s score) between hands.
tick, deadline Do nothing: no timers.
note The shared log.note, so a seat changing hands shows in the log.
away, holds_up, arrange Not defined. A player who walks away holds the table up until their seat goes to a bot (the service).
bots, seating, teams The defaults. The rules keep the teams (seat mod 2), not the service.

The cards and the bowers#

The deck is 24 cards, 9 T J Q K A in four suits; every card string ends in 0. Teams are fixed: seats 0 and 2 against 1 and 3.

tables/euchre/rules.pysuit_of · lines 50–54
def suit_of(card: str, trump) -> str:
    """The suit a card follows: the left bower is trump's."""
    if trump and card[0] == "J" and card[1] == PARTNER[trump]:
        return trump
    return card[1]
tables/euchre/rules.pypower · lines 61–71
def power(card: str, trump, led) -> int:
    """How strong a card is in a trick: any trump beats the suit led, which
    beats everything else (worth nothing). Trump runs 9 10 Q K A, then the
    left bower, then the right."""
    if is_trump(card, trump):
        if card[0] == "J":
            return 106 if card[1] == trump else 105
        return 100 + "9TQKA".index(card[0])
    if card[1] == led:
        return 50 + RANKS.index(card[0])
    return 0

The jack of trump (the right bower) is the top card, and the other jack of its color (the left bower) is next. The left bower comes entirely from suit_of, which reports trump as its suit. So legal() makes it follow trump and power() ranks it as trump. Trump runs 9, 10, Q, K, A, then the left bower at 105 and the right at 106. The led suit runs 50 to 55, and every other card is 0, so a card thrown off can’t win. With clubs trump and a spade led:

suit_of("JS0", "C")  → "C"          (not "S")
power("JS0", "C", "S") → 105        (beats every trump but the right bower)
legal(["JS0", "9S0"], [{"card": "KS0"}], "C") → ["9S0"]
                                    (spades led: the jack isn't a spade, so 9♠ must follow)

State#

The state is one JSON object:

Key Meaning
n, seed, first_dealer, deals Four seats; the table’s seed; the first dealer; deals so far, thrown-in ones included.
rules The table’s settings, cleaned.
dealer, hands, up, kitty, kitty_state The dealer; five cards a seat; the turned card; the three face-down cards; the turned card’s fate (up, picked, down or out).
buried The dealer’s discard.
phase, turn farmer, bid1, bid2, discard, defend, play, then scored between hands and over.
bids This deal’s bids: {seat, bid: "order" \| "call" \| "pass", suit, alone}.
trump, maker, alone, defender, sitting Trump; the caller; the lone caller; a lone defender; seats sitting out.
trick, leader, done, taken The current trick and its leader; finished tricks {leader, cards, winner}; tricks per seat.
scores, totals, ready, outcome A row per hand; running totals; who’s said “Next hand”; the last hand’s result.
farmed Seats already asked about a farmer’s hand this deal.

The code says order, maker, march and buried; the screen says “Call ♥”, “Called ♥”, “took all five” and “discard” (docs/euchre.md, “Bidding” and decision 7; the site’s words are in Words). The moves:

tables/euchre/rules.pyMOVES · lines 387–390
MOVES = {
    "farmer": farmer, "order": order, "call": call, "pass": pass_bid, "discard": discard,
    "defend": defend, "play": play, "ready": ready_move,
}

Dealing#

tables/euchre/rules.py_deal · lines 127–146
def _deal(state: dict) -> None:
    """Five each from a fresh shuffle; the next card is turned up on the
    three left face down (the kitty). The deal passes left every deal, a
    thrown-in one too."""
    dealer = (state["first_dealer"] + state["deals"]) % 4
    stock = deck.shuffled(cards(), f"{state['seed']}:{state['deals']}")
    hands = [[] for _ in range(4)]
    for _ in range(5):
        for k in range(1, 5):
            hands[(dealer + k) % 4].append(stock.pop())
    up = stock.pop()
    state["deals"] += 1
    state.update(
        dealer=dealer, hands=hands, up=up, kitty=stock, kitty_state="up", buried=None,
        phase="bid1", turn=(dealer + 1) % 4, bids=[], trump=None, maker=None, alone=None, defender=None,
        sitting=[], asked=[], trick=[], leader=None, done=[], taken=[0] * 4, farmed=[],
    )
    log.event(state, "deal", dealer=dealer, up=up, deal=state["deals"])
    if _on(state, "farmers"):
        _next_farmer(state)

A deal shuffles from the table’s seed and the deal number ("seed:0", "seed:1", …), so a seed always deals the same game (tables/deck.py) and the tests can replay a hand. Each seat gets five cards, the next card is turned up, and three stay face down as the kitty. The deal passes left every time, thrown-in hands included. The first dealer is the seed as a number, mod 4.

Bidding#

The screen says “call” in both rounds; the code has two moves because the rules differ.

Round one, bid1. From the dealer’s left, each seat may order (make the turned card’s suit trump) or pass. If anyone orders, the dealer picks the card up and, in discard, buries any one of six. Whoever ordered is the maker.

Round two, bid2. If all pass, the card is turned down (kitty_state = "down") and bidding goes round again. A seat may call any other suit, or pass.

Stick the dealer. With this on, the dealer can’t pass in round two:

tables/euchre/rules.pystuck · lines 188–190
def stuck(state: dict, seat: int) -> bool:
    """Stick the dealer: everyone else passed twice, so the dealer must name trump."""
    return state["phase"] == "bid2" and seat == state["dealer"] and _on(state, "stick_dealer")

With it off, the dealer’s pass throws the hand in (thrown_in): the deal passes left and _deal runs again. A thrown-in hand scores nothing and isn’t added to scores, so round_no doesn’t move.

Going alone. order and call carry alone. A lone caller’s partner joins sitting:

tables/euchre/rules.pyorder · lines 212–228
def order(state, seat, action, now):
    """The first round: order the turned card up to the dealer (the dealer
    "picks it up"), making its suit trump."""
    _turn(state, seat, "bid1")
    alone = _alone(state, action) or must_alone(state, seat)
    state["bids"].append({"seat": seat, "bid": "order", "suit": state["up"][1], "alone": alone})
    log.event(state, "bid", seat=seat, bid="order", suit=state["up"][1], alone=alone)
    _make(state, seat, state["up"][1], alone)
    dealer = state["dealer"]
    if alone and seat == (dealer + 2) % 4:
        # The dealer sits out, so the card isn't picked up.
        state["kitty_state"] = "out"
        _defend_or_play(state)
    else:
        state["kitty_state"] = "picked"
        state["hands"][dealer].append(state["up"])
        state["phase"], state["turn"] = "discard", dealer

If the dealer’s partner orders alone, the dealer sits out and nothing is picked up (the card is out). Otherwise the dealer picks up and discards even if someone else goes alone. With the Canadian loner on, the dealer’s partner may order only alone (or pass), and must_alone tells the table to lock the Go alone toggle on.

tables/euchre/rules.pymust_alone · lines 182–185
def must_alone(state: dict, seat: int) -> bool:
    """The Canadian loner: ordering your partner the card, you play alone."""
    return (state["phase"] == "bid1" and _on(state, "canadian") and _on(state, "alone")
            and seat == (state["dealer"] + 2) % 4)

Defending alone. With this on and a lone caller, each defender from the dealer’s left is asked in turn (the defend phase). The first to say yes sits their own partner out:

tables/euchre/rules.pydefend · lines 292–305
def defend(state, seat, action, now):
    _turn(state, seat, "defend")
    alone = action.get("alone") is True
    log.event(state, "defend", seat=seat, alone=alone)
    if alone:
        state["defender"] = seat
        state["sitting"].append((seat + 2) % 4)
        _start_play(state)
        return
    rest = state["asked"][state["asked"].index(seat) + 1:]
    if rest:
        state["turn"] = rest[0]
    else:
        _start_play(state)

David hasn’t said whether a lone defender’s euchre is worth 4 or 2 (docs/euchre.md, “Still open”). The code scores 4.

Farmer’s hand. A farmer’s hand is five 9s and 10s. With this on, _next_farmer runs after the deal and after each answer, giving the farmer phase to the first seat from the dealer’s left not yet asked who holds one. They may swap exactly three cards for the kitty, sight unseen, or keep (cards: []). Swapped cards go into the kitty, so a later farmer may draw them. The log says a seat had one and whether they swapped, never the cards.

tables/euchre/rules.pyfarmers_hand · lines 90–91
def farmers_hand(hand: list[str]) -> bool:
    return len(hand) == 5 and all(c[0] in "9T" for c in hand)

It’s rare: 0.132% for a seat, 0.53% of deals at a table of four.

Play#

The seat left of the dealer leads (or the next one playing). play() takes a card the seat holds and legal() allows. When the trick has 4 − len(sitting) cards, winner() gives it to the highest power, who leads next. After five tricks _score runs.

tables/euchre/rules.pywinner · lines 74–77
def winner(trick: list[dict], trump) -> int:
    """The seat whose card takes the trick."""
    led = suit_of(trick[0]["card"], trump)
    return max(trick, key=lambda p: power(p["card"], trump, led))["seat"]

Scoring#

tables/euchre/rules.py_score · lines 351–375
def _score(state: dict) -> None:
    """Makers who take 3 or 4 tricks score 1; all 5, a march, scores 2, or 4
    alone. Makers held to 2 or fewer are euchred: 2 to the defenders, or 4 to
    a defender who went alone."""
    makers = team(state["maker"])
    got = team_tricks(state)[makers]
    if got >= 3:
        scorer = makers
        how = "march" if got == TRICKS else "made"
        points = (4 if state["alone"] is not None else 2) if got == TRICKS else 1
    else:
        scorer, how = 1 - makers, "euchre"
        points = 4 if state["defender"] is not None else 2
    row = [points if team(s) == scorer else 0 for s in range(4)]
    rounds.score(state, row)
    state["outcome"] = {
        "makers": makers, "maker": state["maker"], "trump": state["trump"], "alone": state["alone"],
        "defender": state["defender"], "tricks": got, "how": how, "team": scorer, "points": points,
    }
    log.event(state, "hand", **state["outcome"])
    state["turn"] = None
    done = max(score(state)) >= TARGET
    if done:
        log.event(state, "won", team=scorer)
    rounds.end(state, last=done)
Callers take Points
3 or 4 tricks 1 to the callers
all 5 2 to the callers
all 5, alone 4 to the callers
3 or 4, alone 1
2 or fewer (euchred) 2 to the defenders
2 or fewer, against a lone defender 4 to the defenders

rounds.score gives both seats of the scoring team the points, so a seat’s total is its team’s and score() is [totals[0], totals[1]]. The first team to 10 wins, checked after every hand. The target is a constant, not a setting. Only one team scores a hand, so both can’t reach it at once, and a team at 9 that takes all five alone ends on 13. rounds.end moves to scored (waiting for “Next hand”) or over.

The house rules#

Settings reach the rules through options(), which accepts only an exact allowed value (moves.choice). The host picks them in the waiting room, starting from a signed-in player’s saved house rules (house rules).

tables/euchre/rules.pyoptions · lines 31–37
def options(raw: dict | None) -> dict:
    """A table's settings, from whatever the host asked for."""
    out = {}
    for key in ("stick_dealer", "alone", "defend_alone", "farmers", "canadian"):
        out[key] = moves.choice(raw, key, TOGGLES, DEFAULTS[key])
    out["bot_pace"] = moves.choice(raw, "bot_pace", tuple(moves.BOT_PACE), "brisk")
    return out
Setting (key) Default Choices What it does
Stick the dealer (stick_dealer) on on, off Off: two rounds of passes throw the hand in.
Going alone (alone) on on, off Off: _alone refuses, and the toggle is hidden.
Defending alone (defend_alone) off on, off On: the defend phase against a lone caller.
Farmer’s hand (farmers) off on, off On: the farmer phase before bidding.
Canadian loner (canadian) off on, off On (with going alone on): the dealer’s partner can order only alone.
Bot speed (bot_pace) brisk brisk, relaxed Multiplies bots’ think time (1 or 1.8).

Nothing else is a setting: no joker (“Best Bower” isn’t in the family’s game), always to ten, fixed scoring.

Turn flow and timers#

Each decision belongs to one seat. deciding() returns (turn, kind, key), where kind is farmer, bid, discard, defend or play. The key names this decision, so a bot’s think time survives other seats’ moves but restarts when the decision changes:

tables/euchre/rules.pydeciding · lines 485–493
def deciding(state: dict) -> list[tuple[int, str, str]]:
    phase, turn, deal = state["phase"], state["turn"], state["deals"]
    if phase == "scored":
        return rounds.ready_deciding(state)
    kinds = {"farmer": "farmer", "bid1": "bid", "bid2": "bid", "discard": "discard", "defend": "defend", "play": "play"}
    if phase not in kinds:
        return []
    step = len(state["bids"]) + 10 * len(state["farmed"]) + 4 * len(state["done"]) + len(state["trick"])
    return [(turn, kinds[phase], f"{phase}{deal}-{step}-{turn}")]

step rises with every bid, farmer answer, card and trick, so each decision gets a fresh key. Between hands, every seat not yet ready is deciding, with kind ready; bots answer at once. The table waits as long as it takes. Bots’ think times (seconds, before the pace multiplier) are bot_think, laid over the engine’s:

DecisionSeconds
bid1.3
discard1.2
defend1
farmer1.2
play0.9
Live from tables/euchre/rules.py, bot_think

fallback() is the always-legal move the service plays if a bot’s choice is refused: pass (or, if stuck, the first other suit), keep a farmer’s hand, don’t defend alone, discard the first card, play the first legal card.

Your move#

The first time each kind of choice falls to you, the room kit’s YourMove (Your move at a table) shows a prompt and rings where to act: Call trump or pass, the dealer’s Discard one card, and with Farmer’s hand on, Swap three cards. Playing a trick needs no prompt.

Secrets#

Hands, the kitty and the dealer’s discard live in the state, and view(state, seat) is the only way they reach a player:

Own seat Other seats Spectator
Hand Its cards, sorted (trump first once named) Only a count per seat (counts) No hands at all
Cards you may play legal, on your turn only Not shown None
What you may do can: the buttons for this phase {} {}
Turned card, its fate Public: up, kitty (up, picked, down, out) Same Same
The kitty’s three Never Never Never
The dealer’s discard (buried) Only the dealer sees it null null
Bids, trump, caller, who’s alone, who sits out Public Public Public
Tricks, taken, scores, outcome Public Public Public
Farmer’s hand That they had one and whether they swapped; never the cards Same Same
The log Public lines and lines addressed to it Public lines and lines addressed to them Public lines only

The dealer’s discard is private twice: in the view’s buried, and in a buried log line with to=[dealer]. The public picked_up line says only that one was discarded.

tests/test_euchre.pyEuchreContract · lines 446–464
class EuchreContract(TableGameContract, SimpleTestCase):
    """Euchre meets what every table game must (tests/table_contract.py)."""

    game = game

    def scramble(self, state, keep, rng):
        # Other hands (a lone maker's partner's too), the kitty's face-down
        # cards and, unless `keep` dealt, the card the dealer buried: deal
        # them out again, each pile its own size.
        others = [s for s in range(4) if s != keep]
        secret_buried = state["buried"] is not None and keep != state["dealer"]
        pool = [c for s in others for c in state["hands"][s]] + state["kitty"] + ([state["buried"]] if secret_buried else [])
        rng.shuffle(pool)
        for s in others:
            size = len(state["hands"][s])
            state["hands"][s], pool = pool[:size], pool[size:]
        state["kitty"], pool = pool[:len(state["kitty"])], pool[len(state["kitty"]):]
        if secret_buried:
            state["buried"] = pool.pop()

The contract test’s scramble re-deals every secret a seat mustn’t see (others’ hands, the kitty, and the buried card unless the seat dealt). The seat’s view and every bot level’s move must come out the same (the contract tests). Since bots.choose starts by calling view(), this also proves bots see nothing more.

Bots#

A bot decides from rules.view(state, seat) alone:

tables/euchre/bots.pychoose · lines 26–41
def choose(state: dict, seat: int, level: str, rng) -> dict:
    v = rules.view(state, seat)
    can = v["can"]
    if v["phase"] == "scored":
        return {"type": "ready"}
    if "farmer" in can:
        return _farmer(v)
    if "order" in can:
        return _bid_first(v, level, rng)
    if "call" in can:
        return _bid_second(v, level, rng)
    if "discard" in can:
        return {"type": "discard", "card": _bury(v["mine"], v["trump"])}
    if "defend" in can:
        return {"type": "defend", "alone": level != "easy" and _sure_tricks(v["mine"], v["trump"], v) >= 4}
    return {"type": "play", "card": _play(v, level, rng)}

The three levels share a hand evaluator and differ in how much they count.

Judging a hand#

tables/euchre/bots.py_strength · lines 46–66
def _strength(hand: list[str], trump: str) -> float:
    """About how many tricks this hand takes with `trump` as trump, before
    the partner's help: a bower or the ace is nearly sure, small trump less
    so, and an off-suit ace usually wins once. A void helps when there's
    trump to ruff with."""
    total = 0.0
    trumps = [c for c in hand if suit_of(c, trump) == trump]
    for c in trumps:
        total += {106: 1.0, 105: 0.85, 104: 0.7, 103: 0.55, 102: 0.45, 101: 0.4, 100: 0.35}[power(c, trump, trump)]
    total += 0.3 * max(0, len(trumps) - 2)
    for s in SUITS:
        if s == trump:
            continue
        mine = [c for c in hand if suit_of(c, trump) == s]
        if any(c[0] == "A" for c in mine):
            total += 0.7 if len(mine) <= 2 else 0.5
            if any(c[0] == "K" for c in mine):
                total += 0.25
        elif not mine and len(trumps) >= 2:
            total += 0.25
    return total

_strength estimates the tricks a hand takes with a given trump, before the partner’s help. Each trump is worth part of a trick: right bower 1.0, left 0.85, ace 0.7, king 0.55, queen 0.45, 10 0.4, 9 0.35. It adds a third for each trump beyond two, 0.7 for an off-suit ace in a short suit (0.5 in a long one), a quarter for ace-king together, and a quarter for a void (no cards in a suit) with at least two trump. _sure_tricks is stricter: the top trumps held in an unbroken run from the best still out, then its other trump once those have drawn everyone’s, plus off-suit aces.

tables/euchre/bots.py_sure_tricks · lines 69–86
def _sure_tricks(hand: list[str], trump: str, v=None) -> int:
    """Tricks this hand takes alone against anything: the top trumps held in
    an unbroken line from the best one still out, then the rest of its trump
    once those have drawn the others', then off-suit aces if trump is drawn."""
    gone = _gone(v, trump) if v else set()
    trumps = sorted((c for c in hand if suit_of(c, trump) == trump), key=lambda c: -power(c, trump, trump))
    line = 0
    for code in TRUMP_ORDER:
        card = _trump_card(code, trump)
        if card in hand:
            line += 1
        elif card not in gone:
            break
    sure = line
    if line >= 2 and len(trumps) >= 3:
        sure = len(trumps)
        sure += sum(1 for c in hand if suit_of(c, trump) != trump and c[0] == "A")
    return sure

Bidding#

Easy Medium Hard
Value used Trump cards in hand × 0.75, ± 0.3 at random _strength, with the turned card’s gift to the dealer’s side The same
Calls at 2.6 2.25 2.15
Goes alone Never (but see the Canadian loner, below) _sure_tricks ≥ 5 ≥ 4 when _strength ≥ 4.2, else ≥ 5
Defends alone Never _sure_tricks ≥ 4 The same

In round one the turned card goes to the dealer, so a non-dealer adds 0.35 (0.6 for a jack) when the dealer is its partner and subtracts it when not. The dealer judges the five cards it would keep (_best_after_pickup). In round two a bot calls its best allowed suit; just left of the dealer, it adds 0.2 (PARTNER) to the other suit of the turned-down color: the dealer passed on that color, so its bowers may be on the bot’s side. A stuck dealer calls its best suit even when weak. A seat forced alone by the Canadian loner orders only at the lone threshold, at every level.

The dealer’s discard#

tables/euchre/bots.py_bury · lines 115–127
def _bury(hand: list[str], trump) -> str:
    """The card the dealer buries: never trump if there's a choice; a lone
    low card in a suit (leaving a void to ruff), else the lowest off-suit
    card, keeping aces."""
    off = [c for c in hand if suit_of(c, trump) != trump]
    if not off:
        return min(hand, key=lambda c: power(c, trump, trump))
    by_suit = {}
    for c in off:
        by_suit.setdefault(c[1], []).append(c)
    singles = [cs[0] for cs in by_suit.values() if len(cs) == 1 and cs[0][0] != "A"]
    pool = singles or [c for c in off if c[0] != "A"] or off
    return min(pool, key=lambda c: (power(c, trump, c[1]), c))

Never trump if there’s a choice; else a lone card, to make a void; else the lowest off-suit card that isn’t an ace. All levels bury the same way.

Playing#

Easy Medium Hard
Leading A random legal card If its side called: the top trump if none higher is unseen, else (caller with two trump) a small one. Then a “sure” off-suit winner, an ace, or a low card from the shortest suit The same, counting every card played; leads a suit its partner is out of, so the partner can trump it
Following Cheapest winning card, else its “lowest” (see the known issues) Never trumps a partner’s trick, and lets a partner’s winner stand when safe (last to play, or unbeatable). Otherwise wins as cheaply as it surely can, or plays high; out of the suit, trumps with the cheapest winner. Throwing off, it keeps aces and trump and sheds from the shortest suit Also lets a partner’s card stand when everyone still to play is out of the led suit and trump
What it knows The trick Its hand and the trick Every card gone, and who is out of which suit

“Sure” means nothing still out in the suit is higher (_boss). Medium sees its hand and the trick; hard subtracts every card gone:

tables/euchre/bots.py_boss · lines 229–237
def _boss(v: dict, card: str, level: str) -> bool:
    """Whether nothing still out beats this card in its own suit. Medium
    knows only the cards in its hand and the current trick; hard counts
    everything played."""
    trump = v["trump"]
    s = suit_of(card, trump)
    seen = set(v["mine"]) | ({p["card"] for p in v["trick"]} if level == "medium" else _gone(v, trump))
    mine = power(card, trump, s)
    return not any(power(c, trump, s) > mine and suit_of(c, trump) == s and c not in seen for c in rules.cards())

Hard’s extra knowledge is _gone (cards played, turned down or buried), _void (who has failed to follow which suit) and _safe. There’s no search, no simulation and no memory between hands, which limits how much better hard can be.

Measured strength#

tools/ratings/anchors.py plays bots two against two, partners matched, swapping seats every other game, and counts each head-to-head between opposing seats. At 150 games a pairing (October 2026):

Pairing Share Elo gap
Medium over easy 95.3% +524
Hard over medium 51.3% +9

The committed anchors come from 300 games a pairing (95.0%, +512 and 53.0%, +21). Euchre’s luck hides most of hard’s counting.

Rating#

A game is rated like any table game: kept if it ended by the rules with someone signed in, rated once, ranked by places. Two things are Euchre’s own.

Partners are one side. teams_of("euchre", …) is seat % 2, so a game is two sides of two (k = 2: one game, not four). A side’s skill is its members’ summed, and each member moves by their share of the side’s variance (teams). Partners share a place (1 and 1, then 3 and 3).

Bots are the anchors, each a fixed, certain rating:

LevelRating
easy1100
medium1500
hard1520
Live from ratings/tables.py, ANCHORS.euchre

Easy at 1100 is the measured gap (over 500) capped at 400 below medium. Medium is 1500 by definition. Hard at 1520 is the measured 21 points. Always beating the easy bot pays only while it’s news (bots as a farm).

Worked example. A settled 1500 ± 70 player, partnered with a medium bot, against two bots of each level:

Opponents Their rating Your side’s chance A win A loss
Easy 1100 99% +0.4 −26.0
Medium 1500 50% +13.2 −13.2
Hard 1520 45% +14.6 −11.8

The easy pair’s summed skill is 800 under your side’s, so beating them is worth almost nothing and losing costs a lot. Medium or hard is a coin flip, about a dozen points either way. A new player moves much further.

When it isn’t rated. A game the host ended early isn’t kept. A seat a bot covered at any point counts at the player’s rating but isn’t rated. In the same win with a friend (also 1500 ± 70) as partner and the first player covered, 1 player is rated, the friend, who moves +12.3. Guests count as 1500 ± 350 and never change.

The interface#

The page (games/templates/games/euchre.html) extends the table template. app.js starts the room:

games/static/games/euchre/app.jslines 1–63
// Euchre: the family rules (docs/euchre.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, euchreOptions, partnersNote, shareText, progress } from './table.js';

const onOff = [['on', 'On'], ['off', 'Off']];

let room;
room = start({
  game: 'euchre',
  title: 'Euchre',
  kicker: 'Card game · 4 players',
  blurb: 'Four players, two teams, and one jack that isn’t what it looks like.',
  // Always four at the table: bots fill the seats nobody takes, and the
  // partner across from you is whoever sits there.
  min: 4,
  max: 4,
  defaultBots: 3,
  fill: true,
  rules,
  play,
  look,
  devices: 'desktop',
  spectate: true,
  firstRun: true,
  cues: true,
  progress,
  shareText,
  neighbors: partnersNote(() => room),
  shortcuts: 'Single keys, shown on the buttons: the suit’s letter to call it, A to go alone, P to pass, 1 to 6 to play a card, O to sort.',
  options: () => euchreOptions(),
  // Every engine setting (tables/euchre/rules.py options()), in plain words.
  settings: [{
    key: 'stick_dealer', label: 'Stick the dealer', value: 'on',
    hint: 'The dealer must call trump.',
    choices: onOff,
  }, {
    key: 'alone', label: 'Going alone', value: 'on',
    hint: 'Play without a partner.',
    choices: onOff,
  }, {
    key: 'defend_alone', label: 'Defending alone', value: 'off',
    hint: 'Defenders may go alone too.',
    choices: onOff,
  }, {
    key: 'farmers', label: 'Farmer’s hand', value: 'off',
    hint: '9s and 10s only: swap three.',
    choices: onOff,
  }, {
    key: 'canadian', label: 'Canadian loner', value: 'off',
    hint: 'Dealer’s partner goes alone.',
    choices: onOff,
  }, {
    key: 'bot_pace', label: 'Bot speed', value: 'brisk',
    hint: 'Relaxed is easier to follow.',
    choices: [['brisk', 'Brisk'], ['relaxed', 'Relaxed']],
  }],
});

devices: 'desktop' warns a phone that Euchre is “made for a computer first”, spectate: true lets spectators watch, fill: true and defaultBots: 3 fill a solo table with bots, and neighbors draws the partners panel.

The table#

EuchreTable (games/static/games/euchre/table.js) draws a bar, the rail and table, your hand, and the dock.

  • The rail shows both teams’ scores, trump (who called it, and “alone”), tricks as five pips a side with the count beside them, and the turned card’s fate. The counts and the scores are the house’s Figure (ADR 0054): the tricks decide the hand, so they’re as heavy as the score, and “We need 3” is in ink. On a phone the pips give way to the counts.
  • The table has four plates (name, dealer mark, tags like Called ♥ and Sitting out, a card count; the tags and the dealer’s “D” 12px at every size), you at the bottom, with the trick between. While bidding, each seat shows its bid in place of a card.
  • The dock has a status line (a press that does nothing says why), four fixed move slots (slots), the Go alone toggle and the sort. Round one puts “Call ♥” in slot three and Pass in four; round two puts the other suits in the first three. A stuck dealer’s Pass reads “Can’t pass”, disabled.

draw() and its parts redraw only what changed since last time (changed(part, key)), so a snapshot that changes nothing visible redraws nothing and focus stays put.

Your hand#

The hand is the card room’s Hand (the card room). Euchre’s sorter groups suits with trump first (the left bower included, bowers high) and the rest alternating colors; before trump, S H C D. The server’s tidy() sorts view().mine high to low, but the hand shows sort.js’s low-to-high order. “My order” is a sort you drag, kept in this browser (uo:euchre:hand-sort, and per table under uo:euchre:order:<code>). Tapping plays a card, or selects one (or three) when discarding or swapping. Keys 1 to 6 pick by position.

Motion#

Cards move with the room kit’s table motion, and the table lags the server by that motion only:

games/static/games/euchre/table.jsEuchreTable.syncTrick · lines 600–641
/** 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; 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). It's your turn to follow and the card led is still to come? The
 * room catches up (Cues.catchUp): older cards land at once, but the last
 * trick's sweep and the lead still play, so a lead is never kept from you
 * and never just appears. */
syncTrick(was) {
  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 })) });
  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.sweeping = null;
    this.shownTrick = copy(now);
    this.planned = copy(now);
    // A new hand is dealt out, from the dealer's seat (not a late arrival's).
    const fresh = !v.bids.length && !v.done.length && !v.trick.length && (v.phase === 'bid1' || v.phase === 'farmer');
    if (fresh && (newDeal || (!was && v.hand === 0))) this.cues.add(this.dealCue());
    return;
  }
  // The dealer picks up the card turned up (a call in the first round): it
  // flies from the middle to the dealer's seat, and the discard follows.
  if (was.kitty === 'up' && v.kitty === 'picked' && v.phase === 'discard' && !still()) {
    this.lifting = true;
    this.cues.add(this.pickupCue());
  }
  const plan = this.planned;
  const play = p => { plan.cards.push({ ...p }); this.cues.add(this.playCue(p)); };
  while (plan.done < now.done) {
    const t = v.done[plan.done];
    for (const p of t.cards) if (!plan.cards.some(x => x.seat === p.seat)) play(p);
    this.cues.add(this.sweepCue(t, plan.done));
    plan.done += 1;
    plan.cards = [];
  }
  for (const p of now.cards) if (!plan.cards.some(x => x.seat === p.seat)) play(p);
  if (this.myTurn() && v.phase === 'play' && v.trick.length) this.cues.catchUp();
}

syncTrick compares the trick on screen with the server’s and queues the difference as Cues: each card flies from its player, a finished trick is ringed and swept to its winner, a deal comes from the dealer, and in round one the turned card flies to the dealer.

Euchre’s Cues skip to the end when twelve are waiting (the kit’s default is six) or the queue is more than the kit’s six seconds behind. A press skips a deal or pickup. When your turn to follow comes while cards are still flying, the room catches up (catchUp(), table motion): older cards land at once, but a finished trick’s sweep is a held cue, so it and the lead after it still play. A first look, a new game or reduced motion shows the table as it is. The result sheet waits for the cues and then the banners, plus 700 ms (900 at game end), so the last trick is seen. Your turn coming clears no toasts.

Banners show for two seconds for every call (“Ada called ♥”, with “They need three tricks” or “We need three tricks”; a second-round call goes straight to play, so no bid bubble ever shows it), “Going alone!” (with the suit), “Defending alone!”, “Euchred!”, “All five, alone!” and the winner; a call lights up the trump panel too. They go through the card room’s queue (games/static/games/cards/kit/banners.js): each is checked as its turn comes, so one from a hand gone by (a backlog from a tab in the background, say) never plays over the next hand, and the result sheet drops any still waiting when it opens. The first time you may defend alone, a Your move card says so. They’re a preference (Options, Playing), not an assist.

Assists and helpers#

All three are on by default, each with a row in Options (uo:cards:euchre:assist-<key>), by David’s standing rule (Options):

games/static/games/euchre/table.jsHELPERS · lines 52–59
// The helpers, on by default (docs/design-language.md, "Options"), each with
// its own row to turn it off; and Banners, a preference (Playing). Kept
// where they always were.
const HELPERS = [
  { key: 'legal', label: 'Light up cards I can play', hint: 'Dims the cards you can’t play and lifts the ones you can.' },
  { key: 'partner', label: 'Say when my partner’s winning', hint: 'A quiet note, and a ring round their card, while they’re taking the trick.' },
  { key: 'bower', label: 'Mark the left bower', hint: 'A small tag on it once trump is called, since it counts as trump.' },
];
Assist What it computes
Light up cards I can play On your turn, lifts the cards in view().legal and dims the rest. Tapping a dimmed card says “Follow hearts: you have one.” Off, the tap goes to the server, which refuses it.
Say when my partner’s winning leading(trick, trump), a client copy of the server’s power, finds who’s winning. If it’s your partner and the trick isn’t done, their card gets a ring and a quiet note.
Mark the left bower Once trump is named, a tag on the left bower in your hand and the trick, with a spoken label (isLeft).

leading and isLeft are the only rules in the client, and only for pointing things out. The server decides what’s legal and who wins.

Between hands, and the result sheet#

After a hand the table is scored and the house’s result sheet opens in hand state: the call, tricks per side, points, the score and every hand so far. “Next hand” says you’re ready; bots always are. Once the host is ready, they also get “Start without waiting”, after a short delay so a double click can’t deal over someone still reading. “See the table” closes the sheet.

At game over it shows the winners, the final score, Share result, the rating line (the snapshot’s rating), and Play again for the host. Share result says it from your side, “We won 10–9 🏆”, then a square a hand (🟩 your side scored, 🟥 theirs) and who beat whom.

The waiting room#

partnersNote shows the teams. People sit side by side by default, so two friends are opponents, each with a bot partner. Once the table is full, the host can switch two people between “Against each other” and “As partners”, which reorders the seats.

On a phone#

Euchre is desktop-first but works at 390×844. Below 640 px the bar’s tools fold into “⋯”, the rail becomes three small panels, and side seats, call buttons and tags shrink to marks. On a short phone (iPhone SE) cards shrink to 40 px. Touch targets are 44 px under pointer: coarse. The layout and details checks measure all of this (testing).

Its look#

Euchre’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 Four plates round a trick, you at the foot and your partner across. A plate is a mark, the name, the cards held as small backs, and tags (Dealer, Called ♥, Alone), and never changes size. The rail at the left holds the score to ten, trump, the tricks as pips and what became of the up card. The trick is a cross in the middle.
Emblem and art Its box on the shelf
Voice “Call ♥” and Pass in both rounds; “Euchred!”, “Going alone!”, “All five, alone!”
Payoff Its banners, then the room’s win celebration when your place is first, else best for a rating high, behind the result sheet.
The glance Now: the plate’s ring, the status line and a banner for every call. The countdown: the tricks this hand, a Figure a side, with “We need 3”. The score: the score to ten, a Figure. Yours: your hand.
Exceptions The card room’s (the dealer’s chip, a fanned hand, a seat’s name, the dock’s line)

Testing#

What Proves
tests/test_euchre.py: DealingTests, BowerTests The deal, the bowers, following suit.
BiddingTests, GoingAloneTests, FarmersHandTests Every bidding path, each setting on and off, the private discard, thrown-in hands, farmer swaps.
ScoringTests Every score in the table, ten winning, the deal moving left.
ViewTests, SettingsTests What each seat and a spectator sees; the defaults; bad settings falling back.
BotTests Single bot decisions, and forty whole games across settings and levels.
EuchreContract Every table game’s secrecy and bot contract.
tools/room/hand.test.mjs The hand’s sorts under Node.
check:layout, check:details The play screen at every window size, and the result sheet.

Run uv run python manage.py test tests.test_euchre. After a look change, also run npm run check:layout -- --only euchre and npm run check:details -- --only euchre (testing).

Known limitations and known issues#

  • Easy throws the wrong “lowest” card. _lowest ranks off-suit cards by a power that’s 0 for all, so ties break on the card string (9s, then aces, jacks, kings, queens, 10s). Easy throws an ace before a queen, against its docstring.
  • “Sure” winners aren’t sure. _boss ignores trumping, so medium and hard will lead an off-suit ace into a player who’s out of the suit and holds trump.
  • Hand numbers disagree after a throw-in. With stick the dealer off, the bar and score sheet call the redeal “Hand 1”; the log, which numbers deals, says “Hand 2”.
  • Lone-defender euchre is 4. David hasn’t confirmed it (docs/euchre.md), and How to play’s scoring table leaves it out.
  • No away. A player who leaves mid-hand holds up the table until a bot covers them (the host’s choice, or anyone’s after the service’s idle time).
  • Hard barely beats medium (51 to 53 percent), so a hard bot rated 1520 is mostly a name.
  • Teams aren’t the service’s. Seat mod 2 is built into the rules, the ratings (teams_of) and the client (teamOf). Changing partnerships touches all three.

Changing it#

To change Needs
A rule (scoring, a phase) rules.py, docs/euchre.md (they must agree), rules.js (How to play) and the tests. If a finished game’s places would change, bump the ratings VERSION.
A setting DEFAULTS and options() in rules.py, the settings list in app.js (keys and choices must match), docs/euchre.md, and tests for the default and the on state.
A bot threshold bots.py; rerun tools/ratings/anchors.py 300 euchre, update ANCHORS, bump VERSION.
What a seat sees view(), scramble() in the contract test, and the table. Anything private must carry to.
The look euchre.css and the table; npm run check:tokens, check:layout, check:details.
Words on the table docs/euchre.md (“Bidding”) and Words first; the code’s names (order, maker) don’t change.

Then reread this chapter against the change and npm run guide:stamp euchre.