Decide who moves first by the official rule: each seated player draws one tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader remains. Each draw uses honest per-draw crypto/rand entropy (not the deterministic bag seed), so the recorded draw — not a seed — is the only account of the outcome. The leader takes seat 0, so the engine and journal replay are unchanged. The draw is recorded with the game (game_setup_draws, migration 00013) for future tournaments, designed as a discrete "player N draws a tile" step. Friend/AI games draw at creation. Auto-match draws when the game opens, against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are back-filled to the real opponent on join — so the opener's seat is fixed up front and the existing open-game pre-move is preserved with no reseating. Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with A-O/1-15 headers and highlighted premium squares, placed letters with their tile value as a subscript, rack panels around the board (seat 0 top, 1 bottom, 2 left, 3 right) with the current player highlighted, and a per-move log with the tiles drawn and the bag remainder. Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
8.4 KiB
First-move tile-draw seeding + admin game replay — design
- Date: 2026-06-19
- Branch:
feature/first-move-draw - Status: approved (owner sign-off 2026-06-19)
Goal
Two linked pieces.
-
First-move selection by the official rule. Each seated player draws one tile from the bag; the tile alphabetically closest to "A" wins, a blank superseding all letters. Players tied for the best tile re-draw until a single leader remains. Each draw uses fresh entropy — not the game's deterministic seed. The whole draw process is persisted with the game (not shown to players yet; needed later for tournaments, where the draw becomes a manual, per-tile external "player N draws a tile" call). A game must not start until the draw has produced a leader. The future API is not designed here, but the code exposes the draw as a discrete step.
-
Admin replay on
/_gm/games/:id. A simple board that steps move-by-move: column (A–O) and row (1–15) headers, highlighted premium squares, placed letters drawn upper-case with the tile value as a subscript (0 → no number), rack panels around the board (seat 0 top, 1 bottom, 2 left, 3 right) labelled by player name with a profile link and the current player's rack highlighted, prev/next stepping, and a per-move log line with the move, the tiles drawn from the bag, and the bag remainder. The first-move draw is shown as a flat list of actions, not stepped on the board.
Decisions (owner interview, 2026-06-19)
- Comparison key: alphabetical (alphabet index; closest to "A" wins; blank supersedes). Not tile point value.
- The draw decides only the first player; the rest keep their seating order (the circle is rotated so the winner leads).
- Persistence: a dedicated table
game_setup_draws(one row per draw), tournament / manual-API ready. - Lifecycle: a code seam only (a seeding component with a per-draw method); no
persisted
seedingstatus yet (the auto path runs it synchronously at the active transition).
Design
Seeding (internal/game/seating.go)
- A component shaped as a finite state machine, ready for a future per-tile external
call. Entropy is a parameter (interface /
io.Reader); production usescrypto/rand, tests inject a deterministic source so the ranking / tie / rotation logic is unit-testable. - A round: honestly shuffle the variant's full tile multiset (
rules.Counts+ blanks) with fresh entropy and draw one tile without replacement for each participant. This realises "shuffle before each draw, not a single seed". - Rank: blank = best (
-1); letter = alphabet index (0= closest to "A"). Lower rank moves earlier. - Tie: participants sharing the best rank re-draw in the next round (others drop out) until a single leader remains.
- Result: the winner plus the full draw log.
Turn order — seat rotation
The draw affects the game only through who occupies seat 0 (seat 0 moves first;
the engine/replay invariant is unchanged). Rotate the seated-account circle so the
winner sits at seat 0, preserving the cyclic order of the rest. This generalises the
current seed&1 swap (2 players) and replaces both seed&1 (auto-match) and
"Seats[0] first" (friend/AI). It applies to every new game.
The engine deals racks by seat index and is account-agnostic, and journal replay
starts at seat 0, so no engine or replay change is needed — only the seat→account
mapping in game_players.
Persistence — game_setup_draws (migration 00013)
CREATE TABLE backend.game_setup_draws (
game_id uuid NOT NULL REFERENCES backend.games (game_id) ON DELETE CASCADE,
round smallint NOT NULL,
pick_no smallint NOT NULL,
account_id uuid NOT NULL REFERENCES backend.accounts (account_id),
letter text NOT NULL,
is_blank boolean NOT NULL DEFAULT false,
rank smallint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, round, pick_no)
);
Self-contained and dictionary-independent (decoded letter + blank flag + numeric
rank), like the §9.1 journal. The first mover is visible as seat 0 in game_players,
so there is no redundant column. Additive — existing games have no rows (no backfill).
The future tournament API appends one row per draw call.
Wiring — the active transition
- Create (friend/AI): run the seeding over
params.Seats, persist the draw rows, rotate the account list so the winner is index 0, then proceed unchanged (engine.Newtakes only the seat count). All within the create transaction. - Auto-match: the second player is unknown until the seat is filled, so the
seeding runs at
fillOpenSeat(human join) andAttachRobot(robot) inside the fill transaction. The seat assignment is written from the draw outcome (seat 0 = winner) rather than only filling the empty seat; the open-timeseed&1pre-seating is dropped.
The auto path calls the seeding component synchronously in a loop. No persisted
seeding status; the seam in code is what a future manual API drives.
Admin replay
- Data: a new service method
ReplayTimeline(gameID)reuses the existingreplay()(engine rebuilt from seed + journal) and snapshots after each move: every seat's rack (Hand(i)), score (Score(i)), the seat to move (ToMove()), the bag remainder (BagLen()), plus per move the action, placements, words, and the tiles drawn from the bag (multiset diff:hand_after − (hand_before − used);hand_beforefrom the journal). Step 0 is the dealt racks. The board at step k is the placements of plays 1..k applied onto an empty grid (no dictionary — §9.1 visual replay). - UI: server-rendered Go
html/templateplus a small page-scoped vanilla-JS stepper over the timeline embedded as JSON (no per-step round-trip). The board renders headers, highlighted premium squares (a standard fixed 15×15 layout duplicated as a Go constant —premiums.tsis itself such a constant; the admin is not coupled to solver internals), upper-case letters with the value subscript (0 → no number) in tile-styled cells. Rack panels: seat 0 top, 1 bottom, 2 left, 3 right; name + profile link; the current player's rack highlighted. A log accumulates by move (play / pass / exchange + drawn tiles + bag remainder), the current step highlighted. The seeding is a separate flat list ("Round N: player X — E(rank), … → Y leads first"), with profile links.
Tests
- Seeding: ranking (blank beats letters; closer to "A" wins), tie chains, rotation for 2/3/4 seats, record determinism under an injected entropy source.
- ReplayTimeline: drawn tiles / bag remainder / racks / scores on a known short game.
- Store / integration: write+read
game_setup_draws; seat 0 = winner for both Create and auto-match.
Out of scope
The tournament draw API itself (only the seam). No change to the game bag's determinism or replay. No reuse / rewrite of the Svelte board in the admin. The board design is deliberately minimal.
Docs to bake back (same PR)
docs/ARCHITECTURE.md (§6 seeding procedure, §9 table, §9.1-style record invariant),
docs/FUNCTIONAL.md (+ _ru) first-move-selection story, backend / admin READMEs, Go
Doc comments, PRERELEASE.md note.
Update (2026-06-20) — final auto-match design
During implementation the owner replaced the auto-match approach. The original plan above ran the draw at fill time (when the opponent joins) and reseated the winner to seat 0. That conflicts with the existing open-game feature where the opener may play their opening move while waiting: reseating after a move would corrupt the journal's seat↔move mapping.
The final design runs the draw when the game opens, with the not-yet-arrived opponent as a
synthetic placeholder (uuid.Nil). The draw fixes the opener's seat up front; the opponent
joins into the already-decided empty seat with no reseating, so the open-game pre-move is
preserved and the journal is never disturbed. The opponent's draw rows are persisted with a
NULL account_id (so game_setup_draws.account_id is nullable) and back-filled to the
real opponent (or robot) on join. The directly-seated (friend/AI) path is unchanged from the
plan (draw at create, real accounts, no synthetic). Consequently there is no play-gating and
no rack-hiding while a game is open. See docs/ARCHITECTURE.md §6 / §9 for the authoritative
description.