# Phase 1: Tournament Engine - Research **Researched:** 2026-03-01 **Domain:** Full-stack tournament management — Go backend (LibSQL, NATS JetStream, WebSocket), SvelteKit frontend (embedded via go:embed), poker domain logic (clock, blinds, financials, seating, ICM) **Confidence:** HIGH (core stack verified via official sources and docs) ## Summary Phase 1 is a greenfield full-stack build: a single Go binary embedding LibSQL (local SQLite fork), NATS JetStream (event durability), and a WebSocket hub, serving a SvelteKit SPA via `go:embed`. The binary runs in an x86 LXC container and provides a touch-friendly Tournament Director (TD) UI for managing a complete poker tournament lifecycle. The Go backend stack is well-established. LibSQL provides a local-first SQLite-compatible database via `go-libsql` (CGO required, no tagged releases — pin to commit hash). NATS server embeds programmatically with JetStream enabled for append-only event durability (`sync_interval: always` is mandatory for single-node per the December 2025 Jepsen finding). The WebSocket hub uses `coder/websocket` (v1.8.14, formerly nhooyr.io/websocket) for idiomatic Go context support. HTTP routing uses `go-chi/chi` v5 for its lightweight middleware composition. Authentication is bcrypt-hashed PINs producing local JWTs via `golang-jwt/jwt` v5. The SvelteKit frontend uses Svelte 5 with runes (the new reactivity model replacing stores) and `adapter-static` to prerender a SPA embedded in the Go binary. Catppuccin Mocha provides the dark theme via CSS custom properties from `@catppuccin/palette`. The operator UI follows a mobile-first bottom-tab layout with FAB quick actions, persistent clock header, and 48px touch targets. The poker domain has several non-trivial algorithmic areas: ICM calculation (factorial complexity, needs Monte Carlo approximation beyond ~15 players), blind structure wizard (backward calculation from target duration to level progression), table balancing (TDA-compliant seat assignment with blind position awareness), and PKO bounty chain tracking (half bounty to hitman, half added to own bounty). **Primary recommendation:** Build as a monolithic Go binary with clean internal package boundaries (clock, financial, seating, player, audit). Use event-sourced state changes written to both NATS JetStream (for real-time broadcast) and LibSQL (for persistence and query). All financial math uses int64 cents with a CI gate test proving zero-deviation payout sums. ## User Constraints (from CONTEXT.md) ### Locked Decisions - **Template-first creation** — TD picks from saved tournament templates, everything pre-fills, tweak what's needed for tonight, hit Start - **Templates are compositions of reusable building blocks** — a template is NOT a monolithic config. It references: Chip set, Blind structure, Payout structure, Buy-in config, Points formula (venue-level, reused across seasons) - **Local changes by default** — when creating a tournament from a template, the TD gets a copy. Edits only affect that tournament - **Dedicated template management area** — create from scratch, duplicate/edit existing, save tournament config as new template - **Built-in starter templates** ship with the app (Turbo, Standard, Deep Stack, WSOP-style) - **Structure wizard** lives in template management — input player count, starting chips, duration, denominations -> generates a blind structure - **Minimum player threshold** — configured in tournament metadata, Start button unavailable until met - **Chip bonuses** — configurable per tournament: early signup bonus, punctuality bonus - **Late registration soft lock with admin override** — when cutoff hits, registration locks but admin can push through (logged in audit trail) - **Payout structure as standalone reusable building block** — entry-count brackets with tiered prizes - **Entry count = unique entries only** — not rebuys or add-ons - **Prize rounding** — round down to nearest venue-configured denomination (e.g. 50 DKK, EUR 5) - **Bubble prize** — fast and prominent, "Add bubble prize" easily accessible, funded by shaving top prizes - **Overview tab priority** — Clock > Time to break > Player count > Table balance > Financial summary > Activity feed - **Bust-out flow** — tap Bust -> pick table -> pick seat -> verify name -> confirm -> select hitman -> done - **PKO (Progressive Knockout)** — bounty transfer part of bust flow (half to hitman, half added to own bounty) - **Buy-in flow** — search/select player -> auto-seat -> TD override option -> confirm -> receipt - **Multi-tournament switching** — tabs at top (phone) or split view (tablet landscape) - **Undo is critical** — bust-outs, rebuys, add-ons, buy-ins can all be undone with full re-ranking - **Oval table view (default)** — top-down view with numbered seats, switchable to list view - **Balancing is TD-driven, system-assisted** — system alerts, TD requests suggestion, TD announces, assistant reports, two-tap recording - **Break Table is fully automatic** — system distributes evenly, TD sees result - **No drag-and-drop in Phase 1** — tap-tap flow for all moves - **Flexible chop/deal support** — ICM, custom split, partial chop, any number of players - **Prize money and league positions are independent** - **Tournament auto-closes** when one player remains - **Receipts configurable per venue** — off / digital / print / both - **Operator is the Tournament Director (TD)** — use this term consistently ### Claude's Discretion - Loading skeleton and animation design - Exact spacing, typography, and component sizing - Error state handling and messaging - Toast notification behavior and timing - Activity feed formatting - Thermal printer receipt layout - Internal data structures and state management patterns ### Deferred Ideas (OUT OF SCOPE) - **Drag-and-drop seat moves** — future Phase 1 enhancement or later - **PWA seat move notifications** — Phase 2 - **"Keep apart" player marking** — evaluate for Phase 1 or later - **Chip bonus for early signup/punctuality** — captured in decisions, evaluate complexity during planning ## Phase Requirements | ID | Description | Research Support | |----|-------------|-----------------| | ARCH-01 | Single Go binary on x86 LXC with embedded LibSQL, NATS JetStream, WebSocket hub | go-libsql local-only mode (`sql.Open("libsql", "file:"+path)`), embedded nats-server with JetStream, coder/websocket hub pattern | | ARCH-03 | All financial values stored as int64 cents — never float64 | Standard Go int64 arithmetic, CI gate test pattern documented | | ARCH-04 | NATS JetStream embedded with `sync_interval: always` for durability | Jepsen 2.12.1 finding confirms mandatory for single-node; embedded server opts `JetStream: true` + sync config | | ARCH-05 | WebSocket hub broadcasts within 100ms | coder/websocket concurrent writes, custom hub with connection registry pattern | | ARCH-06 | SvelteKit frontend via `//go:embed` | adapter-static + `//go:embed all:build` + SPA fallback handler pattern | | ARCH-07 | Leaf is sovereign — cloud never required | Local LibSQL file, embedded NATS, all logic in Go binary | | ARCH-08 | Append-only audit trail for every state-changing action | Event-sourced audit table in LibSQL + NATS JetStream publish for real-time | | AUTH-01 | Operator PIN login -> local JWT (bcrypt in LibSQL) | golang-jwt/jwt v5 + golang.org/x/crypto/bcrypt, offline-capable | | AUTH-03 | Operator roles: Admin, Floor, Viewer | JWT claims with role field, middleware enforcement on chi routes | | CLOCK-01 | Countdown timer per level, second-granularity display, ms-precision internal | Go time.Ticker + monotonic clock, server-authoritative time | | CLOCK-02 | Separate break durations with distinct visual treatment | Level type enum (round/break), frontend conditional styling | | CLOCK-03 | Pause/resume with visual indicator across all displays | Clock state machine (running/paused/stopped), WebSocket broadcast | | CLOCK-04 | Manual advance forward/backward between levels | Clock engine level index navigation | | CLOCK-05 | Jump to any level by number | Direct level index set on clock engine | | CLOCK-06 | Total elapsed time display | Tracked from tournament start, pauses excluded | | CLOCK-07 | Configurable warning thresholds with audio/visual | Threshold config per level, play_sound action on WebSocket, CSS animation triggers | | CLOCK-08 | Clock state authoritative on Leaf; 1/sec normal, 10/sec final 10s | Server-side tick emitter with dynamic interval | | CLOCK-09 | Reconnecting clients receive full clock state | WebSocket connection handler sends current snapshot on connect | | BLIND-01 | Unlimited configurable levels (round/break, game type, SB/BB, ante, duration, chip-up, notes) | Level struct with all fields, array in blind structure entity | | BLIND-02 | Big Blind Ante support alongside standard ante | Separate ante and bb_ante fields per level | | BLIND-03 | Mixed game rotation (HORSE, 8-Game) | Game type per level, rotation sequence in structure | | BLIND-04 | Save/load reusable blind structure templates | Template CRUD in LibSQL, building block pattern | | BLIND-05 | Built-in templates (Turbo, Standard, Deep Stack, WSOP-style) | Seed data on first boot | | BLIND-06 | Structure wizard | Algorithm: target duration -> final BB -> geometric curve -> nearest chip denominations | | CHIP-01 | Define denominations with colors (hex) and values | Chip set entity (venue-level building block) | | CHIP-02 | Chip-up tracking per break | Level flag + chip-up denomination reference | | CHIP-03 | Total chips in play calculation | Sum of (active players * starting chips) + rebuys + add-ons | | CHIP-04 | Average stack display | Total chips / remaining players | | FIN-01 | Buy-in configuration (amount, chips, rake, bounty, points) | Buy-in config entity as building block, int64 cents | | FIN-02 | Multiple rake categories (staff, league, house) | Rake split array in buy-in config | | FIN-03 | Late registration cutoff (by level, by time, or both) | Cutoff config with dual-condition logic | | FIN-04 | Re-entry support (distinct from rebuy) | Separate transaction type, new entry after bust | | FIN-05 | Rebuy configuration (cost, chips, rake, points, limits, cutoff, chip threshold) | Rebuy config entity with all parameters | | FIN-06 | Add-on configuration (cost, chips, rake, points, window) | Add-on config entity | | FIN-07 | Fixed bounty system (cost, chip, hitman tracking, chain tracking, cash-out) | PKO bounty entity per player, half-split on elimination, chain tracked in audit trail | | FIN-08 | Prize pool auto-calculation | Sum of all entries * entry amount - total rake, int64 cents | | FIN-09 | Guaranteed pot support (house covers shortfall) | Guarantee config field, shortfall = guarantee - actual pool | | FIN-10 | Payout structures (percentage, fixed, custom) with rounding | Payout building block, round-down to venue denomination, bracket selection by entry count | | FIN-11 | Chop/deal support (ICM, chip-chop, even-chop, custom) | ICM: Malmuth-Harville algorithm (Monte Carlo for >15 players), custom split UI | | FIN-12 | End-of-season withholding | Rake category marked as season reserve | | FIN-13 | Every financial action generates receipt | Transaction log with receipt rendering | | FIN-14 | Transaction editing with audit trail and receipt reprint | Undo/edit creates new audit entry referencing original | | PLYR-01 | Player database persistent on Leaf (LibSQL) | LibSQL local file, player table with UUID PK | | PLYR-02 | Search with typeahead, merge duplicates, import CSV | FTS5 (SQLite full-text search) for typeahead, merge logic, CSV import endpoint | | PLYR-03 | QR code generation per player | Go QR library (e.g. skip2/go-qrcode), encodes player UUID | | PLYR-04 | Buy-in flow (search -> confirm -> auto-seat -> receipt -> display update) | Multi-step transaction: financial entry + seat assignment + WebSocket broadcast | | PLYR-05 | Bust-out flow (select -> hitman -> bounty -> rank -> rebalance -> display) | Multi-step: bust record + bounty transfer + re-rank + balance check + broadcast | | PLYR-06 | Undo for bust-out, rebuy, add-on, buy-in with re-ranking | Reverse transaction in audit trail, recalculate rankings and balances | | PLYR-07 | Per-player tracking (chips, time, seat, moves, rebuys, add-ons, bounties, prize, points, net, history) | Computed from transaction log and current state | | SEAT-01 | Tables with configurable seat counts (6-max to 10-max) | Table entity with seat_count, name/label | | SEAT-02 | Table blueprints (save venue layout) | Blueprint entity as venue-level template | | SEAT-03 | Dealer button tracking | Button position field per table, advanced on bust/balance | | SEAT-04 | Random initial seating on buy-in (fills evenly) | Random seat assignment algorithm: find table with fewest players, random empty seat | | SEAT-05 | Auto balancing suggestions with operator confirmation | TDA-compliant algorithm: size difference threshold, move fairness, button awareness, dry-run preview | | SEAT-06 | Drag-and-drop manual moves on touch interface | DEFERRED in Phase 1 — tap-tap flow instead | | SEAT-07 | Break Table action (dissolve and distribute) | Distribute players from broken table to remaining tables evenly, respecting blind position | | SEAT-08 | Visual top-down table layout, list view, movement screen | Oval SVG table component, list view alternative, move confirmation screen | | SEAT-09 | Hand-for-hand mode | Clock pause + per-hand level decrement mode | | MULTI-01 | Multiple simultaneous tournaments with independent state | Tournament-scoped state (clock, financials, players all keyed by tournament ID) | | MULTI-02 | Tournament lobby view | List of active tournaments with status summary | | UI-01 | Mobile-first bottom tab bar (Overview, Players, Tables, Financials, More) | SvelteKit layout with bottom nav component | | UI-02 | FAB for quick actions (Bust, Buy In, Rebuy, Add-On, Pause/Resume) | Expandable FAB component with context-aware actions | | UI-03 | Persistent header (clock, level, blinds, player count) | Fixed header component subscribing to clock WebSocket | | UI-04 | Desktop/laptop sidebar navigation | Responsive layout: bottom tabs on mobile, sidebar on desktop | | UI-05 | Catppuccin Mocha dark theme (default), Latte light theme | @catppuccin/palette CSS custom properties, theme toggle | | UI-06 | 48px minimum touch targets, press-state animations, loading states | CSS touch-action, :active states, skeleton loading | | UI-07 | Toast notifications (success, info, warning, error) | Svelte toast store/rune with auto-dismiss timer | | UI-08 | Data tables with sort, sticky header, search/filter, swipe actions | Custom table component or thin wrapper, virtual scroll for large lists | ## Standard Stack ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Go | 1.23+ | Backend language | Current stable, required for latest stdlib features | | github.com/tursodatabase/go-libsql | commit pin (no tags) | Embedded LibSQL database | SQLite-compatible, local-first, CGO required, `sql.Open("libsql", "file:"+path)` | | github.com/nats-io/nats-server/v2 | v2.12.4 | Embedded NATS server with JetStream | Event durability, append-only streams, embedded in Go process | | github.com/nats-io/nats.go | latest (v1.49+) | NATS Go client + jetstream package | New JetStream API (replaces legacy JetStreamContext) | | github.com/coder/websocket | v1.8.14 | WebSocket server | Minimal, idiomatic, context.Context support, concurrent writes, zero deps | | github.com/go-chi/chi/v5 | v5.2.5 | HTTP router | Lightweight, stdlib-compatible middleware, route grouping | | github.com/golang-jwt/jwt/v5 | v5.x (latest) | JWT tokens | Standard Go JWT library, 13K+ importers, HS256 signing | | golang.org/x/crypto | latest | bcrypt for PIN hashing | Standard library extension, production-grade bcrypt | | Svelte | 5.46+ | Frontend framework | Runes reactivity model, compiled output, small bundle | | SvelteKit | 2.x | Frontend framework/router | adapter-static for SPA, file-based routing, SSR-capable | | @sveltejs/adapter-static | latest | SPA build adapter | Prerenders all pages to static HTML for go:embed | | @catppuccin/palette | latest | Theme colors | Official Catppuccin color palette as CSS/JS, Mocha + Latte flavors | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | github.com/skip2/go-qrcode | latest | QR code generation | PLYR-03: Player QR codes for self-check-in | | github.com/google/uuid | latest | UUID generation | Player IDs, tournament IDs, all entity PKs | | FTS5 (built into LibSQL/SQLite) | N/A | Full-text search | PLYR-02: Typeahead player search | | @catppuccin/tailwindcss | latest | Tailwind theme plugin | Only if using Tailwind; otherwise use raw CSS vars from @catppuccin/palette | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | go-chi/chi v5 | Go 1.22+ stdlib ServeMux | Go 1.22 added method+path matching but chi has better middleware composition, route grouping, and ecosystem | | coder/websocket | gorilla/websocket | gorilla is more battle-tested (6+ years) but coder/websocket is more idiomatic (context support, concurrent writes) and actively maintained by Coder | | go-libsql | mattn/go-sqlite3 | go-sqlite3 is more mature with tagged releases but doesn't support libSQL extensions and eventual Turso sync in Phase 3 | | SvelteKit adapter-static | Vite vanilla SPA | SvelteKit provides file-based routing, layouts, and SSR-capability for Phase 2+ | **Installation (Go):** ```bash # go.mod — pin go-libsql to specific commit go get github.com/tursodatabase/go-libsql@ go get github.com/nats-io/nats-server/v2@v2.12.4 go get github.com/nats-io/nats.go@latest go get github.com/coder/websocket@v1.8.14 go get github.com/go-chi/chi/v5@v5.2.5 go get github.com/golang-jwt/jwt/v5@latest go get golang.org/x/crypto@latest go get github.com/skip2/go-qrcode@latest go get github.com/google/uuid@latest ``` **Installation (Frontend):** ```bash npm create svelte@latest frontend -- --template skeleton cd frontend npm install @catppuccin/palette npm install -D @sveltejs/adapter-static ``` ## Architecture Patterns ### Recommended Project Structure ``` felt/ ├── cmd/ │ └── leaf/ │ └── main.go # Entry point: starts NATS, LibSQL, HTTP server ├── internal/ │ ├── server/ │ │ ├── server.go # HTTP server setup, chi router, middleware │ │ ├── ws/ │ │ │ └── hub.go # WebSocket hub: connection registry, broadcast │ │ └── middleware/ │ │ ├── auth.go # JWT validation middleware │ │ └── role.go # Role-based access control │ ├── auth/ │ │ ├── pin.go # PIN login, bcrypt verify, JWT issue │ │ └── jwt.go # Token creation, validation, claims │ ├── clock/ │ │ ├── engine.go # Clock state machine (running/paused/stopped) │ │ ├── ticker.go # Server-side tick emitter (1/sec, 10/sec final 10s) │ │ └── warnings.go # Threshold detection and alert emission │ ├── tournament/ │ │ ├── tournament.go # Tournament lifecycle (create, start, close) │ │ ├── state.go # Tournament state aggregation │ │ └── multi.go # Multi-tournament management │ ├── financial/ │ │ ├── engine.go # Buy-in, rebuy, add-on, bounty transactions │ │ ├── payout.go # Prize pool calculation, payout distribution │ │ ├── icm.go # ICM calculator (Malmuth-Harville + Monte Carlo) │ │ ├── chop.go # Deal/chop flows (ICM, chip-chop, custom) │ │ └── receipt.go # Receipt generation │ ├── player/ │ │ ├── player.go # Player CRUD, search, merge │ │ ├── ranking.go # Live rankings, re-ranking on undo │ │ └── qrcode.go # QR code generation │ ├── seating/ │ │ ├── table.go # Table management, seat assignment │ │ ├── balance.go # Table balancing algorithm │ │ ├── breaktable.go # Table break and redistribute │ │ └── blueprint.go # Venue layout blueprints │ ├── blind/ │ │ ├── structure.go # Blind structure definition and CRUD │ │ ├── wizard.go # Structure wizard algorithm │ │ └── templates.go # Built-in template seed data │ ├── template/ │ │ ├── tournament.go # Tournament template (composition of blocks) │ │ ├── chipset.go # Chip set building block │ │ ├── payout.go # Payout structure building block │ │ └── buyin.go # Buy-in config building block │ ├── audit/ │ │ ├── trail.go # Append-only audit log │ │ └── undo.go # Undo engine (reverse operations) │ ├── nats/ │ │ ├── embedded.go # Embedded NATS server startup │ │ └── publisher.go # Event publishing to JetStream │ └── store/ │ ├── db.go # LibSQL connection setup │ ├── migrations/ # SQL migration files │ └── queries/ # SQL query files (or generated) ├── frontend/ │ ├── src/ │ │ ├── lib/ │ │ │ ├── ws.ts # WebSocket client with reconnect │ │ │ ├── api.ts # HTTP API client │ │ │ ├── stores/ # Svelte 5 runes-based state │ │ │ ├── components/ # Reusable UI components │ │ │ └── theme/ # Catppuccin theme setup │ │ ├── routes/ │ │ │ ├── +layout.svelte # Root layout (header, tabs, FAB) │ │ │ ├── overview/ # Overview tab │ │ │ ├── players/ # Players tab │ │ │ ├── tables/ # Tables tab │ │ │ ├── financials/ # Financials tab │ │ │ └── more/ # More tab (templates, settings) │ │ └── app.html │ ├── static/ │ │ └── sounds/ # Level change, break, bubble sounds │ ├── svelte.config.js │ ├── embed.go # //go:embed all:build │ └── package.json └── go.mod ``` ### Pattern 1: Embedded NATS Server with JetStream **What:** Start NATS server in-process with JetStream enabled and `sync_interval: always` for single-node durability. **When to use:** Application startup in `cmd/leaf/main.go`. ```go // Source: https://github.com/nats-io/nats-server/discussions/6242 import ( "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" ) func startEmbeddedNATS(dataDir string) (*server.Server, error) { opts := &server.Options{ DontListen: true, // In-process only, no TCP listener needed JetStream: true, StoreDir: dataDir, JetStreamMaxMemory: 64 * 1024 * 1024, // 64MB memory limit JetStreamMaxStore: 1024 * 1024 * 1024, // 1GB disk limit JetStreamSyncInterval: 0, // 0 = "always" — fsync every write } ns, err := server.NewServer(opts) if err != nil { return nil, fmt.Errorf("failed to create NATS server: %w", err) } go ns.Start() if !ns.ReadyForConnections(5 * time.Second) { return nil, errors.New("NATS server startup timeout") } return ns, nil } ``` **CRITICAL:** `sync_interval: always` (or `JetStreamSyncInterval: 0`) is mandatory. The Jepsen December 2025 analysis of NATS 2.12.1 found that the default 2-minute fsync interval means recently acknowledged writes exist only in memory and are lost on power failure. For single-node (non-replicated) deployments, every write must fsync immediately. ### Pattern 2: WebSocket Hub with Connection Registry **What:** Central hub managing all WebSocket connections with topic-based broadcasting. **When to use:** Real-time state updates to all connected operator/display clients. ```go // Source: coder/websocket docs + standard hub pattern import "github.com/coder/websocket" type Hub struct { mu sync.RWMutex clients map[*Client]struct{} } type Client struct { conn *websocket.Conn tournamentID string // Subscribe to specific tournament send chan []byte } func (h *Hub) Broadcast(tournamentID string, msg []byte) { h.mu.RLock() defer h.mu.RUnlock() for client := range h.clients { if client.tournamentID == tournamentID || client.tournamentID == "" { select { case client.send <- msg: default: // Client too slow, drop message } } } } // On new WebSocket connection — send full state snapshot func (h *Hub) HandleConnect(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, nil) if err != nil { return } client := &Client{conn: c, send: make(chan []byte, 256)} h.register(client) defer h.unregister(client) // Send full state snapshot on connect (CLOCK-09) snapshot := h.getCurrentState(client.tournamentID) client.send <- snapshot // Read/write pumps... } ``` ### Pattern 3: SvelteKit SPA Embedded in Go Binary **What:** Build SvelteKit with adapter-static, embed the output, serve with SPA fallback. **When to use:** Serving the frontend from the Go binary. ```javascript // svelte.config.js import adapter from '@sveltejs/adapter-static'; export default { kit: { adapter: adapter({ pages: 'build', assets: 'build', fallback: 'index.html', // SPA fallback precompress: false, strict: true }), paths: { base: '' // Served from root } } }; ``` ```go // frontend/embed.go package frontend import ( "embed" "io/fs" "net/http" "strings" ) //go:embed all:build var files embed.FS func Handler() http.Handler { fsys, _ := fs.Sub(files, "build") fileServer := http.FileServer(http.FS(fsys)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Try to serve static file first path := strings.TrimPrefix(r.URL.Path, "/") if path == "" { path = "index.html" } if _, err := fs.Stat(fsys, path); err != nil { // SPA fallback: serve index.html for client-side routing r.URL.Path = "/" } fileServer.ServeHTTP(w, r) }) } ``` ### Pattern 4: Event-Sourced Audit Trail **What:** Every state-changing action writes an immutable audit entry to both LibSQL and NATS JetStream. **When to use:** All mutations (buy-in, bust-out, rebuy, clock pause, seat move, etc.). ```go type AuditEntry struct { ID string `json:"id"` // UUID TournamentID string `json:"tournament_id"` Timestamp int64 `json:"timestamp"` // UnixNano OperatorID string `json:"operator_id"` Action string `json:"action"` // "player.bust", "financial.buyin", etc. TargetID string `json:"target_id"` // Player/table/etc ID PreviousState json.RawMessage `json:"previous_state"` NewState json.RawMessage `json:"new_state"` Metadata json.RawMessage `json:"metadata,omitempty"` UndoneBy *string `json:"undone_by,omitempty"` // Points to undo entry } // Write to both LibSQL (persistence) and NATS JetStream (real-time broadcast) func (a *AuditTrail) Record(ctx context.Context, entry AuditEntry) error { // 1. Insert into LibSQL audit table if err := a.store.InsertAudit(ctx, entry); err != nil { return err } // 2. Publish to NATS JetStream for real-time broadcast data, _ := json.Marshal(entry) _, err := a.js.Publish(ctx, fmt.Sprintf("tournament.%s.audit", entry.TournamentID), data, ) return err } ``` ### Pattern 5: Int64 Financial Math **What:** All monetary values stored and calculated as int64 cents. Never float64. **When to use:** Every financial calculation (prize pool, payouts, rake, bounties). ```go // All money is int64 cents. 1000 = $10.00 or 10.00 EUR or 1000 DKK (10 kr) type Money int64 func (m Money) String() string { return fmt.Sprintf("%d.%02d", m/100, m%100) } // Prize pool: sum of all entry fees minus rake func CalculatePrizePool(entries []Transaction) Money { var pool Money for _, e := range entries { pool += e.PrizeContribution // Already int64 cents } return pool } // Payout with round-down to venue denomination func CalculatePayouts(pool Money, structure []PayoutTier, roundTo Money) []Money { payouts := make([]Money, len(structure)) var distributed Money for i, tier := range structure { raw := Money(int64(pool) * int64(tier.BasisPoints) / 10000) payouts[i] = (raw / roundTo) * roundTo // Round down distributed += payouts[i] } // Remainder goes to 1st place (standard poker convention) payouts[0] += pool - distributed return payouts } // CI GATE TEST: sum of payouts must exactly equal prize pool func TestPayoutSumEqualsPool(t *testing.T) { // ... property-based test across thousands of random inputs // assert: sum(payouts) == pool, always, zero deviation } ``` ### Anti-Patterns to Avoid - **float64 for money:** Floating-point representation causes rounding errors. A pool of 10000 cents split 3 ways (3333 + 3333 + 3334) is exact in int64. With float64, 100.00/3 = 33.333... causes cumulative errors across a tournament. - **Client-authoritative clock:** The clock MUST be server-authoritative. Client clocks drift, can be manipulated, and diverge across devices. Server sends ticks, clients display. - **Mutable audit trail:** Audit entries must be append-only. "Undo" creates a NEW entry that references the original, never deletes or modifies existing entries. - **Blocking WebSocket writes:** Never write to a WebSocket connection synchronously in the broadcast loop. Use buffered channels per client with drop semantics for slow consumers. - **Global state instead of tournament-scoped:** Every piece of state (clock, players, tables, financials) must be scoped to a tournament ID. Global singletons break MULTI-01. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Password hashing | Custom hash function | golang.org/x/crypto/bcrypt | Bcrypt handles salt generation, timing-safe comparison, configurable cost | | JWT token management | Custom token format | golang-jwt/jwt/v5 | Standard claims, expiry, signing algorithm negotiation | | QR code generation | Manual QR encoding | skip2/go-qrcode | Reed-Solomon error correction, multiple output formats | | WebSocket protocol | Raw TCP upgrade | coder/websocket | RFC 6455 compliance, compression, ping/pong, close handshake | | SQLite FTS | Custom search index | FTS5 (built into LibSQL) | Tokenization, ranking, prefix queries, diacritics handled | | UUID generation | Custom ID scheme | google/uuid | RFC 4122 compliant, v4 random, v7 time-ordered | | Theme colors | Manual hex values | @catppuccin/palette | 26 colors across 4 flavors, community-maintained, consistent | | HTTP routing + middleware | Custom mux | go-chi/chi v5 | Middleware composition, route grouping, stdlib compatible | **Key insight:** The complexity in this phase is in the poker domain logic (ICM, balancing, blind wizard, PKO chains), not in infrastructure. Use battle-tested libraries for all infrastructure concerns so implementation time is spent on domain logic. ## Common Pitfalls ### Pitfall 1: NATS JetStream Default sync_interval Loses Data **What goes wrong:** Acknowledged writes exist only in OS page cache for up to 2 minutes. Power failure loses all recent tournament state. **Why it happens:** NATS defaults to `sync_interval: 2m` for performance, relying on cluster replication for durability. Single-node deployments have no replication. **How to avoid:** Set `sync_interval: always` (or `JetStreamSyncInterval: 0` in Go opts) on every embedded NATS instance. Accept the ~10-20% write throughput reduction. **Warning signs:** Tests pass because no simulated power failure; first real power cut at a venue loses the last 2 minutes of tournament data. ### Pitfall 2: go-libsql Unpinned Dependency Breaks Build **What goes wrong:** `go get github.com/tursodatabase/go-libsql@latest` resolves to a different commit on different days. Breaking changes arrive silently. **Why it happens:** go-libsql has no tagged releases. Go modules fall back to pseudo-versions based on commit hash. **How to avoid:** Pin to a specific commit hash in go.mod. Test thoroughly before updating. Document the pinned commit with a comment. **Warning signs:** CI builds succeed locally but fail in a clean environment, or vice versa. ### Pitfall 3: ICM Calculation Factorial Explosion **What goes wrong:** Exact Malmuth-Harville ICM calculation for 20+ players takes minutes or hours. UI freezes during chop negotiation. **Why it happens:** ICM exhausts all possible finish permutations. For N players this is O(N!) — 15 players = 1.3 trillion permutations. **How to avoid:** Use exact calculation for <= 10 players. Use Monte Carlo sampling (Tysen Streib method, 2011) for 11+ players with 100K iterations — converges to <0.1% error in under 1 second. **Warning signs:** Chop calculation works in testing (4-5 players) but hangs in production with 8+ players. ### Pitfall 4: Payout Rounding Creates Money from Nothing **What goes wrong:** Prize pool is 10000 cents, payouts sum to 10050 cents due to rounding up. Venue pays out more than collected. **Why it happens:** Rounding each payout independently can round up. Multiple round-ups accumulate. **How to avoid:** Always round DOWN to venue denomination. Assign remainder to 1st place. CI gate test: assert `sum(payouts) == prize_pool` for every possible input combination. **Warning signs:** Payout sum doesn't match prize pool in edge cases (odd numbers, large fields, unusual denominations). ### Pitfall 5: Clock Drift Between Server and Client **What goes wrong:** Timer shows different values on different devices. Operator's phone says 45 seconds, display says 43 seconds. **Why it happens:** Client-side timers using `setInterval` drift due to JS event loop scheduling, tab throttling, and device sleep. **How to avoid:** Server is authoritative. Send absolute state (level, remaining_ms, is_paused, server_timestamp) not relative ticks. Client calculates display from server time, correcting for known RTT. Re-sync on every WebSocket message. **Warning signs:** Devices show slightly different times; discrepancy grows over long levels. ### Pitfall 6: Undo Doesn't Re-Rank Correctly **What goes wrong:** Undoing a bust-out puts the player back but rankings are wrong. Players who busted after them have incorrect positions. **Why it happens:** Rankings are calculated at bust time. Undoing a bust requires recalculating ALL rankings from that point forward. **How to avoid:** Rankings should be derived from the ordered bust-out list, not stored as independent values. Undo removes from bust list, all subsequent positions shift. Re-derive, don't patch. **Warning signs:** Rankings look correct for simple undo (last bust) but break for earlier undos. ### Pitfall 7: Table Balancing Race Condition **What goes wrong:** System suggests "move Player A from Table 1 to Table 4". Before TD executes, Player B busts from Table 1. Now the move makes tables unbalanced in the other direction. **Why it happens:** Time gap between suggestion and execution. Real tournaments have concurrent busts during hands in play. **How to avoid:** The CONTEXT.md already addresses this: "Suggestion is live and adaptive — if Table 1 loses a player before the move happens, system recalculates or cancels." Implement suggestions as pending proposals that are re-validated before execution. **Warning signs:** TD executes a stale balancing suggestion and tables end up worse than before. ### Pitfall 8: SvelteKit adapter-static Path Resolution **What goes wrong:** Routes work in development but return 404 in production when embedded in Go binary. **Why it happens:** SvelteKit adapter-static generates files like `build/players/index.html`. The Go file server doesn't automatically resolve `/players` to `/players/index.html`. **How to avoid:** Configure `fallback: 'index.html'` in adapter-static config for SPA mode. The Go handler falls back to index.html for any path not found as a static file, letting SvelteKit's client-side router handle navigation. **Warning signs:** Direct navigation to routes works, but refresh on a nested route returns 404. ## Code Examples ### LibSQL Local Database Setup ```go // Source: https://github.com/tursodatabase/go-libsql/blob/main/example/local/main.go import ( "database/sql" _ "github.com/tursodatabase/go-libsql" ) func openDB(dataDir string) (*sql.DB, error) { dbPath := filepath.Join(dataDir, "felt.db") db, err := sql.Open("libsql", "file:"+dbPath) if err != nil { return nil, fmt.Errorf("open libsql: %w", err) } // Enable WAL mode for concurrent reads during writes if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { return nil, err } // Enable foreign keys if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { return nil, err } return db, nil } ``` ### Clock Engine State Machine ```go type ClockState int const ( ClockStopped ClockState = iota ClockRunning ClockPaused ) type ClockEngine struct { mu sync.RWMutex tournamentID string state ClockState levels []Level currentLevel int remainingNs int64 // Nanoseconds remaining in current level lastTick time.Time // Monotonic clock reference totalElapsed time.Duration warnings []WarningThreshold hub *ws.Hub } func (c *ClockEngine) Tick() { c.mu.Lock() defer c.mu.Unlock() if c.state != ClockRunning { return } now := time.Now() elapsed := now.Sub(c.lastTick) c.lastTick = now c.remainingNs -= elapsed.Nanoseconds() c.totalElapsed += elapsed // Check for level transition if c.remainingNs <= 0 { c.advanceLevel() } // Check warning thresholds c.checkWarnings() // Determine tick rate: 10/sec in final 10s, 1/sec otherwise remainingSec := c.remainingNs / int64(time.Second) msg := c.buildStateMessage() // Broadcast to all clients c.hub.Broadcast(c.tournamentID, msg) } func (c *ClockEngine) Snapshot() ClockSnapshot { c.mu.RLock() defer c.mu.RUnlock() return ClockSnapshot{ TournamentID: c.tournamentID, State: c.state, CurrentLevel: c.currentLevel, Level: c.levels[c.currentLevel], RemainingMs: c.remainingNs / int64(time.Millisecond), TotalElapsed: c.totalElapsed, ServerTime: time.Now().UnixMilli(), } } ``` ### Catppuccin Mocha Theme Setup ```css /* Source: https://catppuccin.com/palette/ — Mocha flavor */ :root { /* Base */ --ctp-base: #1e1e2e; --ctp-mantle: #181825; --ctp-crust: #11111b; /* Text */ --ctp-text: #cdd6f4; --ctp-subtext1: #bac2de; --ctp-subtext0: #a6adc8; /* Surface */ --ctp-surface2: #585b70; --ctp-surface1: #45475a; --ctp-surface0: #313244; /* Overlay */ --ctp-overlay2: #9399b2; --ctp-overlay1: #7f849c; --ctp-overlay0: #6c7086; /* Accents */ --ctp-rosewater: #f5e0dc; --ctp-flamingo: #f2cdcd; --ctp-pink: #f5c2e7; --ctp-mauve: #cba6f7; --ctp-red: #f38ba8; --ctp-maroon: #eba0ac; --ctp-peach: #fab387; --ctp-yellow: #f9e2af; --ctp-green: #a6e3a1; --ctp-teal: #94e2d5; --ctp-sky: #89dceb; --ctp-sapphire: #74c7ec; --ctp-blue: #89b4fa; --ctp-lavender: #b4befe; } body { background-color: var(--ctp-base); color: var(--ctp-text); font-family: system-ui, -apple-system, sans-serif; } ``` ### Svelte 5 Runes WebSocket State ```typescript // Source: Svelte 5 runes documentation // lib/stores/tournament.svelte.ts class TournamentState { clock = $state(null); players = $state([]); tables = $state([]); financials = $state(null); activity = $state([]); get remainingPlayers() { return this.players.filter(p => p.status === 'active').length; } get isBalanced() { if (this.tables.length <= 1) return true; const counts = this.tables.map(t => t.players.length); return Math.max(...counts) - Math.min(...counts) <= 1; } handleMessage(msg: WSMessage) { switch (msg.type) { case 'clock.tick': this.clock = msg.data; break; case 'player.update': // Reactive update via $state const idx = this.players.findIndex(p => p.id === msg.data.id); if (idx >= 0) this.players[idx] = msg.data; break; // ... other message types } } } export const tournament = new TournamentState(); ``` ### PIN Authentication Flow ```go // PIN login -> bcrypt verify -> JWT issue func (a *AuthService) Login(ctx context.Context, pin string) (string, error) { // Rate limiting: exponential backoff after 5 failures if blocked := a.rateLimiter.Check(pin); blocked { return "", ErrTooManyAttempts } operators, err := a.store.GetOperators(ctx) if err != nil { return "", err } for _, op := range operators { if err := bcrypt.CompareHashAndPassword([]byte(op.PINHash), []byte(pin)); err == nil { // Match found — issue JWT token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": op.ID, "role": op.Role, // "admin", "floor", "viewer" "iat": time.Now().Unix(), "exp": time.Now().Add(24 * time.Hour).Unix(), }) return token.SignedString(a.signingKey) } } a.rateLimiter.RecordFailure(pin) return "", ErrInvalidPIN } ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Svelte stores (writable/readable) | Svelte 5 runes ($state, $derived, $effect) | Oct 2024 (Svelte 5.0) | All state management uses runes, stores deprecated for new code | | nats.JetStreamContext (legacy API) | nats.go/jetstream package (new API) | 2024 | Simpler interfaces, pull consumers, better stream management | | gorilla/websocket | coder/websocket (nhooyr fork) | 2024 | Coder maintains the project, context.Context support, concurrent writes | | Go 1.21 stdlib ServeMux | Go 1.22+ enhanced ServeMux | Feb 2024 | Method matching + path params in stdlib, but chi still better for middleware | | Manual ICM calculation | Monte Carlo ICM approximation | 2011 (Streib) | Practical ICM for any field size, converges quickly | **Deprecated/outdated:** - **Svelte stores for new code:** Still work but Svelte 5 docs recommend runes ($state) for all new code. Stores remain for backward compatibility. - **nats.JetStreamContext:** Legacy API. The `jetstream` package under `nats.go/jetstream` is the replacement. Don't use the old JetStreamContext methods. - **gorilla/websocket as default choice:** Still works but archived/maintenance-mode. coder/websocket is the actively maintained modern alternative. ## Open Questions 1. **go-libsql CGO Cross-Compilation for CI** - What we know: go-libsql requires CGO_ENABLED=1 and precompiled libsql for linux/amd64 - What's unclear: Whether CI runners (GitHub Actions) have the right C toolchain pre-installed, or if we need a custom Docker build image - Recommendation: Test early with a minimal CI pipeline. If problematic, use a Debian-based CI image with build-essential. 2. **go-libsql Exact Commit to Pin** - What we know: No tagged releases. Must pin to commit hash. - What's unclear: Which commit is most stable as of March 2026 - Recommendation: Check the repo's main branch, pick the latest commit, verify it passes local tests, pin it with a comment in go.mod. 3. **NATS JetStream Stream Configuration for Audit Trail** - What we know: JetStream streams need proper retention, limits, and subject patterns - What's unclear: Optimal retention policy (limits vs interest), max message size for audit entries with full state snapshots, disk space planning - Recommendation: Start with limits-based retention (MaxAge: 90 days, MaxBytes: 1GB), adjust based on real tournament data sizes. 4. **Svelte 5 Runes with SvelteKit adapter-static** - What we know: Svelte 5 runes work for state management, adapter-static produces a SPA - What's unclear: Whether $state runes in .svelte.ts files work correctly with adapter-static's prerendering (runes are runtime, prerendering is build-time) - Recommendation: Use `fallback: 'index.html'` SPA mode to avoid prerendering issues. Runes are client-side only. 5. **Thermal Printer Receipt Integration** - What we know: FIN-13 requires receipt generation, CONTEXT.md mentions configurable receipt settings - What's unclear: Which thermal printer protocol to target (ESC/POS is standard), whether to generate in Go or browser - Recommendation: Generate receipt as HTML/CSS rendered in a hidden iframe for print. Thermal printer support can use ESC/POS in a later iteration. Digital receipts first. ## Sources ### Primary (HIGH confidence) - [go-libsql GitHub](https://github.com/tursodatabase/go-libsql) — Local-only API (`sql.Open("libsql", "file:"+path)`), CGO requirement, platform support - [go-libsql local example](https://github.com/tursodatabase/go-libsql/blob/main/example/local/main.go) — Verified local-only database opening pattern - [NATS JetStream docs](https://docs.nats.io/nats-concepts/jetstream) — JetStream configuration, sync_interval - [NATS server discussions #6242](https://github.com/nats-io/nats-server/discussions/6242) — Embedded JetStream account setup, PR #6261 fix - [Jepsen NATS 2.12.1](https://jepsen.io/analyses/nats-2.12.1) — sync_interval durability finding, single-node recommendation - [coder/websocket GitHub](https://github.com/coder/websocket) — v1.8.14, API, features - [go-chi/chi GitHub](https://github.com/go-chi/chi) — v5.2.5, middleware, routing - [golang-jwt/jwt v5](https://pkg.go.dev/github.com/golang-jwt/jwt/v5) — JWT library API - [Catppuccin palette](https://catppuccin.com/palette/) — Mocha hex values verified - [SvelteKit adapter-static docs](https://svelte.dev/docs/kit/adapter-static) — Configuration, fallback, prerender - [Svelte 5 runes blog](https://svelte.dev/blog/runes) — $state, $derived, $effect API - [ICM Wikipedia](https://en.wikipedia.org/wiki/Independent_Chip_Model) — Malmuth-Harville algorithm, factorial complexity ### Secondary (MEDIUM confidence) - [Liip blog: Embed SvelteKit into Go](https://www.liip.ch/en/blog/embed-sveltekit-into-a-go-binary) — Complete go:embed + adapter-static pattern, verified with official docs - [Replay Poker table rebalancing](https://replayhelp.casino.org/hc/en-us/articles/360001878254-How-table-rebalancing-works) — TDA-compliant balancing algorithm description - [PokerSoup blind calculator](https://pokersoup.com/tool/blindStructureCalculator) — Blind structure wizard algorithm approach - [Poker TDA 2024 rules](https://www.pokertda.com/poker-tda-rules/) — Official TDA breaking/balancing rules - [GTO Wizard PKO theory](https://blog.gtowizard.com/the-theory-of-progressive-knockout-tournaments/) — PKO bounty mechanics (half-split) - [HoldemResources ICM](https://www.holdemresources.net/blog/high-accuracy-mtt-icm/) — Monte Carlo ICM approximation for large fields ### Tertiary (LOW confidence) - [nats-server releases](https://github.com/nats-io/nats-server/releases) — v2.12.4 latest stable (Jan 2026), needs re-check at implementation time - go-libsql latest commit hash — needs to be fetched fresh at implementation time - Svelte exact version — reported as 5.46+ (Jan 2026), may have newer patches ## Metadata **Confidence breakdown:** - Standard stack: HIGH — All core libraries verified via official GitHub repos, official docs, and package registries - Architecture: HIGH — Patterns (go:embed SPA, embedded NATS, WebSocket hub, event sourcing) verified via multiple official examples and production usage - Pitfalls: HIGH — NATS Jepsen finding verified via official Jepsen report; ICM complexity verified via Wikipedia and academic sources; other pitfalls derived from verified library characteristics - Domain logic: MEDIUM — Poker domain (ICM, balancing, blind wizard) based on community sources and TDA rules, not on verified software implementations in Go **Research date:** 2026-03-01 **Valid until:** 2026-03-31 (30 days — stack is stable, check go-libsql for new tagged releases)