Technical guide

Board and party games

Michigan Rails

A train board game where you collect cards, claim railway routes between Michigan’s towns, and try to connect the cities on your tickets.

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

On this page
  1. At a glance
  2. The map
    1. Keeping Europe’s balance
    2. The map file
    3. How it’s made
  3. The rules in code
    1. The state
    2. A turn
    3. The end and the score
    4. The contract
  4. Dealing a game
  5. Your move
  6. Secrets
  7. Bots
    1. Should hard take more risks?
  8. Rating
  9. The interface
    1. The room, and the table
    2. Moving
    3. Assists
    4. Motion, the log, the look
  10. Its look
  11. Testing
  12. Changing it
  13. Known issues

Michigan Rails is a route-building board game for two to five people, with bots to fill any seat. You collect train cards and spend sets of them to claim routes between Michigan’s towns. You score for your routes and for linking the two cities on each of your destination tickets, and lose points for each ticket you don’t link.

It plays the published rules of the Europe edition of the family classic it’s based on, with the same number of every component, so that game’s balance carries over. That was David’s call: “the exact same ruleset and component count”. The name, the map and the drawing are the site’s own. docs/michigan-rails.md is the rules as played, with sources. It’s a table game on the table service, drawn with the room kit and rated.

At a glance#

Part Where
The map: cities, routes, tickets, checked as it’s read tables/michigan_rails/board.py, games/static/games/michigan-rails/map.json
The rules: dealing, a turn, tunnels, stations, the end and the score tables/michigan_rails/rules.py
The bots (easy, medium, hard) tables/michigan_rails/bots.py
The table games/static/games/michigan-rails/table.js
Its entry into the room kit, How to play games/static/games/michigan-rails/app.js, games/static/games/michigan-rails/rules.js
Its look games/static/games/michigan-rails/michigan-rails.css
Its pages games/templates/games/michigan-rails.html, games/templates/michigan-rails/page.html
The map’s maker tools/michigan-rails/build.py, tools/michigan-rails/stops.py, tools/michigan-rails/network.txt
Tests, and Europe’s board as a fixture tests/test_michigan_rails.py, tests/fixtures/europe_rails.json
Design notes docs/michigan-rails.md

The map#

Keeping Europe’s balance#

David placed 47 stops on a map of Michigan, as many as Europe has cities. Europe’s own network can’t be laid over them: Michigan is two peninsulas, and the best fit found still crossed thirteen routes and ran track across Lake Michigan. So the map keeps all of Europe’s numbers instead:

CountEuropeMichigan
Cities4747
Routes (city pairs)101 (90)101 (90)
Doubles1111
Spaces300300
Routes by length1: 4, 2: 35, 3: 30, 4: 29, 6: 2, 8: 11: 4, 2: 35, 3: 30, 4: 29, 6: 2, 8: 1
Tunnels by length2: 9, 3: 5, 4: 2, 6: 1, 8: 12: 9, 3: 5, 4: 2, 6: 1, 8: 1
Ferries (length/locomotives)2/1: 4, 2/2: 1, 3/1: 3, 4/1: 2, 4/2: 2, 6/2: 12/1: 4, 2/2: 1, 3/1: 3, 4/1: 2, 4/2: 2, 6/2: 1
Cities with n neighbors (count×n)1×1, 4×2, 14×3, 15×4, 10×5, 2×6, 1×71×1, 4×2, 14×3, 15×4, 10×5, 2×6, 1×7
Tickets (long)46 (6)46 (6)
Ticket points5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 10, 10, 10, 10, 10, 11, 11, 12, 12, 13, 20, 20, 20, 21, 21, 215, 5, 5, 5, 5, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 10, 10, 10, 10, 10, 11, 11, 12, 12, 13, 20, 20, 20, 21, 21, 21
Live from guide/worked/michigan_rails.py, COMPARE

That’s 90 pairs of neighboring stops, with as many cities of each number of neighbors as Europe has, so blocking works the same. Each pair takes one of Europe’s routes whole (length, color, double, tunnel or ferry, and a ferry’s locomotives), chosen to fit the distance. Ferries cross only water (the Straits, the islands, the bays, and Frankfort to Escanaba, where the car ferries ran). Tunnels are only in the hills, and doubles only on main lines such as the Mackinac Bridge and I-94. The longest trip is Copper Harbor to Detroit, 34 trains; Europe’s is 29.

A ticket is worth the fewest trains that link its cities, as all but two of Europe’s 46 are (those two are worth one more). Michigan’s 46 tickets take Europe’s points exactly, six long and forty regular, and every city is on as many tickets as a city in Europe. Bots score within a few points on the two maps at every table size (docs/michigan-rails.md, “Bots”).

The map file#

One file, games/static/games/michigan-rails/map.json, is what the rules read and the table draws. The rules need only cities, routes and tickets. The browser also reads positions, labels, each route’s track and car positions, and the shoreline. board.py reads a map once per process and checks that every route and ticket names real cities, colors are known, a ferry asks no more locomotives than it has spaces, and a double’s two routes name each other.

tables/michigan_rails/board.pyparse · lines 52–81
def parse(data: dict) -> Map:
    """A map from its file's JSON, checked: every route and ticket names
    real cities, doubles name each other, colors and ferries make sense."""
    cities = data["cities"]
    routes = {r["id"]: dict(r) for r in data["routes"]}
    tickets = {t["id"]: dict(t) for t in data["tickets"]}
    by_city = {c: [] for c in cities}
    for rid, r in routes.items():
        for end in (r["a"], r["b"]):
            if end not in cities:
                raise ValueError(f"Route {rid} goes to {end!r}, which isn't a city on the map.")
            by_city[end].append(rid)
        if r["color"] not in COLORS and r["color"] != GREY:
            raise ValueError(f"Route {rid} is {r['color']!r}: say one of {', '.join(COLORS)} or grey.")
        if not 1 <= r["length"] <= 8:
            raise ValueError(f"Route {rid} is {r['length']} long; routes are 1 to 8.")
        r.setdefault("tunnel", False)
        r["ferry"] = int(r.get("ferry") or 0)
        if r["ferry"] > r["length"]:
            raise ValueError(f"Ferry {rid} needs more locomotives than it has spaces.")
        pair = r.get("pair")
        if pair is not None:
            other = routes.get(pair)
            if not other or other.get("pair") != rid or {other["a"], other["b"]} != {r["a"], r["b"]}:
                raise ValueError(f"Route {rid}'s double, {pair!r}, isn't its twin.")
    for tid, t in tickets.items():
        if t["a"] not in cities or t["b"] not in cities:
            raise ValueError(f"Ticket {tid} names a city that isn't on the map.")
    return Map(id=data["id"], name=data.get("name", data["id"]), cities=cities, routes=routes, tickets=tickets,
               by_city=by_city)

The state keeps only the map’s id, so tables on a map share one copy. register() makes another map playable; the tests register Europe’s.

How it’s made#

tools/michigan-rails/build.py builds the file from the stops (where David drew each, and its real place), the 90 pairs (whether each may be a ferry, in the hills or a main line, and any bend), and Europe’s board (tests/fixtures/europe_rails.json). It gives each pair one of Europe’s routes by closest fit to the distance (an assignment, solved exactly), and picks tickets by a seeded search that holds each city to Europe’s ticket count and spreads paths over the map. The shoreline is Natural Earth (public domain), fitted to David’s Mercator map from the stops’ real places. The tip of the mitt and the Straits are drawn larger than life.

Every car is the same size, as on a printed board, because mixed sizes looked cheap. So relax() moves the stops to fit the cars: no track much longer than its cars, the tip of the mitt keeping its shape, and the shoreline carried along by a smooth field held still at the edges. A track whose stops are too close bends, with each corner in a gap between cars. Each car’s place is written into the file, so the table draws exactly what the build laid out.

tools/michigan-rails/build.pytrack_shape · lines 334–367
def track_shape(p0, p1, n, side, bow=False):
    """A track for n cars from p0 to p1, as its corners and the cars on each
    leg. Straight when the stops are far enough apart (any spare track is
    shared out by lay()); else bent as the published maps bend theirs, with
    every car on a straight leg and each corner in a gap between cars: one
    corner (a V) where it isn't too sharp, else out, along and back (three
    legs). `side` is the way it bends: +1 left, -1 right, from p0 to p1."""
    c = math.dist(p0, p1)
    ux, uy = (p1[0] - p0[0]) / c, (p1[1] - p0[1]) / c
    nx, ny = uy * side, -ux * side
    if bow and n > 1:  # a long track bowed clear of a neighbor: a shallow V
        apex = (p0[0] + ux * c / 2 + nx * c * BOW, p0[1] + uy * c / 2 + ny * c * BOW)
        return [p0, apex, p1], [(n + 1) // 2, n // 2]
    if n == 1 or c >= run_length(n) + 2 * END:
        return [p0, p1], [n]

    def leg(k, ends):  # a leg's length: its cars, half a gap at each corner, END at a stop
        return run_length(k) + ends * END + (2 - ends) * GAP / 2

    k1 = (n + 1) // 2
    l1, l2 = leg(k1, 1), leg(n - k1, 1)
    if l1 + l2 > c and abs(l1 - l2) < c:
        along = (l1 * l1 - l2 * l2 + c * c) / (2 * c)
        out = math.sqrt(max(l1 * l1 - along * along, 0))
        corner = math.degrees(math.acos(max(-1, min(1, (l1 * l1 + l2 * l2 - c * c) / (2 * l1 * l2)))))
        if corner >= MIN_CORNER or n < 3:
            apex = (p0[0] + ux * along + nx * out, p0[1] + uy * along + ny * out)
            return [p0, apex, p1], [k1, n - k1]
    k = max(1, n // 4) if n >= 4 else 1
    l1, l2 = leg(k, 1), leg(n - 2 * k, 0)
    lean = math.acos(max(-1, min(1, (c - l2) / (2 * l1))))
    q1 = (p0[0] + (ux * math.cos(lean) + nx * math.sin(lean)) * l1, p0[1] + (uy * math.cos(lean) + ny * math.sin(lean)) * l1)
    q2 = (q1[0] + ux * l2, q1[1] + uy * l2)
    return [p0, q1, q2, p1], [k, n - 2 * k, k]

The same inputs always make the same file. tools/michigan-rails/README.md says how to run it.

The rules in code#

The state#

One JSON object, saved whole. A train card is its color or "loco", and a hand is a count of each kind:

tables/michigan_rails/rules.pynew · lines 66–97
def new(players: int, seed: str, now: float, settings: dict | None = None) -> dict:
    if not min_players <= players <= max_players:
        raise Illegal("Michigan Rails is for two to five players.")
    rules = options(settings)
    m = board.load(rules["map"])
    cards = deck.shuffled([c for c in COLORS for _ in range(EACH_COLOR)] + [LOCO] * LOCOS, f"{seed}:cards")
    hands = [{k: 0 for k in KINDS} for _ in range(players)]
    for _ in range(HAND):
        for s in range(players):
            hands[s][cards.pop()] += 1
    longs = deck.shuffled(m.long, f"{seed}:long")
    regular = deck.shuffled(m.regular, f"{seed}:tickets")
    offers = []
    for s in range(players):
        offers.append({"ids": [longs.pop(), regular.pop(), regular.pop(), regular.pop()], "keep": 2})
    # (The long tickets no one was dealt leave the game, unseen.)
    first = int.from_bytes(seed.encode(), "big") % players
    state = {
        "game": slug, "n": players, "seed": seed, "map": m.id,
        "rules": {"map": m.id}, "_pace": moves.BOT_PACE[rules["bot_pace"]],
        "seq": 0, "logged": 0, "events": [],
        "hands": hands, "deck": cards, "discards": [], "faceup": [None] * FACE_UP, "shuffles": 0,
        "ticket_deck": regular, "tickets": [[] for _ in range(players)], "offers": offers,
        "trains": [TRAINS] * players, "stations": {}, "claimed": {}, "points": [0] * players,
        "phase": "tickets", "first": first, "turn": None, "turns": 0, "drawn": 0, "tunnel": None,
        "ending": None, "left": None, "passes": 0, "last": None,
        "final": None, "winners": None,
        "scores": [], "totals": [0] * players, "ready": [False] * players,
    }
    _refill(state)
    log.event(state, "start", first=first)
    return state
  • claimed maps a route to its holder; stations maps a city to the seat that built there.
  • points is each seat’s route points so far.
  • offers holds tickets to choose from: four at the start (keep two or more), three later (keep one or more).
  • tunnel holds a tunnel’s laid cards while its player decides.
  • ending and left count down the last round.

A turn#

A turn is one of: draw two cards, claim a route, take three tickets, or build a station.

tables/michigan_rails/rules.pyMOVES · lines 628–629
MOVES = {"draw": draw, "claim": claim, "tunnel": tunnel, "station": station, "tickets": draw_tickets,
         "keep": keep, "pass": pass_turn}

Drawing. A face-up locomotive is the whole turn and can’t be the second card; a blind one is one card like any other. Taken face-up cards are replaced at once. Three face-up locomotives clear all five (_refill, which gives up after 20 redeals in a row, when the cards left can’t do better).

Claiming. A route takes one card per space, of its color (any one color for grey), with locomotives wild. A ferry needs its locomotives among them. A move may name the cards or just a color, and the rules pay with as few locomotives as possible:

tables/michigan_rails/rules.pypayment · lines 171–181
def payment(hand: dict, length: int, color: str, locos: int = 0):
    """The plainest way to pay `length` cards of `color` (LOCO: locomotives
    only) with at least `locos` locomotives: as few locomotives as can be.
    None if this hand can't."""
    if color == LOCO:
        return {LOCO: length} if hand[LOCO] >= length else None
    use = min(hand[color], length - locos)
    wild = length - use
    if wild > hand[LOCO]:
        return None
    return {k: v for k, v in ((color, use), (LOCO, wild)) if v}

One function, shared by the view and the bots, says whether a seat may claim a route at all. It holds the double rule: no one holds both routes of a double, and below 4 players only one of the two is used.

tables/michigan_rails/rules.pywhy_not_route · lines 237–253
def why_not_route(state: dict, seat: int, rid) -> str | None:
    """Why this seat can't claim this route now (whatever its cards), or None."""
    m = the_map(state)
    r = m.routes.get(rid) if isinstance(rid, str) else None
    if r is None:
        return "Pick a route on the map."
    if rid in state["claimed"]:
        return "That route is taken."
    if state["trains"][seat] < r["length"]:
        return f"You have {state['trains'][seat]} trains left; that route takes {r['length']}."
    pair = r.get("pair")
    if pair and pair in state["claimed"]:
        if state["claimed"][pair] == seat:
            return "You can't claim both routes of a double."
        if state["n"] < DOUBLES_FROM:
            return f"With {state['n']} players, only one route of a double is used."
    return None

Tunnels. The cards are laid down, not yet spent, and the deck’s top 3 are turned up. Each that matches the color laid, or is a locomotive, costs one more card (only locomotives count if only locomotives were laid). If nothing more is owed, the route is claimed. Otherwise tunnel waits for the player to pay or take the cards back, and the turn ends either way.

tables/michigan_rails/rules.pyclaim · lines 334–358
def claim(state, seat, action, now):
    """Claim a route with cards of one color (any one, on a grey route),
    locomotives wild; a ferry needs its locomotives. A tunnel may cost more
    (flip())."""
    _fresh(state, seat)
    rid = action.get("route")
    why = why_not_route(state, seat, rid)
    if why:
        raise Illegal(why)
    r = the_map(state).routes[rid]
    cards, used = _paid(state, seat, action, r["length"], r["color"], r["ferry"])
    if not r["tunnel"]:
        _spend(state, seat, cards)
        return _claimed(state, seat, rid, cards)
    # A tunnel: the cards are laid down, then the deck's top three turned up.
    _spend(state, seat, cards, to_discards=False)
    flipped = [c for c in (_deal_one(state) for _ in range(TUNNEL_FLIP)) if c is not None]
    extra = sum(1 for c in flipped if c == LOCO or (used != LOCO and c == used))
    log.event(state, "tunnel", seat=seat, route=rid, flipped=flipped, extra=extra, color=used)
    if not extra:
        state["discards"] += flipped
        state["discards"] += [k for k, v in cards.items() for _ in range(v)]
        return _claimed(state, seat, rid, cards)
    state["tunnel"] = {"seat": seat, "route": rid, "laid": cards, "color": used, "flipped": flipped, "extra": extra}
    state["last"] = {"seat": seat, "kind": "tunnel", "route": rid, "flipped": flipped, "extra": extra}

Stations. One per city, costing one card, then two of a color, then three. A station lets its owner use one other player’s route into that city, for tickets only.

Route points are scored on claiming. Neither map has a route of 5 or 7, but the published table includes them:

SpacesPoints
11
22
34
47
510
615
718
821
Live from tables/michigan_rails/rules.py, POINTS

The end and the score#

When a player ends a turn with 2 trains or fewer, everyone, that player included, gets one more turn (_end_turn). Then _finish scores:

  • each ticket: plus its points if linked by the player’s routes (and routes their stations lend), minus them if not;
  • 4 points per unbuilt station;
  • 10 for the Michigan Express, the longest continuous path, to everyone tied for it.

In the published game players choose which route each station lends. The best choice is always the highest-scoring one, so the rules try every combination, never more than a few hundred:

tables/michigan_rails/rules.pyborrowed · lines 532–548
def borrowed(state: dict, seat: int, tickets: list[str]) -> dict:
    """Each of this seat's stations lends it one route of another player's
    into its city: the ones that complete the most of these tickets' points.
    {city: route id or None}."""
    m = the_map(state)
    mine = [board.ends(m, rid) for rid in own_routes(state, seat)]
    cities = sorted(c for c, s in state["stations"].items() if s == seat)
    choices = [[None] + [rid for rid in m.by_city[c] if rid in state["claimed"] and state["claimed"][rid] != seat]
               for c in cities]
    best, pick = None, {c: None for c in cities}
    for combo in product(*choices):
        edges = mine + [board.ends(m, rid) for rid in combo if rid]
        score = sum(m.tickets[t]["points"] if _connected(edges, m.tickets[t]["a"], m.tickets[t]["b"])
                    else -m.tickets[t]["points"] for t in tickets)
        if best is None or score > best:
            best, pick = score, dict(zip(cities, combo))
    return pick

The longest path may revisit a city but uses each route once. It’s a search from every city over the player’s own routes, which is quick because a player holds at most a couple of dozen.

tables/michigan_rails/rules.pylongest_path · lines 562–584
def longest_path(state: dict, seat: int) -> int:
    """The longest continuous path of this seat's own routes, in trains: a
    trail that may pass through a city more than once but uses each route
    once. Stations don't count."""
    m = the_map(state)
    edges = [(rid, *board.ends(m, rid), m.routes[rid]["length"]) for rid in own_routes(state, seat)]
    at: dict = {}
    for rid, a, b, w in edges:
        at.setdefault(a, []).append((rid, b, w))
        at.setdefault(b, []).append((rid, a, w))
    best = 0

    def walk(city, used, length):
        nonlocal best
        best = max(best, length)
        for rid, nxt, w in at.get(city, ()):
            if rid not in used:
                used.add(rid)
                walk(nxt, used, length + w)
                used.discard(rid)
    for city in at:
        walk(city, set(), 0)
    return best

A tie on points goes to the most tickets completed, then the fewest stations built, then the Michigan Express:

tables/michigan_rails/rules.py_tiebreak · lines 620–623
def _tiebreak(seats: list[dict]) -> list[int]:
    """Level on points: most tickets completed, then fewest stations built,
    then the Michigan Express."""
    return [x["completed"] * 100 + x["stations_left"] * 10 + (1 if x["express"] else 0) for x in seats]

The contract#

Part Here
terms A round is a game; highest wins
options The map and the bots’ pace; the published rules leave nothing else open
tick, deadline Nothing runs out of time: tick does nothing, deadline is None
idle_after 180 seconds, not the room’s one minute, before a decision counts as holding up the table and anyone may hand the seat to a bot (the table service). A minute came too soon in David’s first family game.
deciding One decision at a time in play (tickets to keep, a tunnel to settle, a second card, or a turn); at the start, everyone choosing tickets at once
fallback Always legal: keep the fewest tickets allowed, take back a tunnel’s cards, draw, else take tickets, claim, build or pass
result Places with the tiebreak above, for history and rating

Dealing a game#

new shuffles the 110 cards and the tickets from the table’s seed (tables.deck.shuffled, with separate streams for cards, long tickets and regular tickets). Each seat gets four cards and four tickets (one long, three regular). Five cards go face up, and the seed picks the first player. Undealt long tickets leave the game unseen.

Your move#

Every turn opens with a Your turn banner saying what others did since your move. Your first turn gets the room kit’s YourMove card instead, Draw or claim a route (Your move at a table), and yourTurn() holds the banner back for it. After a few seconds idle, a second card still to draw rings the cards themselves (not the yard’s buttons), and Choose tickets rings if its sheet was put away.

Secrets#

A seat’s cards and tickets are its own until the end, when every ticket is shown in final. The deck’s order, the ticket deck’s order and other seats’ offers never appear in a view. Everything else is public, including card and ticket counts and a tunnel’s turned cards. A spectator (view(state, None)) gets no hand. A blind draw’s log line carries to its drawer alone. A claim’s line names the cards spent, since they’re laid out for all to see.

tables/michigan_rails/rules.pyview · lines 718–770
def view(state: dict, seat) -> dict:
    m = the_map(state)
    n = state["n"]
    mine = seat is not None
    phase = state["phase"]
    offer = state["offers"][seat] if mine else None
    my_turn = mine and phase == "play" and state["turn"] == seat
    fresh = my_turn and not state["drawn"] and not state["tunnel"] and not offer
    done = ticket_status(state, seat) if mine else {}
    can = {"draw": [], "claim": {}, "station": [], "tickets": False, "pass": False}
    if my_turn and not offer and not state["tunnel"]:
        second = state["drawn"] == 1
        if state["deck"] or state["discards"]:
            can["draw"].append("deck")
        can["draw"] += [i for i, c in enumerate(state["faceup"]) if c is not None and not (second and c == LOCO)]
    if fresh:
        for rid in m.routes:
            colors = route_colors(state, seat, rid)
            if colors:
                can["claim"][rid] = colors
        if len(state["stations"]) < len(m.cities):
            can["station"] = station_colors(state, seat)
        can["tickets"] = bool(state["ticket_deck"])
        can["pass"] = not _anything(state, seat)
    t = state["tunnel"]
    return {
        "game": slug, "seq": state["seq"], "n": n, "seat": seat, "map": state["map"],
        "phase": phase, "turn": state["turn"], "turns": state["turns"], "first": state["first"],
        "drawn": state["drawn"],
        "faceup": list(state["faceup"]), "deck": len(state["deck"]), "discards": len(state["discards"]),
        "ticket_deck": len(state["ticket_deck"]),
        "hand": dict(state["hands"][seat]) if mine else None,
        "hands": [sum(h.values()) for h in state["hands"]],
        "tickets": [_ticket(m, x, done[x]) for x in state["tickets"][seat]] if mine else None,
        "ticket_counts": [len(x) for x in state["tickets"]],
        "offer": {"tickets": [_ticket(m, x) for x in offer["ids"]], "keep": min(offer["keep"], len(offer["ids"]))}
        if offer else None,
        "choosing": [s for s in range(n) if state["offers"][s]],
        "trains": list(state["trains"]),
        "stations": dict(state["stations"]),
        "stations_left": [_stations_left(state, s) for s in range(n)],
        "station_cost": station_cost(state, seat) if mine and _stations_left(state, seat) else None,
        "claimed": dict(state["claimed"]),
        "points": list(state["points"]),
        "tunnel": {"seat": t["seat"], "route": t["route"], "laid": dict(t["laid"]), "color": t["color"],
                   "flipped": list(t["flipped"]), "extra": t["extra"],
                   "can_pay": tunnel_payment(state, seat) is not None if seat == t["seat"] else None} if t else None,
        "ending": state["ending"], "left": state["left"],
        "can": can,
        "last": dict(state["last"]) if state["last"] else None,
        "final": state["final"], "winners": state["winners"],
        "events": log.recent(state, seat),
    }

can lists every legal move for the seat now (draws, claimable routes with the colors that could pay, station colors, tickets, pass), so the table lights only what’s allowed and never reimplements the rules.

Bots#

Each decision starts from a plan built from the bot’s own view: for each unfinished ticket, the cheapest way to link its cities over routes still open, with earlier tickets lending their routes to later ones so tickets share track. The bot collects cards for and claims the routes on its plan.

tables/michigan_rails/bots.pyLEVEL · lines 42–49
LEVEL = {
    "easy": {"wander": 0.15, "late": 3, "mid_budget": 0.5, "lambda": 0.15, "budget": 0.6, "noise": 6.0, "face": 0.4,
             "loco": False, "critical": False, "more_tickets": 34, "station": False},
    "medium": {"wander": 0, "late": 4, "mid_budget": 0.6, "lambda": 0.3, "budget": 0.55, "noise": 0.3, "face": 1.0,
               "loco": False, "critical": False, "more_tickets": 28, "station": True},
    "hard": {"wander": 0, "late": 4, "mid_budget": 0.5, "lambda": 0.35, "budget": 0.55, "noise": 0.0, "face": 1.0,
             "loco": True, "critical": True, "more_tickets": 15, "station": True},
}
  • Easy keeps tickets loosely, sometimes claims whatever catches its eye, and draws face up less.
  • Medium follows the plan: keeps tickets it can afford, claims planned routes as soon as it can pay (longest first), draws the face-up cards it needs, and builds a station when a ticket is cut off.
  • Hard also weighs tickets by shared track, claims first the routes it can’t go round, takes face-up locomotives when worth a turn, and saves cards for the routes that need them.

Medium beats easy 75% of the time and hard beats medium 57% (tools/ratings/anchors.py, 300 deals played both ways round). That sets the anchors: easy 1310, medium 1500, hard 1550.

Should hard take more risks?#

David asked whether a hard bot that dared more tickets would win more. How much a bot dares is APPETITE, whose defaults are the levels as they play:

tables/michigan_rails/bots.pyAPPETITE · lines 50–57
# Ticket appetite, for measuring riskier bots (tools/michigan-rails/strength.py);
# a level that doesn't say takes these, which are how the levels above play.
# risk: how hard a set of tickets over budget is marked down (0 ignores the
# budget); long_bonus: points added for keeping the deal's long ticket;
# spare: draw tickets with some still open once the plan leaves this many
# trains over (None: only with every ticket done); others_min: no ticket
# draw once another player is down to this many trains.
APPETITE = {"risk": 1.0, "long_bonus": 0.0, "spare": None, "others_min": 12}

tools/michigan-rails/strength.py plays a candidate against hard and medium, every deal both ways round, at two and four players, and prints win shares with 95% intervals, mean scores, and tickets kept and made. In October 2026 twelve bolder bots were tried. None clearly beat hard: the best won 52% at two players and 51% at four over 1,000 deals each way. Every bolder bot kept more tickets and made fewer, because what holds a bot back is gathering the cards in time. So hard stays as it is. docs/michigan-rails.md has the full table.

Rating#

A kept game is rated from its places like every table game, with bots at their anchors (Ratings). Every seat plays for itself.

The interface#

The room, and the table#

games/static/games/michigan-rails/app.js fetches the map (the file the server reads) before calling the room kit’s start(), so a table never draws without it. Its cfg adds min: 2, max: 5, defaultBots: 2, fill, spectate, firstRun, a Bot speed setting, progress (“Turn 12 · 9 trains left at the fewest”) and shareText.

Module What it draws
games/static/games/michigan-rails/table.js The table: the players, the rail yard, your hand, your tickets, the dock, the sheets, the log’s lines and the result
games/static/games/michigan-rails/board.js The map, one SVG in layers, and what a press on it means
games/static/games/michigan-rails/zoom.js Zooming and dragging the map
games/static/games/michigan-rails/cards.js The pieces: train cards, their color marks, tickets, train cars, station tokens, a player’s stock

On a computer the map fills the window’s height. The column beside it holds the players (each row opens a popover with their stock), the rail yard (stock, five face-up cards, ticket deck), your hand in a wooden rack, your tickets, and the dock. Pointing at a hand card lights the routes it can pay for. The dock’s three slots are empty on your turn, because every move is made on the table itself. Its buttons cover what isn’t a choice among moves: Pass when nothing else is possible, reopening the ticket choice, a tunnel’s answers. A very wide window gives tickets their own column. On a phone everything goes under the map, and tickets open in a sheet.

A city’s name is set at 12 map units, as large as the map holds it: the names already lie over a few cars (with a paper halo, so the car still shows round each letter), and at 12.5 they’d cover a quarter more of the routes than at 12. So a name reads at about 9.6px where the map fills a computer’s window and is zoomed to read on a phone, as the design language allows for words drawn on a board. The table’s own words (the rack’s engraved lip, the piles’ names, a player’s “pts”, the face slots’ keys and the result’s table) are 12px at least, on a phone too; the stock’s back has its monogram on a larger roundel.

Moving#

Drawing is one press, on the stock or a face-up card (D, or 1–5). A face-up locomotive as your second card is refused with a sentence.

Claiming is never one press, because it can’t be undone. The first press picks the route: its cars lift and glow, and the status line gives the cost. A second press claims it, and Escape lets go. If several colors could pay, or it’s a tunnel, the press opens a chooser sheet instead. Taking tickets is a whole turn too, so the first press (or T) asks “Take 3 tickets?”, which lapses after 6 seconds or any other action.

games/static/games/michigan-rails/table.jsRailsTable.takeTickets · lines 1463–1475
/** Take tickets: a whole turn, so the ticket deck asks first, in place
 * (docs/design-language.md, "Confirm in place"). The first press lifts it
 * and the status line asks; a second press (or T) takes them. The
 * question goes after 6s, or when anything else happens. */
takeTickets() {
  const v = this.v;
  if (!v || !this.seated()) return;
  if (!this.myTurn()) return this.hint(v.phase === 'play' ? `It’s ${this.name(v.turn)}’s turn.` : 'Not now.');
  if (!v.can?.tickets) return this.hint(v.drawn ? 'Draw your second card: tickets are a whole turn.' : !v.ticket_deck ? 'There are no tickets left.' : 'Not now.');
  if (!this.ticketsAsked) return this.askTickets(true);
  this.askTickets(false);
  this.act({ type: 'tickets' });
}
games/static/games/michigan-rails/table.jsRailsTable.pickOnMap · lines 1286–1312
pickOnMap(t, how) {
  const v = this.v;
  if (!v) return;
  if (t.kind === 'city') return this.citySheet(t.id);
  const rid = t.id;
  const r = this.routes[rid];
  const owner = v.claimed[rid];
  if (this.t.you === null) return this.hint(`${this.routeName(rid)}${owner !== undefined ? `: ${this.name(owner)}’s` : ''}. You’re watching.`);
  if (owner !== undefined) return this.hint(`${this.routeName(rid)} is ${this.you(owner) ? 'yours' : `${this.name(owner)}’s`}.`);
  if (v.phase !== 'play') return this.hint(v.phase === 'over' ? 'The game’s over.' : 'Choose your tickets first.');
  if (!this.myTurn()) return this.hint(`It’s ${this.name(v.turn)}’s turn.`);
  if (v.offer) return this.hint('Choose your tickets first.');
  if (v.tunnel) return this.hint('Pay for the tunnel, or take your cards back, first.');
  if (v.drawn) return this.hint('Draw your second card: a claim is a whole turn.');
  const colors = v.can.claim?.[rid];
  if (!colors) { this.pickRoute(null); return this.hint(this.whyNot(r)); }
  // Claiming can't be taken back, so it's never one press (a slip never
  // spends cards): one way to pay picks the route, and a second press on
  // it (or the dock's Claim) claims it; a choice of colors, or a tunnel
  // to explain, is the chooser's.
  if (colors.length > 1 || (r.tunnel && assistOn('tunnel'))) { this.pickRoute(null); return this.claimSheet(rid); }
  if (this.picked === rid) return this.claim(rid, colors[0]);
  this.pickRoute(rid);
  const pay = payment(v.hand, r.length, colors[0], r.ferry);
  // (the status line says it; this says it to a screen reader)
  this.el.hintSay.textContent = `${this.routeName(rid)}: ${payWords(pay)}, +${POINTS[r.length]}. ${how === 'mouse' ? 'Click' : 'Tap'} it again, or press Claim.`;
}

A press is matched by geometry, not by SVG element: the nearest city, else the nearest route, within 10px of a mouse or 22px of a finger. On a crowded spot of a small map (the Straits on a phone, a double’s two routes), a finger zooms in rather than guessing, though a city still opens. A press on nothing lets go of whatever is picked or spotlit, as Escape does. A city’s sheet offers its station and its routes, each with a Claim button when you can.

games/static/games/michigan-rails/board.jsBoard.hit · lines 331–360
/** What's under a point on screen: the nearest city within reach, else
 * the nearest route. Reach is a finger's on a touch screen. `ambiguous`:
 * the next nearest city or route is within a few pixels of it, so a
 * finger can't tell them apart at this zoom. */
hit(cx, cy, type = 'mouse') {
  const [x, y] = this.toMap(cx, cy);
  const s = this.scale();
  const reach = (type === 'mouse' ? 10 : 22) / s;  // in map units
  let city = null, dc = Infinity, dc2 = Infinity;
  for (const id of this.cityIds) {
    const c = this.map.cities[id];
    const d = Math.hypot(c.x - x, c.y - y);
    if (d < dc) { dc2 = dc; dc = d; city = id; } else if (d < dc2) dc2 = d;
  }
  let route = null, dr = Infinity, dr2 = Infinity;
  for (const r of this.map.routes) {
    let best = Infinity;
    for (const [px, py] of this.geo[r.id].samples) best = Math.min(best, (px - x) ** 2 + (py - y) ** 2);
    best = Math.sqrt(best);
    if (best < dr) { dr2 = dr; dr = best; route = r.id; } else if (best < dr2) dr2 = best;
  }
  const close = 10 / s;  // ten pixels on screen
  // On a city's stop it's the city; near one, the city unless a route is
  // plainly nearer (a tap on a route's middle is the route's).
  if (dc <= Math.max(7.5, 6 / s) || (dc <= reach && dc <= dr * 1.25)) {
    return { kind: 'city', id: city, ambiguous: dc2 - dc < close || dr - dc < close };
  }
  if (dr <= Math.max(5.5, reach)) return { kind: 'route', id: route, ambiguous: dr2 - dr < close };
  return null;
}

Each route in the map file lists its cars, one [x, y, angle] per space from its first city to its second, so a bent route’s cars sit exactly where the build put them, and a claim lays them in that order. The route’s path (a polyline) is what presses are measured against and what highlights follow. measure() reads all this once, and shares a path among its spaces for a map without cars.

Pinch, drag, the wheel or the + − Fit strip zoom up to 5×, kept per browser (uo:michigan-rails:zoom). Keys: M goes to the map, arrows move to the nearest city that way, Enter opens it, T takes tickets, C reopens the ticket choice, P passes, L opens the log and ? How to play.

The ticket choice is a sheet with a small map, each ticket a numbered line between its cities. A pending tunnel is a sheet too, with the three cards turned, the extra cost, and Pay or Take my cards back. Both open once the motion catches up, and the dock can reopen them.

Assists#

Six helpers are on by default, each with its own Options row (uo:michigan-rails:assist-<key>), plus Banners:

games/static/games/michigan-rails/table.jsHELPERS · lines 47–56
// 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: 'claimable', label: 'Light up routes you can claim', hint: 'On your turn, routes your cards can pay for glow on the map.' },
  { key: 'rings', label: 'Ring your tickets’ cities', hint: 'The two cities of each ticket you haven’t finished are ringed on the map.' },
  { key: 'path', label: 'Show a ticket’s cheapest way', hint: 'Point at or tap a ticket and the fewest trains that would link its cities are drawn bold in its spotlight.' },
  { key: 'done', label: 'Mark tickets done', hint: 'A ticket whose cities your routes (and stations) link is stamped done.' },
  { key: 'lastround', label: 'Warn when the last round starts', hint: 'A banner when someone’s down to two trains or fewer: everyone has one more turn.' },
  { key: 'tunnel', label: 'Say what a tunnel might cost', hint: 'Before you lay cards for a tunnel, how many more it could take and what you have to spare.' },
];

Pointing at or tapping a ticket spotlights it (Board.spotlight()): the map dims, the two cities pulse, and the way between them glows. A tap pins it. The cheapest way is the one rule the browser computes itself, from public information: cheapest() is Dijkstra over routes still open to you, where yours cost nothing, others’ are closed, and a double’s twin closed to you is left out. Whether a ticket is “done” comes from the server (view()’s ticket done).

Motion, the log, the look#

Every move is animated, so the game can be followed without the log. Each event is a cue on the room’s queue (Cues, long enough to hold a Brisk bot’s moves), skipped only under reduced motion or when the tab is hidden; when your turn comes it catches up, but a claim and a tunnel are held cues, so the move just before yours always plays (the front end). Draws fly cards to the drawer, a claim flies its cards to the discards and lays its cars one by one with a “+N”, a tunnel turns its three cards large mid-map, tickets slide to the drawer, and a station drops onto its city. Each cue’s end sets the player’s last-move text and what stays lit, and a claim’s moves the rows on: a row’s trains and points are the table’s own copy (shown, from asServer()), changed as the cars are laid, not when the update arrives. When cues are idle, reconcile() redraws from the server’s state.

Two things are said in a toast once they’ve played (ADR 0054): someone else’s claim that was on your cheapest way to finish a ticket (crossedBy(): “Ada claimed Lansing – Flint: it was on your way from Detroit to Grand Rapids.”), and three face-up locomotives clearing the yard.

Whose turn it is. When the turn passes, the new player’s row pulses (.prow.handoff). When it returns to you, the status line and a “Your turn” banner list what each player did since your move, in turn order (“Since your move: Lou claimed Lakeport – Detroit · +7 · Mabel took orange and orange”), and a screen reader hears the same. This exists because in David’s first family game, a tunnel and two quick bot moves made it look as if the turn never passed.

games/static/games/michigan-rails/table.jsRailsTable.sinceYours · lines 532–544
/** What everyone else did since your last move, in turn order, from their
 * rows ("Lou claimed Lakeport – Detroit · +7"); none before a whole round
 * has been played. */
sinceYours() {
  const { v, t } = this;
  if (t.you === null || v.phase !== 'play' || v.turns < v.n) return [];
  const out = [];
  for (let k = 1; k < v.n; k++) {
    const s = (t.you + k) % v.n;
    if (this.did[s]) out.push(`${this.name(s)} ${this.did[s]}`);
  }
  return out;
}
games/static/games/michigan-rails/table.jsRailsTable.claimCue · lines 781–812
/** A route claimed: the cards spent fly to the discard pile, the
 * claimer's cars are laid one by one along it, and its points float up. */
claimCue(e, spent) {
  const n = this.routes[e.route]?.length || 1;
  const step = 70;
  const shown = Math.min(spent.length, 8);
  return {
    ms: MOTION.flight + shown * step + n * 90 + 600, hold: true,
    play: async signal => {
      if (this.shown.claimed[e.route] === e.seat) return;
      this.draws = null;
      const from = this.you(e.seat) ? this.el.rack : this.placeOf(e.seat);
      await Promise.all(spent.slice(0, shown).map((k, i) => fly(h('span.cardfly', card(k)), from, this.discardsAt(),
        { ms: MOTION.flight, delay: i * step, scale: [0.9, 0.5], fade: [1, 0.4], layer: this.felt, signal })));
      if (signal.aborted) return;
      this.shown.claimed[e.route] = e.seat;
      await this.board.lay(e.route, e.seat, !still());
      this.board.floatPoints(e.route, `+${e.points}`);
      await wait(still() ? 0 : 450, signal);
    },
    end: () => {
      // (counted once, however the cue ended: the map's claim may be shown already)
      if (!this.counted.has(e.id)) {
        this.counted.add(e.id);
        this.shown.points[e.seat] += e.points;
        this.shown.trains[e.seat] -= this.routes[e.route]?.length || 0;
      }
      this.shown.claimed[e.route] = e.seat;
      this.didThis(e.seat, `claimed ${this.routeName(e.route)} · +${e.points}`, { kind: 'route', id: e.route });
    },
  };
}

The look (games/static/games/michigan-rails/michigan-rails.css, games/static/games/michigan-rails/table.css) is a printed railway map on a lake-blue chart table. Each train color has a mark (diamond, sun, star and so on; an engine for the locomotive) on cards and routes, so colors read without color. Claimed routes get raised cars in the seat’s deeper color (--seat-0…--seat-4) with the seat’s shape (carMarkup()), while printed spaces lie flat, so claimed reads apart from open by more than color. Fonts are Big Shoulders Display, Libre Caslon Text and Figtree (SIL Open Font License). The foyer is the card room’s felt, as Get In Line’s is (ADR 0036). docs/michigan-rails.md has the whole look.

The end is told when the last move has played, not when the update saying so arrives: the game’s over event is a cue of its own, after the last move’s, and its end (tellOver()) turns the title to “Game over · Basil wins”, the status line to the winner and the dock to See the result; until then they still say the last round. The win banner (“Basil wins!”, with the winner’s points) is queued once the cues are idle, behind any banner still showing, and the result sheet waits for it to have gone (splash() returns a promise for that), then a beat more, so a spectator never sees the sheet open with the win still to come behind it (ADR 0054).

The result sheet is the house’s: each seat’s routes, tickets made and missed, stations kept, the Michigan Express and total, with everyone’s tickets beneath.

The seat bar (Take my seat back, Replace with bot) sits where cfg.seatBarIn says, a strip over the foot of the map, so it doesn’t crowd the tickets. Without seatBarIn it floats under the top bar (the room kit); it never sits in a dock.

Its look#

Michigan Rails’s personality, in the design system’s personality sheet.

Family Board and party: the room kit in a look of its own (its train cards are its own faces, not a deck). Its foyer is the card room’s felt. Everything else is the room kit’s (Board and party games).
Palette Ink #f4ead2 on its own ground; the one fill #f4ead2; yours and your turn #f5c451; good #96d68c and bad #ff9a85. A printed railway map of Michigan on a deep lake-blue chart table; each route color has its own mark as well.
Faces Big Shoulders Display (a timetable’s condensed capitals) for titles, banners and numbers; Libre Caslon Text italic for the lakes’ names; Figtree for everything else
Corners and edges Panels 12px, buttons 10px; the rail square
Board The map, one SVG in layers: lakes with ripple lines along every shore, Michigan in cream, the lakes’ names in spaced italic capitals, a compass rose, a title cartouche with a key, a checkered border. Routes are a printed car per space in the route’s color with its mark; tunnels dashed over hill hachures.
Emblem and art Its box on the shelf
Voice Routes, tickets, trains, the yard; “Draw or claim a route”
Payoff The room’s celebration for a win
The glance Now: the row’s ring and the Your turn banner. The countdown: trains left. The score: points, bold in each row. Yours: your hand and tickets, with the stock and tickets on their piles.
Exceptions Its rack’s fanned cards under 44px, and a player’s name and last move giving way in its slim rows

Testing#

tests/test_michigan_rails.py has:

  • the rules as sentences: every move, face-up locomotives, ferries, tunnels paid and taken back, doubles at two and four players, stations, the last round, the score and tiebreaks;
  • a TableGameContract case whose scramble changes every secret;
  • bot games played to the end on both maps at every size;
  • MichiganMapTests, holding the map to Europe’s counts (cities, routes, doubles, lengths, tunnels, ferries, colors, each city’s neighbors and tickets), ticket points equal to the fewest trains, and no crossing tracks.

Changing it#

Change What it needs
Move a stop, join or part two cities Edit tools/michigan-rails/stops.py or tools/michigan-rails/network.txt, rebuild the map (tools/michigan-rails/README.md), look at it, run the tests: they insist on Europe’s counts. A table in play keeps its map’s id, and route and ticket ids are the map file’s, so ship a changed map between games, not during a family’s game night.
A second map A new map file, registered in board.PATHS; the host’s map setting offers every registered map. Its own balance checks.
A rule docs/michigan-rails.md first (they’re the published rules: ask David), then rules.py, its tests, and How to play.

Known issues#

  • Five players at 1280×800 see about one and a half tickets at a time. The players’ rows take the column’s height, and the ticket list scrolls under them.
  • The box art doesn’t follow the map. games/templates/games/thumbs/michigan-rails.html draws its own simplified shore and claims, so a changed map leaves it behind.
  • No timer or away handling, as in Get In Line: a turn waits until the host hands the seat to a bot.