Technical guide

Board and party games

Auction House

Go round the board buying, auctioning and selling paintings whose values only their owners know, and finish with the most cash and art.

Checked against the code on 2026-10-30. Play Auction House

On this page
  1. At a glance
  2. The board and the cards
    1. Galleries
  3. The rules in code
    1. The state
    2. A turn
    3. Auctions
    4. The end
  4. What each seat sees
  5. The bots
  6. The interface
    1. Pacing and announcements
    2. Your move
    3. Helpers
    4. Checking it
  7. Its look
  8. Rating

Auction House is an art-auction board game for three to six players, with bots to fill any seat. Players go round a ring of thirty spaces buying, auctioning and selling 24 paintings. Each painting has a secret value, from a worthless forgery to $1,000,000, known only to those who have owned it. When the last painting leaves the bank, every value is revealed, and the most cash plus paintings wins.

It plays the 1970 rulebook of the family classic of its kind on the 1976 board, with the same number of every component. David’s photo of the family’s 1976 board gave the thirty spaces in order; only the name, art and drawing are the site’s own. docs/auction-house.md has the rules as played, where each came from, and the few calls the rulebook left open.

It’s a table game on the table service, with a room-kit table in the browser (the front end) and ratings for kept games (Ratings).

At a glance#

Part Where
Board, value cards, galleries tables/auction_house/board.py
Rules: the deal, a turn, every space, auctions, the end tables/auction_house/rules.py
Bots (easy, medium, hard) tables/auction_house/bots.py
The table games/static/games/auction-house/table.js
The ring and pieces; paintings in frames games/static/games/auction-house/board.js, games/static/games/auction-house/art.js
Room kit entry, How to play games/static/games/auction-house/app.js, games/static/games/auction-house/rules.js
Look games/static/games/auction-house/auction-house.css, games/static/games/auction-house/table.css
Pages games/templates/games/auction-house.html, games/templates/auction-house/page.html
Gallery maker tools/auction-house/build.py
Tests tests/test_auction_house.py
Design notes docs/auction-house.md

The board and the cards#

The board is the 1976 track, space by space, clockwise from the forced sale. A space is a kind and, if it has a price, an amount:

tables/auction_house/board.pyTRACK · lines 28–59
TRACK = (
    (FORCED_SALE, 350 * K),
    (INHERIT, None),
    (COLLECT, 300 * K),
    (MAY_SELL, None),
    (PRIVATE_AUCTION, None),
    (BUY_BANK, 400 * K),
    (COLLECT, 100 * K),
    (BANK_AUCTION, None),
    (PRIVATE_AUCTION, None),
    (BUY_BANK, 300 * K),
    (MAY_SELL, None),
    (COLLECT, 200 * K),
    (PRIVATE_AUCTION, None),
    (MAY_SELL, None),
    (BANK_AUCTION, None),
    (PRIVATE_AUCTION, None),
    (MAY_SELL, None),
    (BUY_BANK, 350 * K),
    (COLLECT, 400 * K),
    (PRIVATE_AUCTION, None),
    (MAY_SELL, None),
    (BUY_BANK, 250 * K),
    (BANK_AUCTION, None),
    (BUY_BANK, 400 * K),
    (PRIVATE_AUCTION, None),
    (MAY_SELL, None),
    (BUY_PLAYER, 400 * K),
    (COLLECT, 500 * K),
    (BANK_AUCTION, None),
    (PRIVATE_AUCTION, None),
)

The value cards are the 1970 box’s 24, one per painting, two of them forgeries.

ValueCards
10000001
7500001
7000002
6500001
6000002
5500001
5000002
4500002
4000002
3500001
3000002
2500001
2000002
1500001
1000001
02
Live from tables/auction_house/board.py, VALUES

They add up to $10,100,000, about $420,000 a painting, and the board’s prices are set against that average. The bank sells at $250,000 to $400,000, taking a painting from a player costs $400,000, and the forced sale pays $350,000. So a painting you know is worth more is safer sold, and one worth less (a forgery above all) is better auctioned or forced out.

Galleries#

The table’s gallery decides which artworks hang: a manifest of works (title, artist, date, medium, museum, license, source) beside their images, under games/static/games/auction-house/galleries/. tools/auction-house/build.py makes them from museums’ open-access collections, at 560px for the table and 1200px (in large/) for the close-up. The server reads the manifests to deal the art; the browser, to draw it. A fixed gallery (Classic) hangs all 24, shuffled; any other deals 24 at random from its pool.

tables/auction_house/board.pyworks · lines 108–110
def works(gallery: str) -> list[str]:
    """The ids of every work a gallery can hang."""
    return [w["id"] for w in load(gallery)["works"]]

Value cards are shuffled separately and dealt one to each painting, so the art never hints at the value.

Each work has a kind from its medium: a painting (anything flat) or an object (sculpture, ceramics, metalwork), shown on a plinth instead of in a frame. Each gallery has a noun for its works, so a sculpture game says “Inherit a sculpture”:

tools/auction-house/build.pykind_of · lines 228–231
def kind_of(gallery, medium):
    if gallery == "sculpture":
        return "object"
    return "painting" if FLAT.search(medium or "") else "object"

The rules in code#

The state#

One JSON object, saved whole. A painting is its index, 0 to 23: art[i] is its artwork and value[i] its card. held[i] is None while it’s in the pile, a seat, or "out" once the bank has bought it. seen[i] lists every seat that has owned it, the only ones who know its value, until a sale at value reveals it to everyone (shown[i]).

tables/auction_house/rules.pynew · lines 70–102
def new(players: int, seed: str, now: float, settings: dict | None = None) -> dict:
    if not min_players <= players <= max_players:
        raise Illegal("Auction House is for three to six players.")
    rules = options(settings)
    gallery = board.load(rules["gallery"])
    pool = [w["id"] for w in gallery["works"]]
    if len(pool) < PAINTINGS:
        raise Illegal("That gallery hasn't enough works to hang.")
    hung = deck.shuffled(pool, f"{seed}:gallery")
    art = hung[:PAINTINGS] if gallery.get("fixed") else deck.shuffled(hung, f"{seed}:pick")[:PAINTINGS]
    values = deck.shuffled([str(v) for v in VALUE_CARDS], f"{seed}:values")
    first = int.from_bytes(seed.encode(), "big") % players
    state = {
        "game": slug, "n": players, "seed": seed, "gallery": gallery["id"],
        "rules": {"gallery": gallery["id"], "clock": rules["clock"]},
        "_pace": moves.BOT_PACE[rules["bot_pace"]], "clock": CLOCKS[rules["clock"]],
        "seq": 0, "logged": 0, "events": [],
        "art": art, "value": [int(v) for v in values],
        # The pile: the painting on top is the last; the value cards are
        # dealt with them, so value[i] is the card painting i draws.
        "pile": list(range(PAINTINGS - 1, -1, -1)),
        "held": [None] * PAINTINGS, "seen": [[] for _ in range(PAINTINGS)], "shown": [False] * PAINTINGS,
        "price": [None] * PAINTINGS,  # what the bank paid for one it bought
        "cash": [START_CASH] * players, "pos": [None] * players,
        "phase": "start", "first": first, "turn": None, "turns": 0, "rolls": 0, "dice": None, "moves": None,
        "pending": None, "auction": None, "auctions": 0, "last": None,
        "final": None, "winners": None, "scores": [], "totals": [0] * players, "ready": [False] * players,
    }
    # Each player in turn, beginning with the first, draws a painting and its value card.
    for k in range(players):
        _draw(state, (first + k) % players)
    log.event(state, "start", first=first, gallery=gallery["id"])
    return state

First everyone places their piece on a space at once (place), without acting on it. Then each turn goes roll, move (the roller picks a direction), decide (a space asks a question) or auction, round the table, until over.

A turn#

A roll is two dice, seeded from the table’s seed and the roll count, so a replayed table rolls the same. The roll offers the two spaces that many steps away, one each way round (moves), and the roller must take one, even if neither is good. The rulebook says clockwise only; both ways, and no staying put, are David’s calls after playtesting.

tables/auction_house/rules.pydestinations · lines 161–164
def destinations(start: int, steps: int) -> list[int]:
    """The spaces a roll can reach: clockwise first, then counterclockwise
    (just the one, should the two ever be the same space)."""
    return list(dict.fromkeys([(start + steps) % SPACES, (start - steps) % SPACES]))
tables/auction_house/rules.pymove · lines 167–179
def move(state, seat, action, now):
    """Move to one of the two spaces the roll reaches, and do what it says."""
    _my_turn(state, seat, "move")
    space = action.get("space")
    if type(space) is not int or space not in state["moves"]:
        raise Illegal("Move to one of the spaces your roll reaches.")
    start = state["pos"][seat]
    way = "cw" if space == state["moves"][0] else "ccw"
    state["pos"][seat] = space
    state["moves"] = None
    kind, amount = TRACK[space]
    log.event(state, "move", seat=seat, start=start, land=space, way=way, space=kind, amount=amount)
    _land(state, seat, space, now)

Then _land does what the space says:

tables/auction_house/rules.py_land · lines 182–227
def _land(state: dict, seat: int, space: int, now: float) -> None:
    """Do what the space says."""
    kind, amount = TRACK[space]
    mine = owned(state, seat)
    if kind == COLLECT:
        state["cash"][seat] += amount
        log.event(state, "collect", seat=seat, amount=amount)
        return _next(state)
    if kind == INHERIT:
        if not state["pile"]:
            return _next(state)
        i = _draw(state, seat)
        log.event(state, "inherit", seat=seat, painting=i, art=state["art"][i])
        return _next(state)
    if kind == BANK_AUCTION:
        if not state["pile"]:
            return _next(state)
        return _open(state, state["pile"][-1], "bank", seat, now)
    if kind == BUY_BANK:
        if not state["pile"]:
            return _next(state)
        if state["cash"][seat] < amount:
            # Not buying, for want of the price: it goes to auction without them.
            log.event(state, "decline", seat=seat, painting=state["pile"][-1], price=amount, short=True)
            return _open(state, state["pile"][-1], "bank", seat, now, barred=[seat])
        return _ask(state, seat, "buy", price=amount, painting=state["pile"][-1])
    if kind == MAY_SELL:
        return _ask(state, seat, "sell") if mine else _next(state)
    if kind == BUY_PLAYER:
        if state["cash"][seat] >= amount and any(h not in (None, "out", seat) for h in state["held"]):
            return _ask(state, seat, "take", price=amount)
        return _next(state)
    if kind == FORCED_SALE:
        if not mine:
            return _next(state)
        if len(mine) == 1:
            return _forced_sale(state, seat, mine[0], amount)
        return _ask(state, seat, "forced", price=amount)
    if kind == PRIVATE_AUCTION:
        if not mine:
            return _next(state)
        if len(mine) == 1:
            return _open(state, mine[0], seat, seat, now)
        # The player on the lander's left chooses which goes on the block.
        return _ask(state, _left(state, seat), "pick", owner=seat)
    raise ValueError(kind)

A space that asks something sets pending, the seat that must answer and the question: buy (the bank’s offer at the space’s price), sell (one painting at its value, or none), take (another player’s painting for $400,000, or none), forced (which painting to sell for $350,000) and pick (the player on the lander’s left picks which of the lander’s paintings goes up). A choice with one answer isn’t asked: a lone painting is sold or auctioned straight away.

Auctions#

Auctions are live. _open puts a painting on the block with its seller (the bank or the lander) and who may not bid (barred: the owner at a private auction, or a lander who passed on the bank’s offer). Anyone else may bid at any moment, at least 100,000 to open and 50,000 more each time, never more than their cash:

tables/auction_house/rules.pybid · lines 377–399
def bid(state, seat, action, now):
    a = state["auction"]
    if state["phase"] != "auction" or not a:
        raise Illegal("Nothing is on the block.")
    if seat in a["barred"]:
        raise Illegal("You can't bid in this auction." if a["seller"] != seat else "You can't bid on your own painting.")
    if seat in a["out"]:
        raise Illegal("You've dropped out of this auction.")
    if a["high"] and a["high"]["seat"] == seat:
        raise Illegal("You're already the high bidder.")
    amount = action["amount"]
    least = next_bid(state)
    if type(amount) is not int or amount % RAISE:
        raise Illegal("Bid in fifty-thousands.")
    if amount < least:
        raise Illegal(f"The bid is already ${least - RAISE:,}." if a["high"] else f"Bidding opens at ${OPENING_BID:,}.")
    if amount > state["cash"][seat]:
        raise Illegal("You haven't the cash for that bid.")
    a["high"] = {"seat": seat, "amount": amount}
    a["bids"] += 1
    a["closes"] = now + state["clock"]
    log.event(state, "bid", seat=seat, amount=amount)
    _settle(state, now)

The gavel falls at the auction’s closes: the table’s clock (the host’s choice, below) after the last bid, plus 6 seconds before the first bid. tick closes it on time and deadline tells the service when, so an idle table still sells. It also closes at once when everyone who could top the high bid has dropped out:

tables/auction_house/rules.py_settle · lines 416–436
def _settle(state: dict, now: float) -> bool:
    """The gavel falls when time's up, or at once when everyone else is out."""
    a = state["auction"]
    if not a or (now < a["closes"] and bidders(state)):
        return False
    i, high, seller = a["painting"], a["high"], a["seller"]
    state["auction"] = None
    if not high:
        log.event(state, "no_sale", painting=i, art=state["art"][i], seller=seller)
        _next(state)
        return True
    seat, amount = high["seat"], high["amount"]
    state["cash"][seat] -= amount
    if seller == "bank":
        state["pile"].pop()
    else:
        state["cash"][seller] += amount
    _give(state, i, seat)
    log.event(state, "sold", painting=i, art=state["art"][i], seat=seat, price=amount, seller=seller)
    _next(state)
    return True
ClockSeconds
quick6
steady10
leisurely15
Live from tables/auction_house/rules.py, CLOCKS

A player who can’t cover the next bid stays in until they drop out or the clock runs out. Closing early for them would reveal they’re short of cash, which is secret. So bidders counts everyone not barred, not out and not the high bidder, whatever they hold.

tables/auction_house/rules.pybidders · lines 360–369
def bidders(state: dict) -> list[int]:
    """Who is still in the auction: not barred, not dropped out, not already
    the high bidder. Someone short of the next bid is still in until they
    drop out (or the clock runs out): the table never learns another
    player's cash from the gavel."""
    a = state["auction"]
    if not a:
        return []
    high = a["high"]["seat"] if a["high"] else None
    return [s for s in range(state["n"]) if s not in a["barred"] and s not in a["out"] and s != high]

Bids don’t hold up the table (holds_up), since the gavel falls by itself. A player whose page stops checking in is dropped out (away), so the clock needn’t run down.

Bots in an auction think in one of three times in turn, so no seat’s bot always speaks first. The first bid of an auction is an open kind, longer, so the auction’s splash in the browser plays out before anyone bids:

tables/auction_house/rules.pydeciding · lines 513–528
def deciding(state: dict) -> list[tuple[int, str, str]]:
    phase = state["phase"]
    if phase == "start":
        return [(s, "start", "start") for s in range(state["n"]) if state["pos"][s] is None]
    if phase == "roll":
        return [(state["turn"], "roll", f"t{state['turns']}")]
    if phase == "move":
        return [(state["turn"], "move", f"t{state['turns']}-move")]
    if phase == "decide":
        p = state["pending"]
        return [(p["seat"], p["kind"], f"t{state['turns']}-{p['kind']}")]
    if phase == "auction":
        a = state["auction"]
        kinds = RAISE_KINDS if a["bids"] else OPEN_KINDS
        return [(s, kinds[(s + a["bids"]) % len(kinds)], f"a{a['id']}-{a['bids']}") for s in bidders(state)]
    return []

The end#

_next passes the turn left until the last painting leaves the pile. Then _finish reveals every card, adds each seat’s cash and paintings, and places them. Scores are kept in thousands, because score columns are whole numbers people read.

tables/auction_house/rules.py_finish · lines 458–474
def _finish(state: dict) -> None:
    n = state["n"]
    seats = [{"cash": state["cash"][s], "paintings": [{"painting": i, "art": state["art"][i], "value": state["value"][i]}
                                                      for i in owned(state, s)],
              "worth": worth(state, s)} for s in range(n)]
    totals = [x["cash"] + x["worth"] for x in seats]
    for s, x in enumerate(seats):
        x["total"] = totals[s]
    places = rounds.places(totals, high=True)
    state["final"] = {"seats": seats, "places": places}
    state["winners"] = [s for s in range(n) if places[s] == 1]
    state["turn"] = None
    state["auction"] = None
    log.event(state, "over", totals=totals, winners=state["winners"])
    # Scores are kept in thousands: a table's score columns are whole numbers people read.
    rounds.score(state, [t // 1000 for t in totals])
    rounds.end(state, last=True)

What each seat sees#

view is the only way anything reaches a seat. A seat sees its own cash and no one else’s, a painting’s value only if it has owned it or the value was shown at a sale, and the pile’s top painting and count but not what’s under it. The value chart lists every card plus the values this seat has seen, which the table crosses off. At the end, everything is shown.

tables/auction_house/rules.py_card · lines 578–584
def _card(state: dict, i: int, seat) -> dict:
    """A painting as a seat sees it: its artwork, where it is, and its value
    if this seat knows it."""
    out = {"painting": i, "art": state["art"][i]}
    if _knows(state, seat, i):
        out["value"] = state["value"][i]
    return out

The contract tests scramble every secret a seat mustn’t see (unseen values, others’ cash, the pile under its top, the dice seed) and check its view and its bot’s moves don’t change.

The bots#

Bots decide from their seat’s view. An unknown painting is worth the average of the cards they haven’t seen; a known one is worth its card.

tables/auction_house/bots.pyguess · lines 55–58
def guess(v: dict) -> float:
    """What a painting this seat doesn't know is worth, on average."""
    left = unseen(v)
    return sum(left) / len(left) if left else 0.0

After a roll they go toward the space worth more to them: cash, a bargain, a forgery forced out at $350,000, or a private auction of a painting worth less than others will bid. Easy sometimes just goes on round.

tables/auction_house/bots.pyspace_worth · lines 74–102
def space_worth(v: dict, seat: int, i: int) -> float:
    """Roughly what landing on space i gains this seat, by its own view:
    cash collected, a painting's worth over its price, what a private
    auction or the forced sale would fetch over what it gives up."""
    kind, amount = TRACK[i]
    mine = v["hung"][seat]
    cash = v["cash"][seat]
    avg = guess(v)
    pile = v["pile"]["count"] > 0
    if kind == COLLECT:
        return amount
    if kind == INHERIT:
        return avg if pile else 0
    if kind == BUY_BANK:
        return max(0.0, avg - amount) if pile and cash >= amount else 0
    if kind == BANK_AUCTION:
        return avg * 0.1 if pile else 0  # a chance at a bargain, for anyone
    if kind == BUY_PLAYER:
        theirs = [c for s, row in enumerate(v["hung"]) if s != seat for c in row]
        best = max((worth(v, c) for c in theirs), default=0)
        return max(0.0, best - amount) if cash >= amount else 0
    if kind == PRIVATE_AUCTION:
        # The others bid about an unknown painting's worth for one of ours.
        return sum(avg - worth(v, c) for c in mine) / len(mine) if mine else 0
    if kind == FORCED_SALE:
        return amount - min(worth(v, c) for c in mine) if mine else 0
    if kind == MAY_SELL:
        return avg * 0.05 if mine else 0  # a way to bank one, if it's wanted
    return 0

Every bot keeps to the table’s beats, so people can follow its turn: a roll waits for the last move to walk round and what its space did to be shown, a move for the dice to be seen, a first bid for the splash (docs/auction-house.md, “Pacing”):

tables/auction_house/rules.pybot_think · lines 34–40
# Bots keep to the table's beats (table.js), so people can follow them: a
# roll waits for the last move to walk round and what its space did to be
# shown, a move for the dice to be seen. Bids come from three think times in
# turn, so no seat's bot always speaks first; the first bid in an auction
# waits for its splash to play out.
bot_think = {"start": 1.4, "roll": 2.8, "move": 1.6, "buy": 2.2, "sell": 2.2, "take": 2.4, "forced": 2.0, "pick": 2.2,
             "open0": 4.2, "open1": 5.0, "open2": 5.8, "bid0": 1.3, "bid1": 1.9, "bid2": 2.6}

In an auction they raise by the smallest step while the price is below their estimate minus a margin, then drop out. They buy from the bank below that estimate, sell to the bank a painting too valuable to keep safely, take a painting they know is worth over $400,000, and sell their least valuable when forced.

LevelMarginNoiseSells atChance
easy0.050.34500000.25
medium0.120.065500000
hard0.080None0
Live from tables/auction_house/bots.py, LEVEL

Hard judges what’s safe to keep by how much game is left. A valuable painting can be auctioned off at a private auction (seven spaces) or taken for $400,000, so early on it sells anything worth more than an unknown painting to the bank, and near the end, when a painting counts in full, it keeps more.

tables/auction_house/bots.py_at_risk · lines 65–71
def _at_risk(v: dict) -> float:
    """Above this, a painting is safer as cash: kept, it may go to a private
    auction (eight spaces on the board) at about an unknown painting's
    price, or be bought away for $400,000. Near the end the risk is
    small: what it's worth counts in full."""
    left = v["pile"]["count"]
    return guess(v) * (0.9 if left > 6 else 1.0 + (6 - left) * 0.15)

Hard also marks down a player’s painting at a private auction (if it were worth much, its owner might have sold it to the bank) and bids to full worth in the last few paintings. It’s a lucky game: in simulations the levels finish within a few percent of each other’s wealth, hard ahead.

The interface#

games/static/games/auction-house/app.js starts the room with the galleries’ index, so the host can pick one in the waiting room, and the table’s three settings:

tables/auction_house/rules.pyoptions · lines 57–65
def options(raw: dict | None) -> dict:
    """A table's settings: the gallery that hangs, the auction clock and how
    fast the bots play. The rules leave nothing else to choose."""
    galleries = board.ids() or (board.DEFAULT,)
    return {
        "gallery": moves.choice(raw, "gallery", galleries, board.DEFAULT if board.DEFAULT in galleries else galleries[0]),
        "clock": moves.choice(raw, "clock", tuple(CLOCKS), "steady"),
        "bot_pace": moves.choice(raw, "bot_pace", tuple(moves.BOT_PACE), "brisk"),
    }

The table (games/static/games/auction-house/table.js) draws the ring of spaces around the block, where the painting at stake stands on an easel with its museum label. In an auction the block shows the high bid and the gavel’s countdown, ending “Going once…”, “Going twice…”:

games/static/games/auction-house/table.jsAuctionTable.gavelWords · lines 1118–1130
/** The gavel's words, by the seconds left: the high bid, then "Going once",
 * "Going twice". */
gavelWords(secs) {
  const a = this.v?.auction;
  if (!a) return '';
  const third = this.v.clock / 3;
  if (secs <= 0) return a.high ? 'Sold!' : 'No sale';
  // (a phone's small block has room for the seconds alone)
  if (!a.high) return this.board.block.clientWidth < 230 ? `${secs}s` : `Bidding open · ${secs}s`;
  if (secs <= third) return 'Going twice…';
  if (secs <= 2 * third) return 'Going once…';
  return `${secs}s`;
}

The block’s words keep to their lines (a long title wraps to two, one on a phone), and the easel, a size container of its own, takes the remaining height, so a long title never pushes the bid or gavel over the ring. The velvet is a circle, so the block is a box whose corners stay inside it, 66% by 70% of it, with room before the spaces, and none of its words is under 12px (the design language’s “Words have room to breathe” and “Readable”; it was 76% by 82%, and its corners reached the spaces).

The spaces’ words are drawn on the board, so they scale with it (games/static/games/auction-house/board.js’s space()): each line as large as its wedge holds it with room. The first line, in capitals, is 15 units in the 1000-unit board (“BUY FROM” is 82 units where the wedge is 84 across), and the second 15 (“a sculpture” is 79 where it’s 88), set 20 units out from the ring’s middle line, where the wedge is wider and the emblem clears a piece on the space. That’s about 11.7px on a computer and less on a phone, which zooms to read them. While you choose which way to move, the spaces you can’t reach are dimmed (.boardsvg.moving .space:not(.cand)), a state like a disabled control’s, so their words aren’t held to 4.5:1 then. The table’s own small words (Your cash, the turn corner’s “Your turn”, the value chips, a painting’s tag, the result’s table) are 12px at least.

The board’s top corners hold the game’s state at a glance: the paintings left in the bank’s pile, the game’s Figure (the house’s .house-figure, a number in ink named in soft ink under it: the game’s clock, which ends at none), and whose turn. On a phone on its side the corners sit in a column of their own left of the board, since in its corners they’d run into the rim.

Next from the bank (drawBank) is always on screen beside the board, at the top of the side and outside what scrolls (David’s call at the 2026-10-29 playtest, so the block can keep summing up what just happened). The rules’ pile is face up, and the view already gives every seat and spectator its top (pile.top, the value only to whoever knows it), never what’s under it. The strip says what that painting is doing: next, for sale while a Buy from the bank asks, up for auction while it’s on the block, or that the pile is empty. It changes as the table’s beats play, not when the update arrives: news() holds it (bankHold) while a move that changes the pile (PILE_EVENTS) is still to show, and each such cue’s end says what it shows next (bankAt), so a bank auction’s painting is still beside the board when its splash deals it onto the easel from there (the intro’s dealFrom; with the intro off, a flight), and an inherited painting flies from there to its heir:

games/static/games/auction-house/table.jsAuctionTable.drawBank · lines 901–939
/** The bank's next painting, beside the board and never off screen (the
 * pile is face up, docs/auction-house.md: its top is everyone's to see):
 * what an Inherit, a Buy from the bank or a bank auction takes next. While
 * it's up for sale or on the block it says so; with the pile empty, that.
 * It changes as the table's beats play (bankHold, bankFor), so a painting
 * going up is still here to fly onto the easel. */
drawBank() {
  const v = this.v;
  if (!v || this.bankHold) return;
  const at = this.bankFor;
  const p = v.pending;
  let mode, card = null;
  if (at) ({ mode, card } = at);
  else if (!v.pile.count || !v.pile.top) mode = 'empty';
  else if (v.auction?.seller === 'bank') mode = 'block';
  else if (p?.kind === 'buy') mode = 'sale';
  else mode = 'next';
  if (mode !== 'empty' && !card) card = v.pile.top;
  if (mode === 'next' && !card) mode = 'empty';
  const key = [this.gal?.id, mode, card?.painting, mode === 'sale' ? p?.price : null, this.one];
  if (!this.changed('bank', key)) return;
  const work = this.work(card);
  const kicker = { next: 'Next from the bank', block: 'Up for auction now', sale: `For sale by the bank · ${short(p?.price || 0)}`, empty: 'Next from the bank' }[mode];
  // (prefixed: a bare "block" is the board's own block)
  this.el.bank.className = `bankstrip bank-${mode}`;
  this.el.bank.replaceChildren(
    mode === 'empty'
      ? h('span.bankart', h('span.frame.empty', { 'aria-hidden': 'true' }))
      : h('span.bankart', framed(this.gal?.id, work, card, {
        size: 0, tag: 'value' in card, cls: 'house-reach', lazy: false,
        label: work ? `${work.title}, ${work.artist}: look closer` : null,
        onclick: () => this.look([card]),
      })),
    h('span.banktext',
      h('span.bankkicker', kicker),
      mode === 'empty'
        ? [h('b.banktitle', `No ${this.many} left`), h('span.banksub', 'The bank’s pile is empty.')]
        : [h('b.banktitle', named(work)), work ? h('span.banksub', byWhom(work)) : null]));
}

The game’s end is the house’s result sheet (scores), set 600px wide like the other table games’ so its three buttons keep to one row. Put away, the dock’s main button is Play again for the host, with See the result beside it.

Your cash is in the dock, beside what to do, so it’s on screen on a phone and in every auction. The dock’s line says what’s happening in words, built for the pending question’s kind alone: a private auction’s pick or a may-sell has no price to word (a line for every kind, built at once, once read a missing price and threw for every seat but the chooser).

games/static/games/auction-house/table.jsAuctionTable.drawCorners · lines 944–973
/** The board's corners, the game's state at a glance: how many are left
 * in the bank's pile (the game ends when it's empty), and whose turn. */
drawCorners() {
  const v = this.v;
  const n = v.pile.count;
  if (this.changed('pile', [n, v.phase, this.one])) {
    const was = this.board.pileCorner.dataset.n;
    this.board.pileCorner.dataset.n = n;
    // (the countdown is the game's Figure, house rule 4: its number in ink,
    // named in soft ink under it)
    this.board.pileCorner.replaceChildren(
      h('b.cornernum.house-figure', { 'aria-hidden': 'true' }, v.phase === 'over' ? '0' : String(n)),
      h('span.house-figure-noun', { 'aria-hidden': 'true' }, `${n === 1 ? this.one : this.many} left`),
      h('span.sr-only', v.phase === 'over' ? `No ${this.many} left: sold out` : `${plural(n, this.one)} left in the bank’s pile`));
    if (was !== undefined && was !== String(n)) pulse(this.board.pileCorner);
  }
  const turn = v.phase === 'over' || v.phase === 'start' ? null : v.turn;
  // (a turn a bot is playing for you isn't yours to take: it's "Turn · You", in plain ink)
  const yours = turn !== null && this.you(turn) && !this.botted();
  if (this.changed('turncorner', [turn, v.phase, turn !== null && this.who(turn), yours])) {
    this.board.turnCorner.replaceChildren(...(turn === null
      // (before the first turn, no one's: the block says who's still choosing a place)
      ? (v.phase === 'start' ? [] : [h('span.cornerlabel', 'Sold out')])
      : [h('span.cornerlabel', yours ? 'Your turn' : 'Turn'),
        h('span.cornerwho', seatMark(turn, this.marks()[turn], { size: 26 }), h('b', this.who(turn)))]));
    this.board.turnCorner.classList.toggle('mine', yours);
    if (turn !== null && this.turnShown !== undefined && this.turnShown !== turn) pulse(this.board.turnCorner);
    this.turnShown = turn;
  }
}

A space’s card (games/static/games/auction-house/board.js, spaceRule, showCard) says what landing there does, in How to play’s words, over the board’s middle: at once under a mouse or the keyboard’s focus. On a touch screen a finger presses and holds a space and lets go to put it away: the room kit’s hold() (games/static/games/room/kit/hold.js, 450ms, the same hold as Last One Standing’s cards), where a drift of 10px first cancels, that lift is no tap, and a plain tap still moves.

games/static/games/auction-house/board.jsspaceRule · lines 56–72
/** What landing on a space does, in How to play's words (rules.js), for
 * its card: [title, the amount if it has one, what happens]. */
export function spaceRule(i, one = 'painting') {
  const [kind, amount] = TRACK[i];
  const m = amount ? money(amount) : null;
  const many = `${one}s`;
  switch (kind) {
    case 'collect': return ['Collect', m, 'The bank pays you this much.'];
    case 'inherit': return [`Inherit a ${one}`, null, `Take the top ${one} from the bank’s pile, free, with its value card.`];
    case 'bank': return ['Bank auction', null, `The top ${one} of the bank’s pile is auctioned. Everyone may bid, you too. The winner pays the bank.`];
    case 'buy': return ['Buy from the bank', m, `You may buy the top ${one} of the bank’s pile at this price. Pass (or be short of it), and it’s auctioned without you.`];
    case 'private': return ['Private auction', null, `One of your ${many} is auctioned to the others, and the winner pays you. If you have more than one, the player on your left chooses which. With none, nothing happens.`];
    case 'sell': return ['May sell to the bank', null, `You may sell one of your ${many} to the bank at its value, shown to everyone. Or keep them all.`];
    case 'take': return ['Buy from a player', m, `You may buy any one ${one} another player owns at this price, or none. They must sell.`];
    default: return ['Sell to the bank', m, `You must sell one of your ${many} to the bank at this price, whatever it’s worth; its value card stays hidden. With none, nothing happens.`];
  }
}

Pieces sharing a space stack on it, fanned no wider than the space so its words stay clear: the turn’s on top, else the last to arrive, with a count from three up.

games/static/games/auction-house/board.jsBoard.spot · lines 317–322
/** Where the k-th of n pieces on a space stands (the top one last): along
 * the ring's inner edge, fanned no wider than the space. */
spot(space, k, n) {
  const step = n > 1 ? Math.min(3.2, 4.4 / (n - 1)) : 0;
  return at(R_PIECE, angleOf(space) + (k - (n - 1) / 2) * step);
}

After a roll the two reachable spaces light up in the turn’s color while the rest dim. Press either on the board or by its button in the dock (“↺ Collect $500K” on the left for counter-clockwise, “Bank auction ↻” on the right). The two are equals: one width (the dock’s .equals), and neither filled, since neither is the safe move (the design language’s “Alike looks alike”). The piece then walks round, and the player’s row shows the dice. Each seat’s dice are kept as its roll arrives (diceOf), because a bot’s roll and move come together and yours can arrive while earlier moves are still animating. A move whose roll the page never saw says where it went, never the dice of whoever rolled last (walkCue takes lastRoll only when it’s the same seat’s).

Choosing where to start, the dock also offers Choose from a list (placeList): every space as a button a finger’s reach tall, round the ring clockwise from the top, with its place on the clock face and who’s there, since on a small phone a space is narrower than a finger.

Pacing and announcements#

Someone else’s turn plays in beats people can follow (the 2026-10-29 playtest found it too fast and too quiet), each a cue in the room kit’s queue (the front end):

games/static/games/auction-house/table.jsBEAT · lines 44–48
// The table's beats, so everyone can follow someone else's turn (the bots
// keep to them: rules.py bot_think): the dice held a moment, a piece's
// walk a space at a time, what its space did held on the block, Sold! held
// over the painting, a new auction's splash.
const BEAT = { dice: 700, step: 170, news: 1500, sold: 2400, splash: 3000 };

The dice are held a moment, the piece walks a space at a time, and what its space did is held on the block under the dice (newsCue), with a painting it dealt them on the easel; a move whose space does nothing says so. “Sold!” is held over the painting with who bought it, for how much and whose it was. A new auction opens with a splash over the board (auctionSplash, the room kit’s intro(), as Contract Rummy’s hand intro): the painting dealt onto an easel, whose it is and who can’t bid, and a gilt seal, “Bidding open!”, shrinking into the block. Options’ Auction intro (Playing) turns the splash off: it’s the room kit’s intro switch (introOption), as both rummies’ hand intros are.

Something done to you on anyone’s turn (your painting bought from you, your private auction sold, an auction won) is the house’s announcement (tellYou, announce() from the house kit, ADR 0051): a band over the board with the painting slammed down beside the words, “Basil bought Water Lilies from you”, “$400,000, paid to you”, in the same words as the block’s line (“Bought it from you”). Then your collection lights up. It isn’t switched off by Banners (only “Your turn” is), and it’s read out by the log, not twice. A spectator isn’t told.

games/static/games/auction-house/table.jsAuctionTable.tellYou · lines 831–845
/** Something done to you on anyone's turn (your painting bought, your
 * private auction sold, an auction won): the house's announcement over
 * the board (docs/design-language.md, "Done to you"), the painting
 * slammed down beside the words, whatever you were doing and whatever
 * Banners says; then your collection lights up. The log says it to a
 * screen reader, so the announcement isn't read out too. */
tellYou({ kicker, title, sub }, e) {
  if (this.room.spectating || this.t.you === null) return;
  if (document.hidden) chime();
  const work = this.work(e);
  announce({ kicker, title, sub, art: work ? framed(this.gal?.id, work, null, { size: 84, tag: false }) : null }, {
    over: this.board.el, read: false, className: 'ah-announce',
    onend: () => { if (!this.closed) pulse(this.el.mine); },
  });
}

Under reduced motion the splash fades in place, the walk is a step, and the holds stay. A seat a bot is playing (you left, or were away) gets no “Your turn” and no sheet to choose from: yourMove() is false while botted(), and once the game’s over. The board’s turn corner says so too: on your seat’s turn it reads “Turn · You” in plain ink while a bot plays it, and “Your turn” in the turn color only once you’re back. The game is over on screen only once its last move has played: the title’s “Game over”, the block’s winner and the dock’s line wait for the over event’s cue, so the last “Sold!” is seen first. Your own move waits for the beats only so long: more than six seconds behind, the room catches up (catchUp(6000), table motion), ending the older beats but still playing the last one, so the move just before yours is seen (ADR 0054).

Bidding is in the dock: Bid offers the minimum, and − and + change it in 50,000 steps up to your cash. Questions are answered in the dock too, with a painting to sell picked from your collection. Choosing from someone else’s paintings is a sheet. The game log is in the top bar (Log, L, or the ⋯ menu), not the dock (ADR 0046): a drawer over the side, or pinned beside the table from 1440px wide, as Contract Rummy’s. Each player’s row shows their last action.

Any painting opens in the gallery view (games/static/games/auction-house/art.js, look): a house sheet showing it as large as the window allows, with its label, and ← and → to walk its set. It shows the small image at once and swaps in the large one from galleries/<id>/large/ when it arrives, so opening never waits on the network.

games/static/games/auction-house/art.jslook · lines 80–146
/** The gallery view: a painting as large as the window allows, in its gilt
 * frame on the salon wall, with its museum label, its value card when you
 * know it, and a way to the museum's own page. `items` is the set it was
 * opened from ({ work, card, note }), `at` which one; ← and → (or the
 * arrows) walk through the set. The card's picture shows at once, the
 * large one (galleries/<id>/large/) takes its place as it arrives. */
export function look(g, items, at = 0) {
  let i = at;
  const stage = h('div.viewstage');
  const label = h('div.viewlabel');
  const prev = h('button.viewnav.prev', { type: 'button', 'aria-label': 'The one before', onclick: () => go(-1) }, '‹');
  const next = h('button.viewnav.next', { type: 'button', 'aria-label': 'The next one', onclick: () => go(1) }, '›');
  const count = h('p.viewcount', { 'aria-live': 'polite' });
  const draw = () => {
    const { work, card, note } = items[i];
    const big = framed(g, work, card, { size: 0, tag: false, cls: 'viewframe', lazy: false });
    const img = big.querySelector('img');
    if (work) {
      const hi = new Image();
      hi.decoding = 'async';
      hi.onload = () => { if (img.isConnected) img.src = hi.src; };
      hi.src = new URL(`${g}/large/${work.id}.webp`, BASE).href;
      if (work.lw && work.lh) big.style.setProperty('--ratio', String(work.lw / work.lh));
    }
    const object = work?.kind === 'object';
    room.classList.toggle('objects', object);
    stage.replaceChildren(object
      // (an object stands in a glass vitrine on a plinth, under a spotlight
      // from the ceiling)
      ? h('div.shown',
        h('span.spot', { 'aria-hidden': 'true' }),
        h('div.vitrine', big, h('span.glass', { 'aria-hidden': 'true' })),
        h('span.plinth', { 'aria-hidden': 'true' }, h('span.plinthcap')))
      // (a painting hangs from the picture rail on two cords, under a brass
      // picture light that washes the wall above it)
      : h('div.hung',
        h('span.wash', { 'aria-hidden': 'true' }),
        h('span.cord.l', { 'aria-hidden': 'true' }), h('span.cord.r', { 'aria-hidden': 'true' }),
        h('span.lamp', { 'aria-hidden': 'true' }, h('span.lamparm'), h('span.lampbar')),
        big));
    const known = card && 'value' in card;
    label.replaceChildren(...[
      work ? h('h2.viewtitle', work.title) : null,
      work ? h('p.viewby', byWhom(work)) : null,
      work?.medium ? h('p.viewmedium', work.medium) : null,
      work ? h('p.viewmuseum', work.museum) : null,
      work?.source ? h('p.viewlink', h('a', { href: work.source, target: '_blank', rel: 'noopener' }, 'See it at the museum')) : null,
      known ? h(`p.viewvalue${card.value === 0 ? '.forgery' : ''}`, h('span', 'Value card'), h('b', valueWords(card.value))) : null,
      note ? h('p.viewnote', note) : null].filter(Boolean));
    count.textContent = items.length > 1 ? `${i + 1} of ${items.length}` : '';
    prev.hidden = next.hidden = items.length < 2;
  };
  const go = d => { i = (i + d + items.length) % items.length; draw(); };
  const room = h('div.viewroom',
    h('div.viewwall', h('span.rail', { 'aria-hidden': 'true' }),
      h('div.viewrow', prev, stage, label, next)),
    h('div.viewfloor', { 'aria-hidden': 'true' }, h('span.skirting')),
    count);
  const s = sheet(null, room, { wide: true, className: 'ah-view', label: 'Up close' });
  s.box.addEventListener('keydown', e => {
    if (items.length < 2 || e.altKey || e.metaKey || e.ctrlKey) return;
    if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
    if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
  });
  draw();
  return s;
}

Your move#

The first time each kind of move falls to you in a game, a house kit prompt (the front end) shrinks into the control, which then beckons: Place your piece, Choose which way, and Bid, or drop out. Later moves of that kind just ring after a few idle seconds, and an auction you may sit out doesn’t ring. Played prompts are remembered per table, game and kind. The board’s spaces are SVG and can’t take the kit’s ring, so their outlines (halos) carry the .house-beckon class and breathe in the turn’s color themselves; the house’s outline is turned off there, since a browser draws it round an SVG shape’s box. Placing your piece, every space is outlined just inside its own edges, so the thirty read as squares rather than one lattice; that outline is the space’s own, drawn under its words (g.halo), so every name reads in full. The status line always says the same in words.

games/static/games/auction-house/table.jsAuctionTable.cueFor · lines 427–459
/** What's yours to do now, and where: its kind, the moment it's for (a
 * new moment rings afresh), what to ring, and what a prompt says the
 * first time it falls to you in a game. `promptOnly`: no ring on a
 * routine turn (an auction you may sit out). */
cueFor() {
  const { v, t } = this;
  if (!v || t.you === null || t.seats[t.you]?.bot || v.phase === 'over' || this.room.spectating) return null;
  const c = v.can || {};
  const at = `t${v.turns}`;
  if (c.place) {
    return {
      kind: 'place', at: 'start', targets: () => this.board.halosOf(TRACK.keys()), into: () => this.board.block.querySelector('.blocknote') || this.board.block,
      say: { title: 'Place your piece', sub: 'Choose any space to start on. You don’t do what it says yet.' },
    };
  }
  if (c.move) {
    return {
      kind: 'move', at, into: () => this.board.pieceOf(t.you) || this.board.svg,
      targets: () => [...this.board.halosOf(c.move), ...(c.move.length > 1 ? [this.slots[0].el] : []), this.slots[3].el],
      say: { title: 'Choose which way', sub: `${this.rolled()}. You can go either way round: press one of the two spaces that light up.` },
    };
  }
  if (c.roll) return { kind: 'roll', at, targets: () => [this.slots[3].el] };
  // (your collection's panel, which stays as its paintings are drawn again)
  if (c.decide === 'sell' || c.decide === 'forced') return { kind: c.decide, at, targets: () => [this.el.mine] };
  if (v.auction && c.bid) {
    return {
      kind: 'bid', at: `a${v.auction.id}`, promptOnly: true, into: () => this.slots[3].el, targets: () => [this.slots[3].el],
      say: { title: 'Bid, or drop out', sub: 'Anyone may bid at any moment. The gavel falls a few seconds after the last bid.' },
    };
  }
  return null;
}

Helpers#

Each is on by default with its own row in Options: Cross off the value chart (values seen are struck off), Show what an unknown painting is worth (the unseen cards’ average), Add up your collection (your paintings’ total over your collection), and the house’s Show what to do (the prompts and rings above).

Checking it#

tools/auction-house/stage.py freezes a table at a chosen moment (choosing a direction, a live auction with the longest title any gallery has, a dozen paintings to choose from, your own painting on the block) with the bots holding still. That lets the fine-details check (npm run check:details) measure those moments at every screen size, not just the opening, and the log open and pinned. tools/room/auction-house.test.mjs draws the table on the house kit’s fake page for every moment (each question a space asks, private and bank auctions, the start, a roll, the end) and every kind of news, as each seat and a spectator sees it: nothing throws and nothing says null. It also holds the review’s fixes: a painting bought from you is the announcement in the block’s words, with Banners off too; a move never borrows another seat’s dice; a seat a bot plays gets no turn or sheet; the game is over on screen only after its last move; and the start can be chosen from a list. tools/room/hold.test.mjs holds the room kit’s press and hold.

Its look#

Auction House’s personality, in the design system’s personality sheet.

Family Board and party: the room kit in a look of its own. Its foyer is the card room’s felt. Everything else is the room kit’s (Board and party games).
Palette Ink #f6ead2 on its own ground; the one fill #f1d892; yours and your turn #f5c451; good #a6d98c and bad #ff9a85. An auction salon: oxblood walls under warm lamps and gilt. Each kind of space has its color and, never color alone, its emblem and words.
Faces Bodoni Moda (an auction catalogue’s capitals and italic) for titles, banners, prices and placards; Figtree for everything else
Corners and edges Panels 12px, buttons 10px; the rail square
Board One SVG ring of thirty catalogue wedges between gilt rings, each turned to face the velvet middle, where the painting on the block stands on its easel. Pieces sharing a space stack, the turn’s on top.
Emblem and art Its box on the shelf
Voice The block, the bank, the gavel; prices as “$400K”
Payoff The room’s celebration for a win
The glance Now: the turn corner, the dock lit when it’s you, the status line. The countdown: the paintings left in the bank’s pile, a Figure at the board’s top left, with the next painting always on show. Yours: your cash in the dock, and your collection.
Exceptions Its value card clipped over a painting’s corner; a collector’s name, a placard’s title and the bank’s strip keeping to their lines

Rating#

A kept game is rated by place like every table game (Ratings). Its bots sit at fixed ratings measured with tools/ratings/anchors.py (each deal played both ways round). Medium beats easy only a little over half the time and hard beats medium a little more, so the levels sit close together:

1480 · 1500 · 1540 (easy, medium, hard).