felt/.planning/phases/01-tournament-engine/01-RESEARCH.md

49 KiB

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>

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

</user_constraints>

<phase_requirements>

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

</phase_requirements>

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):

# go.mod — pin go-libsql to specific commit
go get github.com/tursodatabase/go-libsql@<commit-hash>
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):

npm create svelte@latest frontend -- --template skeleton
cd frontend
npm install @catppuccin/palette
npm install -D @sveltejs/adapter-static

Architecture Patterns

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.

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

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

// 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
        }
    }
};
// 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.).

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

// 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

// 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

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

/* 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

// Source: Svelte 5 runes documentation
// lib/stores/tournament.svelte.ts

class TournamentState {
    clock = $state<ClockSnapshot | null>(null);
    players = $state<Player[]>([]);
    tables = $state<Table[]>([]);
    financials = $state<FinancialSummary | null>(null);
    activity = $state<ActivityEntry[]>([]);

    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

// 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)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • 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)