Technical guide

Card games

Michigan Rummy

A rummy game for two to eight where you can take any card from the discard pile, and every card you lay down scores.

Checked against the code on 2026-10-30. Play Michigan Rummy

On this page
  1. At a glance
  2. The rules in code
    1. What the engine provides
    2. Players, decks and the deal
    3. State
    4. A turn
    5. Melds
    6. Points
    7. The planner
    8. The settings
    9. The safety nets
  3. Turn flow and timers
  4. Your move
  5. Secrets
  6. Bots
    1. Weighing a hand
    2. How each level plays
    3. Measured strength
  7. Rating
  8. The interface
    1. The table
    2. Taking from the pile
    3. Melding
    4. Discarding
    5. Assists and helpers
    6. The hand intro
    7. Motion
    8. Between hands, and the result sheet
    9. On a phone
  9. Its look
  10. Testing
  11. Known limitations and known issues
  12. Changing it

Michigan Rummy is the family’s name for 500 Rum. Players lay down sets and runs of cards, and every card you lay down scores for you, on your own melds or anyone’s. The discard pile is spread out so every card shows, and you may take any card from it as long as you take everything above it too.

docs/michigan-rummy.md is David’s rules page, and the engine follows it (“if the two disagree, one of them is a bug”). The choices there are his, so changing a rule means asking him. It’s a table game for two to eight: the service runs its rules as pure functions. One planner (tables/michigan_rummy/plan.py) answers what a card or hand could meld, for both the interface and the bots. Its screens use the room and card kits, and results are rated as a field.

At a glance#

What Where
Rules: deal, draw, take, meld, discard, going out, scoring tables/michigan_rummy/rules.py
Sets, runs, aces, jokers, laying off, card values tables/michigan_rummy/melds.py
The planner: can this card be melded, what is the most a hand can meld tables/michigan_rummy/plan.py
Bots, three levels tables/michigan_rummy/bots.py
The package the service registers tables/michigan_rummy/__init__.py
The table: seats, pile, hand, picking and melding, sheets games/static/games/michigan-rummy/table.js
The client’s copy of the meld rules games/static/games/michigan-rummy/melds.js
The front door and the settings games/static/games/michigan-rummy/app.js
How to play games/static/games/michigan-rummy/rules.js
Its look games/static/games/michigan-rummy/michigan-rummy.css
Its page games/templates/games/michigan-rummy.html
Tests tests/test_michigan_rummy.py, tools/room/michigan-rummy.test.mjs, and the contract in tests/table_contract.py
Design notes: David’s family rules docs/michigan-rummy.md
Worked examples in this chapter guide/worked/michigan_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. Nothing runs on a clock.

The rules in code#

What the engine provides#

Part Michigan Rummy’s
min_players, max_players 2 and 8.
options Going out with a final discard, the target, and bot speed.
terms A round is a “hand”; high total wins.
bot_think Draw 1.2 s, play 1 s, before the pace multiplier. A bot’s meld is several moves, and each waits.
result, round_no, note rounds.result(high=True): places by total, ties shared; hands scored so far; the log’s seat news.
tick, deadline No timers.
away, holds_up, arrange Not defined: a player who walks away holds the table until a bot takes their seat.

Players, decks and the deal#

From five players there are two decks, so the stock never starts thin. Jokers are always in, two a deck.

tables/michigan_rummy/rules.pydecks_for · lines 42–43
def decks_for(players: int) -> int:
    return 1 if players <= 4 else 2
tables/michigan_rummy/rules.pydealt · lines 46–47
def dealt(players: int) -> int:
    return 13 if players == 2 else 7
Players Decks Jokers Cards each
2 1 2 13
3–4 1 2 7
5–8 2 4 7
tables/michigan_rummy/rules.py_deal · lines 72–90
def _deal(state: dict, now: float = 0.0) -> None:
    """A fresh shuffle, dealt one at a time from the dealer's left; the next
    card turned up starts the discard pile. The deal passes left each hand."""
    n = state["n"]
    dealer = (state["first_dealer"] + state["deals"]) % n
    decks = decks_for(n)
    cards = deck.build(decks, 2 * decks)  # jokers are always in: two per deck
    stock = deck.shuffled(cards, f"{state['seed']}:{state['deals']}")
    hands = [[] for _ in range(n)]
    for _ in range(dealt(n)):
        for k in range(1, n + 1):
            hands[(dealer + k) % n].append(stock.pop())
    state["deals"] += 1
    state.update(
        dealer=dealer, hands=hands, stock=stock, pile=[stock.pop()], melds=[], next_id=0,
        phase="draw", turn=(dealer + 1) % n, must=None, took=None, melded=[0] * n,
        turns=0, moves=0, reshuffles=0, stalled=0, outcome=None,
    )
    log.event(state, "deal", dealer=dealer, deal=state["deals"], hand=len(state["scores"]), up=state["pile"][0])

The deal shuffles from the seed and deal number, deals from the dealer’s left, and turns the next card up to start the pile. The player left of the dealer goes first, and the deal passes left each hand. A card is a string like QH1 (queen of hearts, deck 1), so with two decks every card is distinct and a set may hold both queens.

State#

Key Meaning
hands, stock, pile Each seat’s cards; the face-down stock (drawn from the end); the public discard pile, oldest first.
melds, next_id Every meld on the table, {id, kind, cards, by, owner, …}. A run also has suit and lo, a set rank. by[i] is who played card i.
phase, turn draw, then play; scored and over between and after hands.
must, took The card dug for, which must be melded first; who took what this turn.
melded Points each seat has melded this hand.
stalled, reshuffles, turns, moves The safety nets’ counters; turns this hand; every move (for bots’ wait keys).
scores, totals, ready, outcome, winners A row per hand (melded less held); running totals; who’s ready; how the last hand ended, with everyone’s remaining cards; who has won.

A turn#

A turn is a draw, then any melds, then a discard or going out. The moves are draw, take, meld, discard and ready.

Draw. Take the top of the stock. If the stock is out, the pile under its top card is shuffled into a new stock (seeded from the seed, deal and reshuffle count, so a replay matches). If the pile is only its top card, the seat must take it:

tables/michigan_rummy/rules.pydraw · lines 102–117
def draw(state, seat, action, now):
    """The top card of the stock. With the stock out, the discard pile (but
    its top card) is shuffled into a new stock first and play goes on."""
    _turn(state, seat, "draw")
    if not state["stock"]:
        if len(state["pile"]) <= 1:
            raise Illegal("The stock is out and there’s nothing to shuffle: take the top card.")
        state["reshuffles"] += 1
        state["stock"] = deck.shuffled(state["pile"][:-1], f"{state['seed']}:{state['deals']}:r{state['reshuffles']}")
        state["pile"] = state["pile"][-1:]
        log.event(state, "reshuffle", count=len(state["stock"]))
    card = state["stock"].pop()
    state["hands"][seat].append(card)
    log.event(state, "draw", seat=seat)
    log.event(state, "drew", to=[seat], seat=seat, card=card)
    state["phase"] = "play"

Take. Any card in the pile, with every card above it. The top card alone works like a draw: you needn’t meld it. A deeper card must be melded at once, and the take is refused if it couldn’t be:

tables/michigan_rummy/rules.pytake · lines 120–140
def take(state, seat, action, now):
    """A card from the discard pile, with every card above it. The top card
    alone works like a draw: it's yours and you needn't meld it. A card from
    further down must be melded straight away (it, the deepest card taken,
    goes down first), so you can't dig for one you couldn't meld."""
    _turn(state, seat, "draw")
    at = action.get("at")
    pile = state["pile"]
    if not isinstance(at, int) or isinstance(at, bool) or not 0 <= at < len(pile):
        raise Illegal("Choose a card in the discard pile.")
    card = pile[at]
    taken = pile[at:]
    deep = at < len(pile) - 1
    if deep and P.can_meld(card, state["hands"][seat] + taken[1:], state["melds"], _spare(state)) is None:
        why = " and keep a card to discard" if _spare(state) else ""
        raise Illegal(f"You can’t meld the {deck.name(card)}{why}, so you can’t dig down to it.")
    state["pile"] = pile[:at]
    state["hands"][seat] += taken
    state["must"], state["took"] = (card if deep else None), {"seat": seat, "cards": list(taken)}
    log.event(state, "take", seat=seat, card=card, cards=list(taken))
    state["phase"] = "play"

plan.can_meld checks the card against the hand plus every card taken with it. The cards above are yours to keep, and you may even discard one that turn. must holds the deepest card: meld refuses any move that leaves it out, and discard refuses until it’s down.

Meld. One move can make any number of melds and play cards onto any melds, in order (the 8♥ before the 9♥ on a 5-6-7♥ run). The plays apply to a copy, so a move refused part-way changes nothing:

tables/michigan_rummy/rules.pymeld · lines 143–200
def meld(state, seat, action, now):
    """New melds and cards played on melds, all at once: `melds` is a list of
    {"cards", "kind"?, "low_jokers"?} (`low_jokers`: how many of a run's
    spare jokers stand below its lowest card, the rest above: the player's
    choice, melds.run_choices); `plays` a list of {"card", "meld",
    "end"?}, in order, each on a meld on the table (its id) or on one of
    this move's new melds ("n0", "n1"…)."""
    _turn(state, seat, "play")
    new_melds = action.get("melds") or []
    plays = action.get("plays") or []
    if not isinstance(new_melds, list) or not isinstance(plays, list) or not (new_melds or plays):
        raise Illegal("Meld some cards, or play them on a meld.")
    hand = state["hands"][seat]
    used = [c for m in new_melds for c in m["cards"]] + [p["card"] for p in plays]
    if len(set(used)) != len(used) or any(not isinstance(c, str) or c not in hand for c in used):
        raise Illegal("Those cards aren’t all in your hand.")
    if state["must"] and state["must"] not in used:
        raise Illegal(f"Meld the {deck.name(state['must'])} first: you took it from the discard pile.")
    if _spare(state) and len(used) >= len(hand):
        raise Illegal("Keep a card to discard: at this table you go out with a discard.")
    table = {m["id"]: m for m in state["melds"]}
    made, ids, points = [], {}, 0
    for k, spec in enumerate(new_melds):
        m = M.make(list(spec["cards"]), spec.get("kind") if spec.get("kind") in ("set", "run") else None,
                   spec.get("low_jokers", 0))
        m.update(id=state["next_id"] + k, owner=seat, by=[seat] * len(m["cards"]))
        made.append(m)
        ids[f"n{k}"] = m["id"]
        table[m["id"]] = m
    # (Plays land on copies, so a move refused part-way changes nothing.)
    touched = {}
    lines = []
    for p in plays:
        target = ids.get(p["meld"], p["meld"])
        if target not in table:
            raise Illegal("That meld isn’t on the table.")
        if target not in touched and target not in ids.values():
            touched[target] = {**table[target], "cards": list(table[target]["cards"]), "by": list(table[target]["by"])}
            table[target] = touched[target]
        m = table[target]
        where = M.lay_off(m, p["card"], p.get("end"))
        m["by"][0 if where == "low" else -1] = seat
        lines.append({"card": p["card"], "meld": target, "owner": m["owner"], "kind": m["kind"]})
    for m in table.values():
        for i, by in enumerate(m["by"]):
            if by == seat and m["cards"][i] in used:
                points += M.worth(m, i)
    state["melds"] = [touched.get(m["id"], m) for m in state["melds"]] + made
    state["next_id"] += len(made)
    for c in used:
        hand.remove(c)
    state["melded"][seat] += points
    state["stalled"] = 0
    if state["must"] in used:
        state["must"] = None
    log.event(state, "meld", seat=seat, melds=[_public(m) for m in made], plays=lines, points=points)
    if not hand:
        _end_hand(state, seat)

Every card scores for whoever puts it down, wherever it lands (by). With “go out with a discard” on, a move may not use up the whole hand (_spare). An empty hand after melding ends the hand.

Discard. One card goes on the pile and ends the turn. If it was the last card, the hand ends. Then the safety nets are checked:

tables/michigan_rummy/rules.pydiscard · lines 203–223
def discard(state, seat, action, now):
    _turn(state, seat, "play")
    card = action.get("card")
    hand = state["hands"][seat]
    if not isinstance(card, str) or card not in hand:
        raise Illegal("That card isn’t in your hand.")
    if state["must"]:
        raise Illegal(f"Meld the {deck.name(state['must'])} first: you took it from the discard pile.")
    hand.remove(card)
    state["pile"].append(card)
    log.event(state, "discard", seat=seat, card=card)
    if not hand:
        _end_hand(state, seat)
        return
    state["stalled"] = state.get("stalled", 0) + 1
    if state["stalled"] >= STALL * state["n"] or state["reshuffles"] > RESHUFFLES:
        # (No one is melding and no one can go out: the hand ends as it lies.)
        log.event(state, "stalled")
        _end_hand(state, None)
        return
    state.update(turn=(seat + 1) % state["n"], phase="draw", took=None, turns=state["turns"] + 1)

Melds#

A set is three or more cards of a rank. A run is three or more in a suit, in sequence. A new meld needs at least two natural cards, so it holds at most one joker among three. Aces are low in A-2-3 or high in Q-K-A, never round the corner (no K-A-2). A run is {suit, lo, cards}, with cards[i] standing for position lo + i, so an ace or joker’s position is fixed by where it sits.

tables/michigan_rummy/melds.pymake_run · lines 88–125
def make_run(cards: list[str], low_jokers: int = 0) -> 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. run_choices() lists the ways it could go."""
    naturals = _naturals(cards)
    jokers = sorted(c for c in cards if is_joker(c))
    low_jokers = max(0, low_jokers) if isinstance(low_jokers, int) and not isinstance(low_jokers, bool) else 0
    if len(cards) < MIN:
        raise Illegal("A run needs at least three cards.")
    if len(naturals) < NATURALS:
        raise Illegal("A meld needs at least two cards that aren’t 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 may sit low or high: try each way.
    for spots in product(*(positions(c) for c in naturals)):
        if len(set(spots)) < len(spots):
            continue
        lo, top = min(spots), max(spots)
        extra = len(jokers) - ((top - lo + 1) - len(naturals))
        if extra < 0:
            continue
        below = min(low_jokers, lo - 1, extra)
        above = extra - below
        if top + above > ACE_HIGH:
            below += top + above - ACE_HIGH
            above = ACE_HIGH - top
        if lo - below < ACE_LOW:
            continue
        score = abs(below - low_jokers)
        if best is None or score < best[0]:
            best = (score, dict(zip(spots, naturals)), lo - below, top + above)
    if best is None:
        raise Illegal("Those cards don’t make a run.")
    _, at, lo, top = best
    spare = iter(jokers)
    ordered = [at[p] if p in at else next(spare) for p in range(lo, top + 1)]
    return {"kind": "run", "suit": suit(naturals[0]), "lo": lo, "cards": ordered}

make_run tries every way the aces can sit, fills gaps with jokers, then puts leftover jokers at the ends: low_jokers of them below and the rest above, as room allows. That’s the player’s choice (high by default), and run_choices lists the options:

tables/michigan_rummy/melds.pyrun_choices · lines 128–143
def run_choices(cards: list[str]) -> list[dict]:
    """The different runs these cards could make, where a joker left over
    could stand at either end: each is {"low_jokers", "lo", "cards"}, the
    default (jokers high) first. Just one when there's no choice; none when
    the cards make no run."""
    out, seen = [], set()
    for k in range(sum(is_joker(c) for c in cards) + 1):
        try:
            run = make_run(cards, k)
        except Illegal:
            return []
        key = (run["lo"], tuple(run["cards"]))
        if key not in seen:
            seen.add(key)
            out.append({"low_jokers": k, "lo": run["lo"], "cards": run["cards"]})
    return out

lay_off lays a card on a meld, and ends says where a card fits. On a set, any card of its rank or any joker fits. On a run, the next position at either end fits, and so does a joker at an end with room:

tables/michigan_rummy/melds.pyends · lines 166–177
def ends(meld: dict, card: str) -> list[str]:
    """Where a card could go on this meld: [] if nowhere, ["set"] on a set,
    else the run's ends it fits ("low", "high")."""
    if meld["kind"] == "set":
        return ["set"] if is_joker(card) or rank(card) == meld["rank"] else []
    lo, top = meld["lo"], hi(meld)
    if is_joker(card):
        return [e for e, ok in (("low", lo > ACE_LOW), ("high", top < ACE_HIGH)) if ok]
    if suit(card) != meld["suit"]:
        return []
    spots = positions(card)
    return [e for e, ok in (("low", lo - 1 in spots), ("high", top + 1 in spots)) if ok]

A joker stays where it’s played. It can’t be swapped for the real card, and a natural can’t be played where a joker stands for it (the 6♥ on a 5-joker-7♥ run); stands_in catches this. The natural can still go on the run’s end or into a set.

Points#

tables/michigan_rummy/melds.pyheld · lines 46–53
def held(card: str) -> int:
    """What a card counts against you, left in your hand."""
    if is_joker(card):
        return 15
    r = rank(card)
    if r == "A":
        return 15
    return 10 if r in "TJQK" else 5
tables/michigan_rummy/melds.pyworth · lines 56–64
def worth(meld: dict, index: int) -> int:
    """What the card at `index` scores in this meld: an ace is 1 as the low
    end of a run (A-2-3), 15 anywhere else; a joker is 15."""
    card = meld["cards"][index]
    if is_joker(card):
        return 15
    if rank(card) == "A" and meld["kind"] == "run" and meld["lo"] + index == ACE_LOW:
        return 1
    return held(card)

An ace is 15, or 1 as the low end of A-2-3. K, Q, J and 10 are 10; the others 5; a joker 15, melded or caught in hand. A seat’s score for a hand is melded − held, which can be negative:

tables/michigan_rummy/rules.py_end_hand · lines 230–250
def _end_hand(state: dict, out) -> None:
    """Score the hand: what each seat melded, less what it holds (out: the
    seat that went out, or None when no one could: see STALL)."""
    n = state["n"]
    held = [sum(M.held(c) for c in h) for h in state["hands"]]
    row = [state["melded"][s] - held[s] for s in range(n)]
    rounds.score(state, row)
    state["outcome"] = {
        "out": out, "melded": list(state["melded"]), "held": held, "score": row,
        "revealed": [list(h) for h in state["hands"]],
    }
    log.event(state, "hand", out=out, scores=row, melded=list(state["melded"]), held=held, totals=list(state["totals"]),
              no=len(state["scores"]))
    state["turn"], state["must"] = None, None
    target = state["rules"]["target"]
    done = max(state["totals"]) >= target
    if done:
        top = max(state["totals"])
        state["winners"] = [s for s in range(n) if state["totals"][s] == top]
        log.event(state, "won", seats=state["winners"], total=top)
    rounds.end(state, last=done)

The first to reach the target at the end of a hand wins. If several pass it in the same hand, the highest total wins, and a tie for highest shares the win. That’s why winners is a list and places can tie.

The planner#

tables/michigan_rummy/plan.py answers two questions from a hand and the table, with no side effects:

  • can_meld(card, pool, table, spare): how could card be melded with as few cards of pool as possible, or None? It tries a play onto a meld, a play after the cards between go first, and a new meld (_new_with). The server runs it for every pile card while a seat is drawing, so picks can say which are takeable and how (“it plays on Ada’s set of 9s”).
  • best(hand, table, must, spare): the most this hand could meld now. candidates lists every set and run the hand could make (with up to two joker gaps). _search picks the richest combination, looking at no more than 3000 mixes, and _lay_offs then plays every card that fits, costliest first. If the richest mix leaves out the must card, it starts again from a way to meld it. This is the server’s suggest, behind “Meld all I can”, and the bots’ engine.

steps() turns a plan into moves of one meld each, as a bot plays them, so the table can follow.

The settings#

tables/michigan_rummy/rules.pyoptions · lines 35–39
def options(raw: dict | None) -> dict:
    """A table's settings, from whatever the host asked for."""
    out = {k: moves.choice(raw, k, choices, DEFAULTS[k]) for k, choices in CHOICES.items()}
    out["bot_pace"] = moves.choice(raw, "bot_pace", tuple(moves.BOT_PACE), "brisk")
    return out
Setting (key) Default Choices What it does
Go out with a discard (discard_out) off off, on On: your last card must be a discard. You can’t meld your hand down to nothing, and a dig that would leave nothing to discard is refused. The family disagrees on this, so it’s a house rule.
Play to (target) 500 500, 300 The total that wins.
Bot speed (bot_pace) brisk brisk, relaxed Multiplies bots’ think times.

Nothing else is a choice (David’s answer 8).

The safety nets#

A hand no one can finish still ends. If the stock has been remade more than 10 times, or 20 turns per player pass with nothing melded, the hand ends as it lies (a stalled line). Everyone counts what they hold, and nobody went out. In about 780 bot hands measured for this chapter, none stalled.

Turn flow and timers#

deciding() names the seat to move, with kind draw or play and a key from the deal and move count, so a bot’s think time restarts at every move. Between hands every seat not yet ready is deciding (ready; bots answer at once). fallback() is always legal: draw (or take the top card if it’s all there is), meld the must card, else discard the first card. There’s no clock. A bot covers a player who leaves, the usual way (the service).

Your move#

Digging is the move new players miss. The first time in a game a deeper pile card melds with your hand (the server’s picks, with “Mark cards I could take” on), the room kit’s YourMove (Your move at a table) shows Dig into the pile (“take it with every card above it, then meld it”), and those cards ring. The top card, which you can always take, doesn’t. It waits for the hand intro and the deal, and the status repeats the hint every turn after.

Secrets#

The state holds the hands and the stock’s order, and view(state, seat) is the only way they reach anyone. The discard pile is public, since it lies spread on the table.

Own seat Other seats Spectator
Hand Its cards (jokers last) A count only No hands
The stock A count A count A count
The pile, the melds, who played each card, who took what Public Public Public
picks For the seat drawing: which pile cards it could take and with what [] []
suggest For the seat that has drawn: the most it could meld None None
fits For the seat that has drawn: which of its cards play on a meld, for the discard check {} {}
Everyone’s remaining cards Revealed once the hand is scored Same Same

Drawing writes two log lines: draw, public (“Ada drew”), and drew, to that seat alone, with the card.

picks, suggest and fits are the planner run on the viewer’s own hand, per request. The contract test’s scramble shuffles everyone else’s hands and the stock between views and requires views and bot moves to be unchanged (the contract tests):

tests/test_michigan_rummy.pyMichiganRummyContract · lines 617–636
class MichiganRummyContract(TableGameContract, SimpleTestCase):
    """Michigan Rummy meets what every table game must (tests/table_contract.py)."""

    game = game
    every = 7  # its views work out a player's helpers: 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#

A bot decides from rules.view, using picks and the planner’s best as a player’s helpers do. Every bot takes the free top discard when it helps, and prizes jokers: it takes one eagerly, never discards one while holding anything else, and puts a spare joker at the end of a run its hand can extend (_joker_side).

Weighing a hand#

tables/michigan_rummy/bots.pyworth · lines 92–106
def worth(hand: list[str], v, must=None, level="medium") -> tuple[float, dict]:
    """How good a hand is to hold now: what it melds this turn, less what
    would be left in it (weighed by how soon someone might go out), plus a
    little for what's nearly a meld and for each joker kept."""
    found = P.best(hand, v["melds"], must, _spare(v), jokers_on_table=level != "medium")
    left = list(hand)
    for c in found["used"]:
        left.remove(c)
    k = PARAMS[level]
    risk = k["risk"] + k["risk_danger"] * danger(v)
    score = found["points"] - risk * sum(M.held(c) for c in left) + k["pot"] * potential(left)
    score += k["joker"] * sum(is_joker(c) for c in left)
    if len(left) <= 1:
        score += 25  # out this turn (or with the discard)
    return score, found

worth is what it melds this turn (P.best) less what’s left in hand, weighted by danger (how few cards the nearest opponent holds). It adds a little for near-melds (potential: pairs, and suited cards one or two apart) and each joker kept, and a bonus for going out. The weights by level:

depthriskrisk_dangerpilemargintop_marginpotjokerknownpile_gift
medium120.50031.52.5800
hard990.250.20002.5122535
Live from tables/michigan_rummy/bots.py, PARAMS

depth is how far above the taken card it looks. risk and risk_danger are the cost of cards left in hand, rising as someone nears going out. margin is the gain it wants before digging rather than drawing (top_margin for the free top card). pot and joker value a near-meld and a held joker. known and pile_gift are discard penalties only hard uses.

How each level plays#

Easy Medium Hard
Draw Only the top card: takes it 80% of the time if it melds, always if a joker, 40% if it makes a pair. Otherwise the stock Weighs the top card and each takeable card in the top twelve, with its margins Weighs every card in the pile, with no margin
Meld Everything the planner finds, jokers on melds too Everything, but jokers go only into its own new melds until it’s down to three cards As medium, but lays jokers on the table too once someone is close to going out (danger ≥ 0.6)
Discard Its costliest loose card (adding nothing to a near-meld) 75% of the time, else a random one; never a joker The cheapest by a cost model: card value, near-meld loss, and heavy penalties for a joker or a card that plays on a table meld The same, plus penalties for a card next to cards another player is known to hold, and for one that melds with the pile (a free dig for the next player)
Memory None None _known: what each player took from the pile and hasn’t melded or thrown, from the log lines in its view

_known reads the view’s last 40 log lines, so hard remembers only a few turns.

Measured strength#

tools/ratings/anchors.py plays bots heads-up, swapping the first seat every other game. At 150 games a pairing (October 2026):

Pairing Share Elo gap
Medium over easy 92.0% +424
Hard over medium 57.3% +51

The committed anchors (300 games a pairing) are 91.7% (+417) and 60.0% (+70). Bigger tables agree: four medium bots split 30 games about evenly, and against medium and two hard bots, easy won none of 30. Games run about five hands heads-up, seven or eight at four to six seats.

Rating#

A game is a field of 2 to 8 seats ranked by place, ties sharing. As in any table game, each seat meets every other, each meeting counting 2/k for k seats (fields), so a four-seat game is worth one and a half games, bots or not. The anchors:

LevelRating
easy1100
medium1500
hard1570
Live from ratings/tables.py, ANCHORS.michigan-rummy

Easy is held 400 below medium, though the measured gap was larger. Hard is 1570.

Worked example. A settled 1500 ± 70 player at a table with an easy, a medium and a hard bot, by where they finish (the bots finishing in order of strength):

You finish Change
1st +15.8
2nd (behind hard) +2.6
3rd (behind hard and medium) −10.6
4th (last) −23.7

Winning gains less than finishing last costs, since you were expected to beat easy and lose to hard. A two-player game against the medium bot moves you +13.2 for a win and −13.2 for a loss. A seat a bot covered at any point is counted but not rated. A guest counts as 1500 ± 350 and never changes. A game the host ended early isn’t kept.

The interface#

app.js calls the room’s start() with the three settings:

games/static/games/michigan-rummy/app.jslines 1–43
// Michigan Rummy: the family's 500 Rum (docs/michigan-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, michiganOptions, shareText, progress } from './table.js';

start({
  game: 'michigan-rummy',
  title: 'Michigan Rummy',
  kicker: 'Card game · 2 to 8 players',
  blurb: 'Dig as deep into the discard pile as you dare: every card you meld scores, and a joker stands in for any card. First to 500.',
  min: 2,
  max: 8,
  rules,
  play,
  look,
  devices: 'desktop',
  spectate: true,
  firstRun: true,
  cues: true,
  progress,
  shareText,
  shortcuts: 'Single keys, shown on the buttons: T to take, D to draw, M to meld, A to meld all you can, X to discard, O to sort.',
  options: () => michiganOptions(),
  // Every engine setting (tables/michigan_rummy/rules.py options()), in plain words.
  settings: [{
    key: 'discard_out', label: 'Go out with a discard', value: 'off',
    hint: 'On: your last card must be a discard. Off: melding everything goes out too.',
    choices: [['off', 'Off'], ['on', 'On']],
  }, {
    key: 'target', label: 'Play to', value: 500,
    hint: 'First to this many wins.',
    choices: [[500, '500'], [300, '300']],
  }, {
    key: 'bot_pace', label: 'Bot speed', value: 'brisk',
    hint: 'Relaxed is easier to follow.',
    choices: [['brisk', 'Brisk'], ['relaxed', 'Relaxed']],
  }],
});

devices: 'desktop' makes the front door tell a phone that the game is made for a computer first. With up to eight seats, the layout depends only on the seat count and the window, never on what’s on the table.

The table#

MichiganTable (games/static/games/michigan-rummy/table.js) draws a bar, the pile row, the seat tiles, your hand and the dock.

  • The pile row has the stock button (“Stock · 27”) and the discard pile, oldest at the left. Its height never changes: the spread overlaps more as it grows, then scrolls sideways.
  • Seat tiles (games/static/games/room/kit/seats.js) show each player’s cards in hand, total, “+N” melded this hand, a meter to the target, and their melds. A card someone else laid on a meld wears a dot in their color, since it scores for them. A badge says “took 3”, “ready” or “away”. A full tile scrolls. The tiles sit in two rows under the pile row, read clockwise round the grid’s edge (loopSeats, ADR 0053): you on the bottom row, the player on your left beside you, then up, across the top and down the right. With two players your opponent’s tile is above yours. On a phone the seat chips run from the player on your left round to you, and your tile is last in the list, pinned at its foot over your hand.
  • The dock has a status line (it explains a dead press, or why picked cards can’t meld), two big moves, “Meld all I can”, the move arrows and the sort.

Taking from the pile#

While drawing, pointing at a pile card lifts it and every card above, and says what taking it would bring: “Take the 7♥ and the 2 cards above it: it makes a run with your 8♥ 9♥”. A card you couldn’t meld is dimmed with a warning (“You can’t meld the K♠, so you can’t dig down to it”).

The first tap (or Enter or Space) chooses a card and sets the left move to “Take 3 cards”; a second tap or the move takes it. The right move is “Draw from stock” (keys T and D), or with the stock out, “Reshuffle and draw” or “Stock’s out”. After a dig, the cards that meld the dug card are picked for you (the “Pick the meld” helper).

Melding#

Picked cards lift, and the left move says what they’d make (“Meld a run”, “Meld a set”, “Play 2 cards”) or, disabled, why not (“The 6♥ can’t replace a joker: jokers stay where they’re played”). Nothing is sent until you press it.

  • A card that fits more than one meld puts a button on each place: “◂ 7♥ low end”, “high end ▸”, “Play 8♥ here”.
  • When a joker could stand at either end (Q-K-joker is J-Q-K or Q-K-A), a button flips it (“Joker as A♥ instead”, or J). “Meld all I can” puts jokers high.
  • Meld all I can sends the server’s suggest whole, as one move.
  • The card you dug for is marked, and Discard is disabled until it’s down.

Discarding#

Pick one card and press Discard (X). With “Check my discards” on, a card that plays on a table meld (a gift to the next player) asks first: “Discard 9♣ anyway?” The server’s fits says which.

Assists and helpers#

All five are on by default, each with a row in Options (uo:michigan-rummy:assist-<key>):

games/static/games/michigan-rummy/table.jsHELPERS · lines 57–65
// The helpers, on by default (docs/design-language.md, "Options"), each
// with its own row to turn it off.
const HELPERS = [
  { key: 'pile', label: 'Mark cards I could take', hint: 'The discard pile marks the top card and each card further down you could meld, and says what with.' },
  { key: 'warn', label: 'Warn me off a card I can’t meld', hint: 'Says so when you choose a card below the top that you couldn’t meld, rather than letting you try.' },
  { key: 'pickfor', label: 'Pick the meld for a card I dig for', hint: 'After you take a card from below the top, the cards that meld it are picked for you.' },
  { key: 'melds', label: 'Light up cards I can play', hint: 'A dot in the turn color on each card in your hand that could meld now.' },
  { key: 'check', label: 'Check my discards', hint: 'Asks once more before you throw a card someone could play on the table.' },
];

Each draws something the server computed for this seat (picks, suggest, fits). Turning one off changes only what’s drawn, not what the server accepts. A sixth row, Hand intro, is a preference.

The hand intro#

Each hand opens with a short card (“Hand 3 · Opal deals · 7 cards each · Ada leads with 212”, and the race to the target) for about three seconds before the deal. It can be skipped or turned off, clicking the hand title replays it, and it never holds up play.

Motion#

The room kit’s Cues animate the deal (one card at a time from the stock), draws, takes (each card flying from where it lay), melds into place, and discards. A remade stock says “Reshuffled” for three seconds. A landing card stays hidden in place until it arrives, so nothing jumps. If a new hand was dealt, or more than six takes, melds and discards arrive at once (a page that was asleep), the queue skips to the end.

When someone plays on your meld your tile lights up as the cards land; any other player whose meld grew lights up too. Here that happens most turns, so the words wait: everything played on your melds since your last turn is said in one toast as your turn comes (or the hand ends), in the room kit’s sentence (games/static/games/room/kit/onyours.js: “Ben played the 7♥ and 8♥ on your run. Cleo played the K♠ on your set.”). Your turn coming clears no toasts, and the cues catch up (catchUp(), never skip()), so the move just before yours always plays (ADR 0054). Until a player who melded discards, the status line says what they laid down (“Ben melded 35.”). Each score is the house’s Figure (ADR 0054; on a phone’s seat chip a size smaller, so four fit across), and the bar keeps the target (“to 500”) on a narrow screen. On a phone a seat that dug into the pile rings in the turn color, where its “took 4” badge has no room.

Between hands, and the result sheet#

When a hand ends, the house’s result sheet opens in hand state: each player’s melded points, the cards left in hand (or “Went out”), the net, the race to the target, and every hand’s scores. “Next hand” says you’re ready; a ready host also gets “Start without waiting”.

At game over the sheet adds a chart (the room kit’s chart.js: its numbers 12 units, its names 12, in a drawing 640 units wide, 360 on a phone) of every player’s total hand by hand, ringed where a player went out, plus Share result (the room’s words, a square a hand and the standings by medal, then a picture of the chart, by games/static/games/room/kit/share.js), the rating line, and Play again for the host.

On a phone#

Below 640 px (or on a phone turned sideways) the seat tiles become a sliding row of chips over a list of melds, with the seat to move scrolled into view. A chip shows cards and score (“7 · 212”) and is always wide enough for both, so −145 never overflows; the name truncates instead. The bar’s tools fold into “⋯”, and the pile row scrolls. This is the one layout switch (COMPACT): tiles on a wide window, a list on a narrow one.

Its look#

Michigan 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 The pile row along the top: the stock, then the discard pile spread out, oldest at the left, every card’s corner showing. Under it, the seat tiles in a clockwise grid, read round the edge, you on the bottom row with the player on your left beside you.
Emblem and art Its box on the shelf
Voice The family’s 500 Rum: meld, play on, the pile
Payoff 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 and the status line, where what the last player melded stays said until they discard. The score: each score toward the target, a Figure, with the target kept in the bar. The countdown: the pile and the stock. Yours: your hand.
Exceptions The card room’s, and its pile line kept to its lines so the table never jumps

Testing#

What Proves
tests/test_michigan_rummy.py: DealingTests, DrawingTests The deal by player count; drawing and taking, with every deep-take refusal.
MeldTests, JokerTests Sets, runs and aces; who scores; refused moves changing nothing; joker placement, and naturals never replacing one.
GoingOutTests, StockTests, ScoringTests Going out both ways; remaking the stock and the safety nets; card values, ties, 300.
SettingsTests, ViewTests, PlanTests Exactly three settings; what each seat sees; the planner’s richest mix and the must card.
BotTests Key bot decisions, and thirty-six whole games of mixed bots at two to eight seats with no move refused.
MichiganRummyContract Every table game’s secrecy and bot contract.
tools/room/michigan-rummy.test.mjs The client’s meld rules (joker choices, aces, mustMove) and the score chart’s axis below zero, 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_michigan_rummy, npm run check:room for the Node tests, and after a look change npm run check:layout -- --only michigan (testing).

The planner was also fuzzed against random play for this chapter. Over 24 games of mixed bots at two to five seats, with and without the discard rule, the server accepted all 2,386 “Meld all I can” suggestions for exactly the points claimed, and all 13,583 pile marks matched whether take was allowed.

Known limitations and known issues#

  • The family doc overstates hard: “about two games in three heads-up” (docs/michigan-rummy.md), against a measured 57 to 60 percent. The anchor follows the measurement.
  • The client has its own copy of the meld rules (games/static/games/michigan-rummy/melds.js). The server decides, but a change to melds.py must be made twice, and the Node test covers only the joker and ace cases.
  • Hard remembers only its view of the log, the last 40 lines. At a table of eight, what each player took fades after a few rounds.
  • A bot’s meld is slow to watch: one meld a move, each with a 1 s think at Brisk.
  • A set can outgrow its suits. Sets have no size limit, so with jokers a one-deck set of five is accepted, following “a joker is any card”. The doc says “three or four cards of one rank (more with two decks)”.
  • A joker can extend a full run past the ace: an A-to-K run takes a joker at the high end, making fourteen cards. Harmless, and not in the doc.
  • A covered or absent player holds up the table. There’s no away.

Changing it#

To change Needs
A rule rules.py or melds.py, docs/michigan-rummy.md (they must agree), rules.js, and for a meld rule the client’s melds.js and its Node test. If a finished game’s places could change, bump the ratings VERSION.
A setting CHOICES, DEFAULTS and options() in rules.py, the settings list in app.js, the doc, and a test. A test insists there are exactly three.
What the planner finds plan.py only. Run PlanTests and the bots’ tests, since picks, suggest and every bot use it.
A bot weight PARAMS or bots.py; rerun tools/ratings/anchors.py 300 michigan-rummy, update ANCHORS, bump VERSION.
What a seat sees view() and scramble(). Anything computed for a seat (picks, suggest, fits) must read only that seat’s hand.
The look michigan-rummy.css and the table; check:tokens, check:layout, check:details.

Then reread this chapter against the change and npm run guide:stamp michigan-rummy.