Files
scrabble-game/docs/superpowers/specs/2026-06-19-first-move-draw-design.md
T
Ilia Denisov caefc8f579
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
feat(game): official first-move tile draw + admin step-by-step replay
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.
2026-06-20 08:47:18 +02:00

8.4 KiB
Raw Blame History

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.

  1. 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.

  2. Admin replay on /_gm/games/:id. A simple board that steps move-by-move: column (AO) and row (115) 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 seeding status 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 uses crypto/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.New takes 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) and AttachRobot (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-time seed&1 pre-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 existing replay() (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_before from 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/template plus 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.ts is 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.