Technical guide

Board and party games

Get In Line

A board game played with cards, where teams take turns claiming spaces and race to get five chips in a row.

Checked against the code on 2026-10-30. Play Get In Line

On this page
  1. At a glance
  2. The rules in code
    1. The board, and where it came from
    2. The state
    3. Teams, hands and the size of the table
    4. A turn
    5. Lines
    6. The end
    7. The contract
  3. Dealing a game
  4. Your move
  5. Secrets
  6. Bots
    1. How a move is scored
    2. How strong they are, and the anchors
  7. Rating
  8. The interface
    1. The room, and the table
    2. Moving
    3. Assists
    4. Motion, the log, the look
  9. Content
  10. Its look
  11. Testing
  12. Changing it
  13. Known issues

Get In Line is a board game played with cards, for two to twelve people in two or three teams, with bots to fill any seat. On your turn you play a card and put your team’s chip on a board space showing that card. The first team to make the lines it needs, five chips in a row, wins.

It follows the published rules and classic board of the family game it’s based on, both chosen by David for family use (docs/get-in-line.md records the sources). Where those rules say nothing, the engine decided, and the design notes and this chapter mark each such rule ours. It runs on the table service as a room-kit table (the front end).

At a glance#

Part Where
The board: 100 spaces, 192 runs of five tables/get_in_line/board.py
The rules: dealing, a turn, jacks, dead cards, lines, the end tables/get_in_line/rules.py
The bots (easy, medium, hard) tables/get_in_line/bots.py
The table: board, teams, hand, dock, log, result sheet games/static/games/get-in-line/table.js
Chips (ring, cross, triangle), How to play, board zoom games/static/games/get-in-line/chips.js, games/static/games/get-in-line/rules.js, games/static/games/get-in-line/zoom.js
Its entry into the room kit games/static/games/get-in-line/app.js
Its look: tokens and fonts; the board games/static/games/get-in-line/get-in-line.css, games/static/games/get-in-line/board.css
Its pages games/templates/games/get-in-line.html, games/templates/get-in-line/page.html
Tests tests/test_get_in_line.py
Bot strength tools/get_in_line/strength.py
Design notes docs/get-in-line.md

The rules in code#

The board, and where it came from#

The board is ten by ten. The four corners are free spaces. The other 96 show every card of two decks except the jacks, so each of the 48 faces is on exactly two spaces. The layout is text, read into LAYOUT:

tables/get_in_line/board.py_ROWS · lines 16–29
# The classic board, row by row (sources in docs/get-in-line.md). "--" is a
# free corner.
_ROWS = """
-- 2S 3S 4S 5S 6S 7S 8S 9S --
6C 5C 4C 3C 2C AH KH QH TH TS
7C AS 2D 3D 4D 5D 6D 7D 9H QS
8C KS 6C 5C 4C 3C 2C 8D 8H KS
9C QS 7C 6H 5H 4H AH 9D 7H AS
TC TS 8C 7H 2H 3H KH TD 6H 2D
QC 9S 9C 8H 9H TH QH QD 5H 3D
KC 8S TC QC KC AC AD KD 4H 4D
AC 7S 6S 5S 4S 3S 2S 2H 3H 5D
-- AD KD QD TD 9D 8D 7D 6D --
"""

A space is its index, row * 10 + column. A face on the board has no deck number ("7H"), but a card in a hand does ("7H0", "7H1"), so either deck’s 7♥ plays to either 7♥ space. Runs of five are computed once at import:

tables/get_in_line/board.pywindows_through · lines 39–50
def windows_through(p: int):
    """Every run of five spaces in a row that includes space p, as a list of
    (direction index, the five spaces in order)."""
    r, c = divmod(p, SIZE)
    out = []
    for d, (dr, dc) in enumerate(DIRECTIONS):
        for back in range(RUN):
            r0, c0 = r - back * dr, c - back * dc
            r4, c4 = r0 + (RUN - 1) * dr, c0 + (RUN - 1) * dc
            if 0 <= r0 < SIZE and 0 <= c0 < SIZE and 0 <= r4 < SIZE and 0 <= c4 < SIZE:
                out.append((d, [(r0 + k * dr) * SIZE + c0 + k * dc for k in range(RUN)]))
    return out

WINDOWS is every run of five, across, down and diagonal: 192 of them. THROUGH[p] lists the runs through a space. The module asserts 48 faces, each twice, so a typo in the layout fails at import.

The rules come from public summaries (Wikipedia and two how-to-play pages). The layout comes from three open-source versions that agree on all 100 spaces, and a test pins it row by row.

The state#

One JSON object, saved whole:

tables/get_in_line/rules.pynew · lines 102–126
def new(players: int, seed: str, now: float, settings: dict | None = None) -> dict:
    if players not in HAND:
        raise Illegal("Get In Line needs teams of the same size: play with 2, 3, 4, 6, 8, 9, 10 or 12. "
                      "Add a bot or remove a seat.")
    rules = options(settings)
    teams = team_count(players, rules["teams"])
    stock = deck.shuffled(deck.build(2, 0), f"{seed}:0")
    hands = [[] for _ in range(players)]
    for _ in range(HAND[players]):
        for s in range(players):
            hands[s].append(stock.pop())
    first = int.from_bytes(seed.encode(), "big") % players
    state = {
        "game": slug, "n": players, "seed": seed, "teams": teams,
        "target": 2 if teams == 2 else 1,
        "rules": {k: rules[k] for k in DEFAULTS}, "_pace": moves.BOT_PACE[rules["bot_pace"]],
        "seq": 0, "logged": 0, "events": [],
        "hands": hands, "stock": stock, "discards": [], "shuffles": 0,
        "chips": [None] * (SIZE * SIZE), "lines": [],
        "phase": "play", "first": first, "turn": first, "turns": 0, "traded": False, "passes": 0,
        "last": None, "winner": None, "outcome": None,
        "scores": [], "totals": [0] * players, "ready": [False] * players,
    }
    log.event(state, "start", first=first, teams=teams, target=state["target"])
    return state
  • chips: 100 entries, each None or a team index.
  • lines: lines made so far, each {team, cells}.
  • hands, stock and the discard pile: lists of card strings.
  • turn is the seat to play, first who started, turns the completed turns, traded whether this turn’s trade-in is used, passes the passes in a row, and last the latest move (for the board’s highlight).
  • scores and totals use the shared rounds helper, one “round” per game, so history and ratings can read them.

A seat’s team is seat % teams:

tables/get_in_line/rules.pyteam · lines 129–130
def team(state: dict, seat: int) -> int:
    return seat % state["teams"]

Teams, hands and the size of the table#

A table starts only if it splits into equal teams, at most three. The published rules don’t allow 5, 7 or 11 players, so new refuses them and asks the host to add a bot or remove a seat.

PlayersTeamsPlayers a teamCards in handLines to win
22172
33161
42262
62, or 3 if the host picks3 (or 2)52 (or 1)
82442
93341
102532
122, or 3 if the host picks6 (or 4)32 (or 1)
Live from guide/worked/get_in_line.py, SIZES
tables/get_in_line/rules.pyteam_count · lines 44–52
def team_count(players: int, wanted: int = 2) -> int:
    """How many teams `players` split into (equal teams, at most three): three
    when they must (9), two when they must, the host's choice at six and
    twelve."""
    if players % 2 and players % 3 == 0:
        return 3
    if players % 3:
        return 2
    return wanted if wanted in TEAMS else 2

At 6 and 12 the host chooses two or three teams, the only sizes where the rules allow both. Besides bot_pace, that’s the only setting:

tables/get_in_line/rules.pyoptions · lines 36–41
def options(raw: dict | None) -> dict:
    """A table's settings, from whatever the host asked for."""
    return {
        "teams": moves.choice(raw, "teams", TEAMS, DEFAULTS["teams"]),
        "bot_pace": moves.choice(raw, "bot_pace", tuple(moves.BOT_PACE), "brisk"),
    }

moves.choice accepts only 2 or 3 for teams and "brisk" or "relaxed" for the pace (1.8× slower). Anything else becomes the default. Two teams need two lines, three teams one (state["target"]). The published rules have no timer, line count or hand-size setting, so neither does the engine. The state’s rules dict echoes only teams.

A turn#

The first player is int.from_bytes(seed.encode(), "big") % players. The deck is two standard decks (104 cards, jacks included), shuffled by the shared, seeded deck.shuffled, so a game replays exactly from its seed and moves.

There are three moves. moves.dispatch ticks the game, then rejects a move out of turn or malformed.

Move Does Refused when
play {card, space} A chip on a space, then you draw; the turn ends Not your turn; not your card; the space isn’t one the card can go to
trade {card} A dead card goes to the discards and you draw; once a turn, then you still play Not dead; already traded this turn
pass You neither play nor draw You can play; or you have a dead card and haven’t traded it yet

targets is the one definition of where a card can go, shared by play, view and the bots:

tables/get_in_line/rules.pytargets · lines 76–85
def targets(state: dict, team: int, card: str) -> list[int]:
    """Where this card can go now: its open spaces (a natural card), any open
    space (a two-eyed jack), or the chips it can take off (a one-eyed jack)."""
    chips = state["chips"]
    if two_eyed(card):
        return [p for p in range(SIZE * SIZE) if p not in CORNERS and chips[p] is None]
    if one_eyed(card):
        safe = locked(state)
        return [p for p in range(SIZE * SIZE) if chips[p] is not None and chips[p] != team and p not in safe]
    return [p for p in SPACES.get(face(card), []) if chips[p] is None]
  • A natural card goes on either of its two spaces, if empty.
  • A two-eyed jack (♣ and ♦) goes on any empty space but a corner.
  • A one-eyed jack (♠ and ♥) removes another team’s chip that isn’t in a completed line. You place no chip that turn.
  • A dead card is a natural card whose two spaces are both taken. A jack is never dead.
tables/get_in_line/rules.pyplay · lines 147–186
def play(state, seat, action, now):
    """Play a card: a chip on one of its spaces (a jack: wherever it may go),
    then draw."""
    _mine(state, seat)
    card = action.get("card")
    hand = _held(state, seat, card)
    space = action.get("space")
    if not isinstance(space, int) or isinstance(space, bool) or not 0 <= space < SIZE * SIZE:
        raise Illegal("Pick a space on the board.")
    mine = team(state, seat)
    if space not in targets(state, mine, card):
        raise Illegal(_why_not(state, mine, card, space))
    hand.remove(card)
    state["discards"].append(card)
    chips = state["chips"]
    if one_eyed(card):
        gone = chips[space]
        chips[space] = None
        state["last"] = {"seat": seat, "card": card, "space": space, "kind": "remove", "team": gone}
        log.event(state, "remove", seat=seat, card=card, space=space, team=gone)
    else:
        chips[space] = mine
        state["last"] = {"seat": seat, "card": card, "space": space, "kind": "place", "team": mine}
        log.event(state, "play", seat=seat, card=card, space=space, team=mine)
        made, dropped = _lines(state, mine, space)
        for cells in dropped:
            state["lines"].remove({"team": mine, "cells": cells})
        for k, cells in enumerate(made):
            state["lines"].append({"team": mine, "cells": cells})
            # (A line laid again in another place isn't news: only a new one is.)
            if k >= len(dropped):
                log.event(state, "line", team=mine, seat=seat, cells=cells, count=_count(state, mine))
        if made and _count(state, mine) >= state["target"]:
            _draw(state, seat)
            return _finish(state, mine, "lines")
    _draw(state, seat)
    state["passes"] = 0
    if all(c is not None for p, c in enumerate(chips) if p not in CORNERS):
        return _stuck(state, "full")
    _next(state, now)

The winning turn still draws (_draw runs before _finish). _why_not turns each refusal into a sentence (“That chip is part of a line, so it stays.”). Drawing is automatic. When the stock runs out, the discards are reshuffled with a derived seed (seed:shuffles), and the log says so (ours).

Passing (ours). If nothing in your hand can be played, you pass, since the game can’t wait on a move that doesn’t exist. You must trade a dead card first, because the new card might play. If every player passes in turn, the game ends.

Lines#

A line is five of a team’s chips in a row. A free corner counts for every team. A completed line is locked, so a one-eyed jack can’t touch it. Two lines may share one space: nine in a row is two lines, six is one.

The order chips arrive in shouldn’t change which lines they make. _lines checks each direction through the new chip, where _run finds the unbroken run of the team’s spaces. A run of n spaces holds at most (n − 1) // 4 lines:

tables/get_in_line/rules.py_lines · lines 285–317
def _lines(state: dict, t: int, p: int) -> tuple[list[list[int]], list[list[int]]]:
    """What a chip of team t on space p does to t's lines, as (new, dropped).
    Along each direction, an unbroken stretch of n spaces holds (n - 1) // 4
    lines at most, since two may share only one space: nine in a row is two,
    six is one. Lines already made stay; if the stretch can hold more only
    when they're set differently (five from the middle, then four more
    round it), the stretch's lines are laid again to take in every chip they
    covered, and the ones that were are `dropped`."""
    chips = state["chips"]
    have = [line["cells"] for line in state["lines"] if line["team"] == t]
    made: list[list[int]] = []
    dropped: list[list[int]] = []
    for d, (dr, dc) in enumerate(DIRECTIONS):
        run = _run(chips, t, p, dr, dc)
        n = len(run)
        if n < RUN:
            continue
        inside = [cells for cells in have if set(cells) <= set(run)]
        fixed = {run.index(cells[0]) for cells in inside}
        want = (n - 1) // (RUN - 1)
        if len(fixed) >= want:
            continue
        covers = lambda starts: {q for s in starts for q in run[s:s + RUN]}  # noqa: E731
        every = [c for c in combinations(range(n - RUN + 1), want) if all(b - a >= RUN - 1 for a, b in zip(c, c[1:]))]
        every.sort(key=lambda c: p not in covers(c))  # (the lines this chip makes, if it can)
        held = {q for cells in inside for q in cells}
        pick = next((c for c in every if fixed <= set(c)), None) or next((c for c in every if held <= covers(c)), None)
        if pick is None:
            continue
        laid = [run[s:s + RUN] for s in pick]
        dropped += [cells for cells in inside if cells not in laid]
        made += [cells for cells in laid if cells not in inside]
    return made, dropped

Existing lines stay put unless the run could hold more lines placed differently. Then _lines lays them again, choosing a placement (starts at least four apart) that contains the existing lines, or at least covers every chip they covered. Lines that no longer fit are dropped, new ones made:

tables/get_in_line/rules.py_run · lines 271–282
def _run(chips, t: int, p: int, dr: int, dc: int) -> list[int]:
    """The unbroken stretch of team t's spaces through p in one direction (a
    free corner counts for everyone), in order, as the runs of five read."""
    own = lambda q: q in CORNERS or chips[q] == t  # noqa: E731
    r, c = divmod(p, SIZE)
    while 0 <= r - dr < SIZE and 0 <= c - dc < SIZE and own((r - dr) * SIZE + c - dc):
        r, c = r - dr, c - dc
    out = []
    while 0 <= r < SIZE and 0 <= c < SIZE and own(r * SIZE + c):
        out.append(r * SIZE + c)
        r, c = r + dr, c + dc
    return out

Worked example: nine in a row, built the awkward way. A team makes a line on columns 2 to 6 of row 4, then adds two chips on the left and one on the right. At eight chips it’s still one line ((8 − 1) // 4 is 1). The ninth makes two, and two lines fit in nine spaces only at the ends, which don’t include the first line. So _lines returns dropped = columns 2 to 6 and made = columns 0 to 4 and 4 to 8. The log counts one new line, and no chip is unlocked, because the new lines cover every chip the old one did.

A team wins when its entries in state["lines"] (counts) reach target.

The end#

  • Lines. The first team to its target wins (_finish(..., "lines")).
  • A full board (checked after each placement) or everyone passing in turn (ours). _stuck gives the win to the team with most lines, or a draw if level. Three teams fill the board now and then; two hardly ever.
  • A ceiling of 1000 turns (ours), which no real game reaches, also through _stuck, reported as "long".
tables/get_in_line/rules.py_finish · lines 320–329
def _finish(state: dict, winner, how: str) -> None:
    lines = counts(state)
    if how != "long":
        state["turns"] += 1  # (the turn that ended it counts; a ceiling's already counted)
    state["winner"] = winner
    state["outcome"] = {"winner": winner, "how": how, "lines": lines, "turns": state["turns"]}
    state["turn"] = None
    log.event(state, "won" if winner is not None else "drawn", team=winner, how=how, lines=lines)
    rounds.score(state, [lines[team(state, s)] for s in range(state["n"])])
    rounds.end(state, last=True)

The game is one “round”. rounds.score gives each seat its team’s line count, rounds.end(last=True) sets the phase to over, and result is the shared rounds.result. A seat’s place is its team’s rank by lines, so with three teams the two losers usually tie at zero and share second.

The contract#

How the game meets the engine contract:

Part Here
slug, min_players, max_players get-in-line, 2, 12
options(raw) teams (2 or 3) and bot_pace
new, act As above; act is moves.dispatch over MOVES
tick, deadline Nothing: there is no clock. A turn waits for its player however long it takes.
view(state, seat) Below
deciding [(turn, "play", "t<turns>-<turn>-<traded>")]: one decision, keyed so a bot’s wait survives other seats’ moves but not its own turn changing or a trade-in
bot_action, fallback The bots; and “play the first card that can go anywhere, else trade a dead card, else pass”
over, result, terms phase == "over"; shared; terms says a round is a “game”, high totals win
bot_think play 1.2 s, trade 0.9 s; but see Known issues
note Shared log.note (seat news in the log)
Not defined away, ready, counts, round_no, teams: a game is one round; the service’s teams for ratings come from ratings/tables.py

Dealing a game#

A game is its seed. new(players, seed, now, settings) shuffles with it, deals 7 cards each at a table of two (down to 3 at twelve) one at a time, and picks the first player. Nothing else is random. Play again makes a new seed.

Your move#

Your first turn gets the room kit’s YourMove card, Play a card (Your move at a table). Later turns just outline your hand in the turn color.

Secrets#

Each seat’s hand is the only secret. view is the only route from the state to a browser:

tables/get_in_line/rules.pyview · lines 371–399
def view(state: dict, seat) -> dict:
    n, teams = state["n"], state["teams"]
    mine = team(state, seat) if seat is not None else None
    hand = sort_hand(state["hands"][seat]) if seat is not None else None
    my_turn = seat is not None and state["phase"] == "play" and state["turn"] == seat
    where = {c: targets(state, mine, c) for c in hand} if hand is not None else {}
    deads = [c for c in hand if dead(state, c)] if hand is not None else []
    playable = my_turn and any(where.values())
    return {
        "game": slug, "seq": state["seq"], "n": n, "seat": seat,
        "team": mine, "teams": teams, "team_of": [s % teams for s in range(n)], "names": list(TEAM_NAMES[:teams]),
        "target": state["target"], "rules": dict(state["rules"]),
        "layout": LAYOUT, "chips": list(state["chips"]),
        "lines": [{"team": line["team"], "cells": list(line["cells"])} for line in state["lines"]],
        "made": counts(state),
        "phase": state["phase"], "turn": state["turn"], "turns": state["turns"], "first": state["first"],
        "traded": state["traded"],
        "mine": hand,
        "stock": len(state["stock"]), "discards": list(state["discards"]),
        "where": where, "dead": deads,
        "can": {
            "play": playable,
            "trade": bool(my_turn and deads and not state["traded"]),
            "pass": bool(my_turn and not playable and (state["traded"] or not deads)),
        },
        "last": dict(state["last"]) if state["last"] else None,
        "winner": state["winner"], "outcome": dict(state["outcome"]) if state["outcome"] else None,
        "events": log.recent(state, seat),
    }

A seat gets its sorted hand (mine), where (the spaces each card can go to now), dead, and the can flags for its buttons. A spectator (seat=None) gets mine: None. Other hands never appear, which test_what_other_seats_hold_is_not_in_the_view checks. No event carries a to, so everyone sees the same log. Because where comes from the server, the browser computes only the “ring four in a row” helper, from public information.

Bots#

There are three levels. A bot decides only from rules.view() for its seat, and counts its partners’ chips as its own:

tables/get_in_line/bots.pychoose · lines 52–78
def choose(state: dict, seat: int, level: str, rng) -> dict:
    from .rules import view
    v = view(state, seat)
    w = LEVEL.get(level, LEVEL["medium"])
    can = v["can"]
    if can["trade"] and (level != "easy" or not can["play"] or rng.random() < 0.6):
        return {"type": "trade", "card": v["dead"][0]}
    if not can["play"]:
        return {"type": "pass"} if can["pass"] else {"type": "trade", "card": v["dead"][0]}
    b = Board(v)
    held = [c[:2] for c in v["mine"]]
    jacks = sum(1 for c in v["mine"] if two_eyed(c))
    scored = []
    seen = set()
    for card in v["mine"]:
        # (Two of the same card from either deck play alike: look once.)
        if card[:2] in seen:
            continue
        seen.add(card[:2])
        options = [(_score(b, card, p, w, rng), card, p) for p in v["where"].get(card, [])]
        if card[0] == "J" and w["ahead"]:
            options = sorted(options, key=lambda o: -o[0])[:JACK_SHORTLIST]
        scored += options
    if w["ahead"]:
        scored = [(s + _ahead(b, card, p, w, held, jacks), card, p) for s, card, p in scored]
    _, card, p = max(scored, key=lambda o: o[0])
    return {"type": "play", "card": card, "space": p}

A bot trades in a dead card (easy only 60% of the time when something else could play), passes if nothing can play, and otherwise scores every (card, space) pair and takes the best.

How a move is scored#

By the runs of five (WINDOWS) through its space:

  • Building: a run with no other team’s chips that will hold k of ours scores build[k]: 0, 1, 4, 16, 64 for 0 to 4. Each line made adds LINE (1,000), and the winning line adds WIN (100,000). A run counts only if it could become a line, sharing at most one space with each existing line (can_line).
  • Blocking: spoiling another team’s run is worth more the fuller it is (block[k]). Blocking their four scores 900, ×1.0 if it would have won them the game, ×0.8 otherwise.
  • Holding jacks: spending a two-eyed jack without a line or block costs a penalty (hold_two). A one-eyed jack is spent only to break four (hold_one).
  • Noise: up to noise at random, so equal moves vary.
tables/get_in_line/bots.pyLEVEL · lines 37–46
# How each level weighs things. build[k]: a run of ours with k after the
# move (short of five); block[k]: spoiling a run of theirs with k.
LEVEL = {
    "easy": {"build": (0, 1, 4, 16, 64), "block": (0, 0, 0, 0, 900), "block_odds": 0.25, "hold_two": 0, "hold_one": 0,
             "noise": 90.0, "ahead": False},
    "medium": {"build": (0, 1, 4, 16, 64), "block": (0, 0, 2, 12, 900), "block_odds": 1.0, "hold_two": 160, "hold_one": 140,
               "noise": 2.0, "ahead": False},
    "hard": {"build": (0, 1, 4, 16, 64), "block": (0, 0, 2, 16, 900), "block_odds": 1.0, "hold_two": 160, "hold_one": 140,
             "noise": 0.5, "ahead": True, "mine_held": 400, "mine_open": 80, "fork": 300, "theirs": 70, "theirs_two": 300},
}
Level In plain words
Easy Builds toward lines, credits blocking a four only 25% of the time, spends jacks freely, plays loosely (noise 90)
Medium The full scoring: blocks every four, breaks one with a one-eyed jack, holds jacks, trades dead cards; noise 2
Hard Medium plus a one-move lookahead (_ahead) at spaces that would finish a line: making them (400 each if it holds the card, 80 if open), forks (300), and saving a two-eyed jack for next turn’s finish. Others’ finishing spaces count −70 each, −300 more if one team has two, since both can’t be blocked

For jacks, which could go almost anywhere, a bot looks ahead only at the best 12 spaces by base score. At “brisk” a bot thinks 1.2 seconds a move (bot_think), and “relaxed” is 1.8× that.

How strong they are, and the anchors#

tools/get_in_line/strength.py plays the levels against each other, each deal twice with the levels swapped, and prints win shares with a 95% interval. October 2026, 300 deals, 600 games a pairing:

Pairing One against one Two teams of two
Hard over medium 50.9% ± 2.8 49.5%
Medium over easy 66.7% 72.0%
Hard over easy 66.0% 69.7%

Games run 40 to 65 turns. With medium bots, three players alone draw about one game in six (18%), three teams of two one in ten, and two teams never.

The rating anchors come from tools/ratings/anchors.py: easy, medium and hard are fixed at 1350, 1500 and 1510 with σ 0 (Ratings). Medium is 1500 by definition. That run measured medium over easy at 70.5% and hard over medium at 51.5% (−9 to +30 points), so hard sits only 10 above medium.

Rating#

A kept game is rated from its places in teams (Ratings). ratings/tables.py assigns sides:

ratings/tables.pyteams_of · lines 47–65
def teams_of(game: str, n: int, options: dict) -> list[int]:
    """Which side each seat plays for: partners across the table in Euchre,
    alternating round it in Get In Line and Bowl (two teams, people only),
    and Vibe Check's two teams (its co-op is one team, so it doesn't rate);
    everyone for themselves elsewhere."""
    if game in ("bowl", "vibe-check"):
        # (The teams the host picked, kept with the result: not by seat.)
        kept = (options or {}).get("_teams")
        if isinstance(kept, list) and len(kept) == n and all(t in (0, 1) for t in kept):
            return list(kept)
        return [s % 2 for s in range(n)]
    if game == "euchre":
        return [s % 2 for s in range(n)]
    if game == "get-in-line":
        from tables.get_in_line import rules

        teams = rules.team_count(n, rules.options(options)["teams"])
        return [s % teams for s in range(n)]
    return list(range(n))

For Get In Line that’s seat % teams, using rules.team_count on the options stored with the result, so a rebuild sees the teams that played. A side’s skill is its members’ summed, scaled to the average side size when sizes differ (Ratings), and it ranks by its best member’s place. Bots stay at their anchors, and guests and covered seats follow the usual table rules. Three teams make a field of three, each side meeting the others at 2/k.

Worked example. Ada (1550 ± 80) and a hard bot (1510) play Basil (1480 ± 120) and a medium bot (1500):

Result Ada Basil
Ada’s team wins +11.7 -26.3
Ada’s team loses -16.7 (the mirror)

The hard partner is in her team’s sum, so a win is more expected (a smaller gain) and a loss costlier. One against one with no bots, the same win would move Ada +12.1 and Basil -27.0. A game the host ends early is never kept or rated.

The interface#

The room, and the table#

games/static/games/get-in-line/app.js is a start() call into the room kit. Its cfg sets min: 2, max: 12, the startable sizes: [2, 3, 4, 6, 8, 9, 10, 12], defaultBots: 1, spectate, fill (Fill with bots) and firstRun (How to play opens on a first visit). It adds two settings, Teams (only at 6 or 12) and Bot speed, and neighbors: teamsNote, which shows the teams in the waiting room.

People sit side by side by default (seating = "together"), so two people at a table of bots are on different teams. The host can switch to as partners by arranging the seats. A table of 5, 7 or 11 can’t start.

table.js draws the table once and patches it on each update: the board (100 buttons in a role="grid"), the teams with their lines and players, your hand (the card room’s hand), and the dock with what to do, the last move, and Trade in / Place or Remove / Pass.

Moving#

Pick a card and its spaces light up, then tap one. A natural card plays at once. A jack, or a space you tapped first, asks for a second tap, so a slip never spends a card you meant to keep. Tapping an open space first picks a card for it (the natural card, else a two-eyed jack). Tapping a chip picks a one-eyed jack. Every refusal has its own sentence (“That’s the 7♥, not the 9♠.”).

Keys: 1–7 pick a card, T trades, P passes, L opens the log and ? the rules. Arrows, Home and End move between spaces, Enter plays, Escape puts down. Options can turn shortcuts off.

Assists#

Four helpers are on by default, each with its own Options row (uo:get-in-line:assist-<key>), plus Banners:

Helper Does
Light up where a card goes The picked card’s open spaces light; a jack lights every space it can go to (quieter rings for a two-eyed jack)
Tap a space to pick its card The tap-first behavior above
Tag dead cards and jacks A dead card wears a tag, and each jack says what it does
Ring four in a row Rings the space that would finish a line for each team, in that team’s color
Banners “Blue made a line!”, “Orange win!” (never in the way of play)

The ring is the one rule the browser reimplements: finishing() walks the same 192 runs with the same one-shared-space rule.

Motion, the log, the look#

Chips fly in, removed chips lift away, a line is drawn through its chips, and a win pulses them. Each is a cue on the room’s queue, skipped under reduced motion or when the tab is hidden; when your turn comes the queue catches up, older moves landing at once but the last one still playing (the front end). A one-eyed jack taking your team’s chip off is done to you (ADR 0054): once it’s lifted away, an announcement says who took it and from which space, your team’s panel lights up, and when your turn comes the status line leads with it (“Lou took your team’s chip off the 7♥.”) until you pick a card. A dead card traded in, or a pass, which the board can’t show, leads it the same way. Each team’s lines toward the win are the house’s Figure in its panel’s head (“1 of 2”, where pips used to be), and the team that’s up has its panel edged in ink. When the queue is idle, reconcile() redraws from the server’s state, so a missed cue can’t leave the board wrong.

The log is the room’s drawer, beside the board on a wide window or behind Log (ADR 0046). Chips are Blue (a ring), Orange (a cross) and Green (a triangle), so they differ without color.

The look is a walnut board in a games parlor, in Fraunces and Figtree. It reuses the card room’s faces and hand but not its table styles (ADR 0015). The room kit’s own screens (.felt.foyer) wear the card room’s Clubhouse felt: games/templates/games/get-in-line.html sets data-foyer="felt" and loads themes.css before its own tokens, and the felt applies only on those screens (ADR 0036).

On a small phone, if a space would be under 28px, a strip of + − Fit buttons beside the board zooms spaces up to 44px, and the board scrolls while the rest stays put. The strip’s width is --strip in board.css. Don’t reuse --room, a color token: the board once read it as a length and shrank to an empty frame. The zoom is kept per browser (uo:get-in-line:zoom). A small space’s card is the whole space, its index in the middle at .4 of a space (as large as the card holds its two lines), and a free corner’s star half a space; on a computer the index is 12px at least and the medallion’s “Free” .16 of a space, as large as the medallion holds it.

Content#

Only the board layout and the fonts (Fraunces 700 and Figtree, SIL Open Font Licence, bundled in fonts/).

Its look#

Get In Line’s personality, in the design system’s personality sheet.

Family Board and party: the room kit in a look of its own, borrowing the card room’s faces and hand (never its table styles). Its foyer is the card room’s felt. Everything else is the room kit’s (Board and party games).
Palette Ink #f5ecdc on its own ground; the one fill #f5ecdc; yours and your turn #ffd34d; good #9fdc8f and bad #ff9a85. A games parlor: espresso walls round a walnut board with a brass edge, the cards on deep blue baize.
Faces Fraunces for titles, banners and team names; Figtree for everything else
Corners and edges Buttons 10px; the rail square
Board The biggest square the window allows, never changing size: ten spaces a row, each the card room’s face scaled to its space (its index alone under 47px). Chips in the teams’ colors, the last move ringed.
Emblem and art Its box on the shelf
Voice Teams, chips, lines (“1 of 2”)
Payoff The room’s celebration for a win
The glance Now: whose turn and team, the status line led by a chip of yours taken off, and the board’s last move, ringed. The countdown: each team’s lines toward the win, a Figure. Yours: your hand.
Exceptions Its board’s spaces under 44px (ten to a row), and the card room’s fanned hand

Testing#

tests/test_get_in_line.py is the bar:

Group Proves
Board 96 faces twice each, free corners, no jacks; the classic layout, row by row; 192 runs of five
Dealing Hand sizes; two decks with jacks; only equal-team sizes start; partners alternate; the host’s choice at 6 and 12; lines to win; a random first player
Turns Play and draw; either space, either deck; illegal spaces, turn and ownership; both jacks; own and locked chips are safe; dead cards; passing only when stuck; the stock reshuffles
Lines Five in a row any way; four or a broken five isn’t one; corners count for all; nine in a row is two lines however built; a relaid line never unlocks a chip; one chip, two lines
Ending Two lines, or one with three teams; a full board; everyone passing
No clock A turn waits
Settings Defaults; unknown settings fall back; only teams and bot speed
View A seat sees its hand and targets; a spectator no hand; other hands never appear
Bots Finish, block, break four, hold jacks, trade dead cards; many whole games at every size without a hitch
Contract The shared TableGameContract
Page Its own look and the card faces; the card room’s tokens load first and the foyer is data-foyer="felt"; no table style applies; it’s on the shelf; its account pages wear its look

tools/get_in_line/strength.py isn’t a test; it makes the bot numbers above.

Changing it#

To… You need
Change a rule rules.py and docs/get-in-line.md (they must agree), How to play in rules.js, and a test. A live game’s saved state doesn’t migrate, so code that reads a new key needs a default or a new game.
Add a team size HAND, a hand size from the published rules, chips.SIZES and app.js’s sizes, and ratings/tables.py only if teams aren’t seat % teams.
Retune a bot LEVEL in bots.py; then tools/get_in_line/strength.py and, for the anchors, tools/ratings/anchors.py and ANCHORS with a VERSION bump (Ratings).
Change the board _ROWS (a published layout; the test pins it, so update both knowingly).
Add an assist An entry in HELPERS in table.js, default on, with its own Options row and a line in How to play.

Note: don’t export counts from tables/get_in_line/__init__.py. rules.counts is the lines per team, but the table service reads a game’s counts as “does this game count?” (Bowl’s meaning).

Known issues#

  • Hard is no better than medium: 50.9% one against one and 49.5% in teams of two, inside the noise, so its anchor is only 10 above medium’s. A hard bot that reads the table (likely hands, forks) was tried and not finished.
  • bot_think["trade"] is never used. deciding always returns the kind "play", so a trade-in thinks 1.2 s like any move.
  • The catalog comment is stale. games/catalog.py says Get In Line is “off the shelf until the owner has played it”, but the entry has no listed=False and a test asserts it’s on the shelf.
  • No timer or away handling. By design a turn waits forever, so a player who walks away blocks the table until the host gives their seat to a bot. The game defines no away.
  • A full board with lines level is a draw, about one game in six with three players alone. It’s an open question for David in the design notes. One alternative is to play on, with one-eyed jacks reopening spaces.