# PintRush — Architecture & Resolved Decisions

Source of truth for how PintRush is built. Spec: `docs/PintRush_Build_Spec.md` (v2.0).
Where the spec is ambiguous or self-contradictory, the decision taken is recorded here under **Resolved**.

---

## 1. Topology

```
 phones ──┐                       ┌──────────────────────────┐
 TVs ─────┼── Socket.IO (wss) ───▶│ apps/game-server  :4000  │──┐
 hosts ───┘                       │  RoomActor per room      │  │ write-behind (ordered)
                                  │  engine = pure reducer   │  ▼
 browsers ── HTTPS ──▶ apps/web :3000 (Next.js 16)          PostgreSQL (Prisma 7)
                      marketing · join · play · tv ·  ──────▶ (same DB)
                      host · admin · API routes
                                                   Redis (optional, REDIS_URL): Socket.IO adapter
```

- **apps/web** — Next.js App Router. Marketing pages are static (SSG). Player/TV/host UIs are client
  components that talk to the game server over Socket.IO. Host/admin auth, pilot form, analytics
  beacon and all CRUD go through server actions / route handlers using `@pintrush/db`.
- **apps/game-server** — long-running Node process (tsx). Owns live game state, timers, scoring
  authority, persistence of play records, always-on scheduler, demo bots, guest-data purge.
- **packages/engine** — pure, deterministic game logic (no I/O, no clock, no Math.random).
- **packages/protocol** — zod schemas for every socket message + token claims + typed Socket.IO maps.
- **packages/db** — Prisma schema/client, tenant-scoped helpers, seed, original question packs.

Local dev: `pnpm db:start` runs a real PostgreSQL 18 from the `embedded-postgres` npm package on
port 54329 (no Docker needed); `docker compose up -d` is the equivalent for Docker users.
Redis is optional: without `REDIS_URL` the game server uses Socket.IO's in-memory adapter
(**single-process limit**: one game-server instance; rooms are in memory).

Ports: web 3000, game server 4000, Postgres 54329.

---

## 2. Game rules (engine)

### 2.1 Board fairness — **Resolved**

The spec asks for (a) all 9 categories once per board, (b) equal positional distribution per category
"across the template set" of "8 balanced layouts", and (d) identical answer patterns → identical scores
regardless of template. Taken literally these conflict: 8 layouts cannot give 9 categories equal center
time (8 center slots / 9 categories), and identical scores for identical answers require every board to
share the same set of line-triples, which only the 8 symmetries of the square (dihedral group D4) preserve.

**Design:**
- **Base layout** for a round = row `r` of a cyclic 9×9 Latin square: `base[cell] = categories[(cell + r) mod 9]`.
  `r = (hashString(sessionId) + roundIndex) mod 9`, so consecutive rounds walk the Latin square.
- **Player board** = base layout transformed by one of the **8 symmetries** of the 3×3 grid
  (identity, rot90, rot180, rot270, mirror-LR, mirror-TB, transpose, anti-transpose). Symmetries map
  rows/columns/diagonals onto rows/columns/diagonals, so every player in a round has a structurally
  identical board even though it looks different. Symmetry = `(joinIndex + roundIndex) mod 8`.
- **Template set** = 9 base rows × 8 symmetries = 72 boards. Across it, every category sits in every
  cell exactly 8 times → (b) holds exactly. Within one round, identical answers → identical lines → (d)
  holds exactly. The 8 symmetries are the spec's "fixed set of 8 balanced layouts" (`BoardTemplate` rows).
- **Schedule**: 9 category questions (one per category, order = seeded shuffle of the pack's categories)
  then **Q10 = Wildcard**. Everyone gets 10 activation chances; full board needs 9 correct of 10.
- **Wildcard tile choice** is sent *with* the Q10 answer (`wildcardPosition`), so no extra round-trip.
  If missing/invalid at grading, the engine auto-picks the tile that completes the most new lines
  (ties → lowest cell index). Max-new-lines is symmetry-invariant, so (d) still holds.
- Category Swap (a player choice) is the only way two boards in a round diverge structurally.

Required tests (engine): (a) every dealt board has all 9 categories once; (b) across all 72 templates each
category occupies each cell exactly 8 times, and each symmetry preserves corner/edge/center classes;
(c) a perfect player reaches full board with all 8 lines; (d) random answer patterns replayed on all 8
symmetries (and all 9 base rows) give identical scores, including wildcard auto-pick.

### 2.2 Timings (defaults, `DEFAULT_TIMINGS`)

Per question: PENDING 4 s ("Up next: History") → OPEN 15 s (18 s for DIFFICULT and the wildcard) →
LOCKED 1.5 s → REVEALED 5 s. Round: INTRO 6 s + 10 questions + ROUND_OVER 10 s = 4.4 min with no hard
questions, 4.9 min in the worst case (spec 3–5), shorter whenever everyone answers early.
Always-on break between rounds: 60 s from round completion. Championship: 20 s between rounds of a
segment, 30 s before a new segment. If every participant has answered, the question locks early.

### 2.3 Scoring (server-authoritative)

| Event | Points | Ref id (unique per player+session) |
|---|---|---|
| Correct, STANDARD / DIFFICULT | 100 / 150 | `CORRECT:<roundId>:<questionId>` |
| Speed bonus (correct only) | `round(50 × clamp01(1 − (t_answer − t_open − 750)/(window − 750)))` | `SPEED_BONUS:<roundId>:<questionId>` |
| Line (8 possible, each once per round) | 250 | `LINE:<roundId>:line:<0-7>` |
| Full board (once per round) | 500 | `FULL_BOARD:<roundId>:full` |
| Table challenge win (each participating member) | 200 | `TABLE_CHALLENGE:<roundId>:<questionId>` |
| Double Down win / loss | +200 / −100 | `DOUBLE_DOWN:<roundId>:<questionId>` |

`t_open` = server time the question opened; `t_answer` = server receive time (stamped by the game
server the moment the socket message arrives, before queuing). Client clocks are never used.
Answers with `t_answer > closesAt` are rejected (`TOO_LATE`). Grading happens at LOCKED → REVEALED;
results (tiles, points, lines) are only published at reveal so phones can't leak answers early.

### 2.4 Tie-breaks (deterministic, shown on TV)
1 higher points → 2 more correct → 3 lower `correctTimeMs` → 4 earlier `lastCorrectAt` (null last) →
5 lower `joinIndex` (final deterministic fallback, not displayed). Ranks are unique.

### 2.5 Teams
Team score = mean of member points over members with ≥ 1 answer. `mergeSolos=false` (default): players
without a team and teams of 1 appear on a separate **Solo** board and are not eligible for table
challenges; `true`: everyone ranks together. Team names are moderated like nicknames.

**Table Wars** (no board): each question is a challenge. Team accuracy = correct ÷ participating members.
Highest accuracy (> 0) wins; tie → lower mean elapsed ms of the team's correct answers; still tied → all
tied teams win. Each participating member of a winning team gets +200, so team means rise by exactly 200
regardless of size ("normalized").

### 2.6 Power-ups — **Resolved**
- Completing a line earns one **token** (cap: `tokens + used ≤ 3`). Never sold.
- A token is spent on any type not yet used **in this event** (championship, or the always-on night):
  one use of each type per event → max 3 uses. Unique `(playerSessionId, eventId, type)`.
- **Double Down** — allowed in INTRO / PENDING / REVEALED (not after the last question), targets the next
  question to open. Needs session points ≥ 100. Correct → +200, wrong/no answer → −100.
- **Second Chance** — during OPEN, before the player answers, MULTIPLE_CHOICE with ≥ 3 options. Server
  removes one incorrect option (seeded by round seed + question + player) and returns its id.
- **Category Swap** — board rounds, between questions (INTRO / PENDING / REVEALED), swaps the categories
  of two different **unactivated** tiles on the player's own board.
- All consumption is idempotent by `idempotencyKey` (duplicate → same success reply, no state change).

### 2.7 Round kinds (`ROUND_RULES`)
| Kind | Board | Questions | Notes |
|---|---|---|---|
| RUSH | yes | 10 (9 + wildcard) | originals pack |
| MUSIC_MAYHEM | yes | 10 | music pack: 9 music categories; no recordings, no lyrics |
| POWER_ROUND | yes | 10 | same rules as RUSH; UI spotlights power-ups |
| TABLE_WARS | no | 10 mixed | team challenge scoring + standard points |
| FINAL_RUSH | no | 5 (prefer DIFFICULT) | top 5 by session standings play; others make a non-scoring prediction (INTRO or Q1 PENDING) |

Championship default plan (`DEFAULT_CHAMPIONSHIP_PLAN`): Rush ×2, Music Mayhem ×2, Table Wars ×2,
Power Round ×2, Final Rush ×1 ≈ 45 min + 5 min lobby = 50 min. Championship scores start at zero
(new GameSession → new PlayerSessions). Always-on progress grants nothing in championships.

### 2.8 Sessions, joining, late join — **Resolved**
- One **GameSession** per **Event**. Always-on: one ALWAYS_ON event per room per business date
  (venue-local, 5 am cutoff). Championship: one per scheduled show.
- A **Room** has at most one *active* session. When a championship lobby opens, the room's always-on
  session is paused; when the championship completes, always-on resumes.
- **PlayerSession is per GameSession.** Phones in the room see the active session's public stream; if
  the player's session is not the active one, the UI offers a one-tap "Join the Championship" using the
  same nickname (de-duplicated if needed).
- Late join: a player can become a round participant until question index 2 **opens** (spec: "until
  question 3 opens"). Boards are dealt lazily — at round start to present players, otherwise the
  moment a not-yet-participating ACTIVE player is present (TICK `present` list) or acts, while the
  window is open. After that, the player waits; the UI shows a countdown to `estimatedEndAt + break`.
- Always-on rounds only start when ≥ 1 player is present (bots count). Otherwise the TV stays in
  attract mode (QR, next-round timer, nightly top 10, next championship time).

### 2.9 Nicknames
Trim, collapse whitespace, NFKC; 2–16 chars; letters (any script), digits, space, `. _ - ' !`; no URLs;
profanity filter with leetspeak normalization (original word list in `packages/engine/src/profanity.ts`).
Duplicates (case-insensitive key) get suffixes: "Mike", "Mike 2", "Mike 3" (base trimmed to fit 16).
Hosts can rename or kick.

---

## 3. Engine API (`packages/engine/src`)

| Module | Exports |
|---|---|
| `types.ts` | All types/constants (contract — change only with care) |
| `rng.ts` | `hashString(s): number`, `createRng(seed): () => number` (cyrb128 → sfc32), `shuffle(arr, rng)`, `pick(arr, rng)` |
| `board.ts` | `SYMMETRIES` (8 × `{key,name,permutation}`), `baseLayout(row, categories)`, `dealBoard(categories, row, symmetry)`, `symmetryFor(joinIndex, roundIndex)`, `baseLayoutRowFor(sessionId, roundIndex)`, `completedLines(tiles)`, `isFullBoard(tiles)`, `bestWildcardPosition(tiles)`, `swapTiles(tiles, a, b)`, `positionClass(p)` |
| `scoring.ts` | `speedBonus({openedAt, receivedAt, windowMs, graceMs})`, `basePoints(difficulty)`, `windowFor(question, isWildcard, timings)` |
| `standings.ts` | `compareStandings`, `rankPlayers(state)`, `teamStandings(state)` |
| `nickname.ts` + `profanity.ts` | `normalizeNickname`, `nicknameKey`, `validateNickname`, `validateTeamName`, `dedupeNickname(name, takenKeys)`, `containsProfanity` |
| `questions.ts` | `selectRoundQuestions({kind, pool, categories, seed, exclude})` |
| `reducer.ts` | `createSession(init)`, `reduce(state, command): ReduceResult`, `needsRound(state)`, `nextDeadline(state)` |
| `views.ts` | `publicView(state)`, `privateView(state, playerId)`, `hostView(state)` |
| `bots.ts` | `botDecision({seed, question, skill, windowMs})` → `{optionId, delayMs}` (demo room) |

`reduce` deep-clones the input (`structuredClone`) and never mutates it. TICK processes every due
transition in order; when a transition is more than 2 s late (e.g. after a server restart) the next phase
starts at `now` instead of at the old deadline, so downtime never fast-forwards through questions.

---

## 4. Game server (`apps/game-server`)

- **RoomActor** per room: holds the room's sessions (active + paused always-on), a serial command
  queue (commands never interleave), one timer armed at `nextDeadline(state)`, the per-room `seq`.
- **Command flow**: socket message → zod validate → auth/tenant check → rate limit → stamp
  `now = Date.now()` → enqueue → `reduce` → on success: append to write-behind log, project events to
  socket messages, ack. Write-behind flushes in order in one transaction per batch: AuditEvent rows
  (every accepted command + system transition), normalized rows (AnswerSubmission, BoardTile,
  ScoreEvent with `ON CONFLICT DO NOTHING`, PowerUpGrant/Use, Round, LeaderboardSnapshot), and the
  GameSession snapshot (`state`, `seq`, `stateVersion` with optimistic check).
- **Recovery**: on boot, load sessions in WAITING/RUNNING/PAUSED, restore snapshots, re-arm timers,
  re-issue pending RoundDue. Phones reconnect automatically and resync.
- **Messaging**: every server→client message is an envelope `{type, seq, serverNow, public?, me?, host?,
  snapshot?, data?}` and carries *complete* views, so rendering never depends on message order.
  **Resolved:** `seq` is per game session (a room shows one active session at a time; a phone finishing
  an always-on round while a championship lobby is open follows its own session). A public transition
  increments `seq` once and every watcher of that session gets a tailored envelope (players get their
  `me`; on reveal players receive `answer.result`, TVs `question.reveal`). Personal updates
  (`board.updated`, `score.updated`) reuse the current `seq`. When a room's active session changes,
  every socket in the room receives a fresh `room.state` snapshot. Clients that see a gap call
  `state.request`. Join lists / answer counts are broadcast throttled (≤ 2/s).
- **Handshake auth** (`socket.handshake.auth`): `{tokens: string[]}` for guests (one per session joined
  in this room; the server prefers the active session's), `{tvToken}` for TVs, `{hostToken, eventId}` for
  hosts. Invalid guest tokens don't refuse the connection: the socket stays anonymous and receives an
  `error` envelope so the phone can clear its tokens and send the player back to the join screen.
- **Rounds only run in the active session.** When a championship lobby opens, an always-on round in
  progress finishes normally but no new always-on round starts until the championship completes.
- **One championship at a time per room.** Opening a lobby is refused while another championship in
  the room is live. A finished show stays on screen for a 2-minute results window (podium, final
  standings); newcomers who scan during it join tonight's always-on directly. A lobby nobody starts is
  closed (event CANCELLED) 6 h after its scheduled time so always-on can return.
- **Always-on pauses at closing time** (and when a venue switches it off) instead of ending, so split
  opening hours resume tonight's session and nightly leaderboard; it ends at the business-date rollover.
  A host ending tonight's always-on event turns games off until the next business date.
- **Write-behind failures**: pending batches are written in one transaction; if that fails they are
  retried one by one so only a batch that truly cannot be saved is quarantined (logged in full) and
  skipped. Snapshot writes are version-checked (`GameSession.stateVersion`); if another process wrote
  the session (e.g. two instances during a rolling deploy) this process stops running it.
- **Limits**: ≤ 500 players per session; joins limited per socket (≈3/min) and per client address
  (burst 60, then 2/s) on top of the general 8/s message bucket; ≤ 300 sockets per address, released on
  every exit path. Behind proxies, `TRUST_PROXY=<hops>` makes the server read the hops-th
  `X-Forwarded-For` entry from the right (never the client-supplied left-most one).
- **Shutdown**: refuse new commands → disconnect sockets → drain the write-behind log, so nothing is
  acked that isn't saved.
- The public demo room keeps its sessions in memory only (bots play around the clock; nothing to keep),
  and deals late joiners in until the wildcard so visitors never wait for the next round.
- **After a championship** (once the results window closes) phones whose device already has an
  always-on player are moved back into tonight's game automatically (fresh `session.token` + snapshot);
  other phones get a one-tap "Keep playing" that rejoins always-on with the same nickname. Snapshots
  carry `activeSessionStatus` and `activeEventScheduledAt` so lobbies can say "Lobby open" vs "live" and
  show a start time.
- Host tokens are re-checked against the database at every handshake (user still exists in the venue,
  venue not cancelled); TICKs that change who is present are kept in the audit log for exact replay.
- **Auth** (HS256 JWT via `jose`, secret `AUTH_SECRET` shared with web):
  - guest `{scope:'guest', sub: playerSessionId, gsid, roomId, venueId, tv: tokenVersion}`, 6 h TTL,
    rotated on every reconnect (current or previous version accepted, then bumped).
  - tv `{scope:'tv', roomId, venueId, v: Room.tvTokenVersion}` — display only; can never send host or
    player commands. Bumping the version revokes old TV links.
  - host `{scope:'host', sub: venueUserId, venueId, role}`, 12 h TTL, minted by web for signed-in hosts;
    every host command checks `room.venueId === claims.venueId` (tenant isolation).
  - anonymous sockets may only call `room.join` and `clock.sync`.
- **Rate limits**: token bucket per socket (8/s, burst 16); per-IP connection cap 300 (bar Wi-Fi NAT).
- **Always-on scheduler** (every 15 s): for rooms whose venue has always-on enabled and is inside its
  schedule, ensure tonight's ALWAYS_ON event + session exists and is RUNNING; end it at close.
  Championship lobbies auto-open 15 min before `scheduledAt` (host can open earlier).
- **Demo room**: venue `demo` (join code `DEMO`) runs always-on 24/7 with 7 bots; `/demo` embeds it.
- **Purge**: daily job deletes PlayerSessions (cascade) and guest AuditEvents older than 90 days.
- HTTP: `GET /health`, `GET /rooms/:roomId/public` (venue name, join code, active status for join page).

---

## 5. Web (`apps/web`)

| Route | Notes |
|---|---|
| `/` `/how-it-works` `/for-venues` `/pricing` `/pilot` `/demo` `/faq` `/legal/privacy` `/legal/terms` | Marketing (static where possible), shared header/footer, OpenGraph images, sitemap, robots |
| `/join/[venueCode]` | Venue lookup (DB), nickname + optional team, `room.join`, then `/play/[roomId]` |
| `/play/[roomId]` | Phone game UI (bottom-anchored answers, 48 px targets, board, power-ups, results) |
| `/tv/[roomId]?t=` | 16:9 display; never shows answers before lock; attract mode between rounds |
| `/host/login`, `/host`, `/host/events/[id]`, `/host/settings` | Venue host dashboard (session cookie) |
| `/admin/login`, `/admin/...` | Platform admin: venues, packs, plans/entitlements, pilot leads, support |
| `/api/a` | Cookie-less analytics beacon (path + referrer host only) |

Design system: dark navy base, electric violet, cyan, amber/lime celebration accents
(`app/globals.css` `@theme`). TV: question ≥ 48 px at 1080p, countdown ≥ 160 px. Player: one-handed,
bottom-anchored, 48 px min targets, visible focus, `prefers-reduced-motion` respected.

Host/admin sessions: signed httpOnly cookie (`jose`), scrypt password hashes (`node:crypto`). Every
host query is scoped by `venueId` via `forVenue(venueId)` helpers in `@pintrush/db`.

---

## 6. Data, privacy, audit
- Tenant rows carry `venueId`; UUID ids; server timestamps; `version` columns; unique constraints per
  spec §6 (AnswerSubmission also includes `roundId` because long always-on nights can repeat a question).
- Guest data: nickname + random device hash only; purged after 90 days — player sessions (cascading
  answers, boards, scores, power-ups), game audit rows, leaderboard snapshots, finished session
  snapshots and teams. Pilot leads kept until deleted.
- `AuditEvent` is append-only and ordered (`ordinal`); replaying a session's commands through `reduce`
  reproduces its results.

## 7. Testing
- Vitest: engine (fairness a–d, scoring, timing, power-ups, tie-breaks, teams, idempotency, full-game
  simulations), protocol (no answer leaks in public schemas), db (constraints, tenant helpers),
  game-server (socket integration: join, play, reconnect/resync, duplicate submits, tenant isolation,
  TV token can't host), web (unit).
- Playwright (system Chrome/Edge, no browser download): join → play → host flows, marketing smoke.
