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.
On this page
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.py windows_through · lines 39–50def 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.py new · lines 102–126def 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, eachNoneor a team index.lines: lines made so far, each{team, cells}.hands,stockand the discard pile: lists of card strings.turnis the seat to play,firstwho started,turnsthe completed turns,tradedwhether this turn’s trade-in is used,passesthe passes in a row, andlastthe latest move (for the board’s highlight).scoresandtotalsuse 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.py team · lines 129–130def 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.
| Players | Teams | Players a team | Cards in hand | Lines to win |
|---|---|---|---|---|
| 2 | 2 | 1 | 7 | 2 |
| 3 | 3 | 1 | 6 | 1 |
| 4 | 2 | 2 | 6 | 2 |
| 6 | 2, or 3 if the host picks | 3 (or 2) | 5 | 2 (or 1) |
| 8 | 2 | 4 | 4 | 2 |
| 9 | 3 | 3 | 4 | 1 |
| 10 | 2 | 5 | 3 | 2 |
| 12 | 2, or 3 if the host picks | 6 (or 4) | 3 | 2 (or 1) |
guide/worked/get_in_line.py, SIZEStables/get_in_line/rules.py team_count · lines 44–52def 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.py options · lines 36–41def 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.py targets · lines 76–85def 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.py play · lines 147–186def 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–317def _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–282def _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).
_stuckgives 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–329def _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.py view · lines 371–399def 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.py choose · lines 52–78def 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 addsLINE(1,000), and the winning line addsWIN(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
noiseat random, so equal moves vary.
tables/get_in_line/bots.py LEVEL · 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.py teams_of · lines 47–65def 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.decidingalways returns the kind"play", so a trade-in thinks 1.2 s like any move.- The catalog comment is stale.
games/catalog.pysays Get In Line is “off the shelf until the owner has played it”, but the entry has nolisted=Falseand 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.