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
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.
// 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
jetstreampackage undernats.go/jetstreamis 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
-
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.
-
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.
-
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.
-
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.
-
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 — Local-only API (
sql.Open("libsql", "file:"+path)), CGO requirement, platform support - go-libsql local example — Verified local-only database opening pattern
- NATS JetStream docs — JetStream configuration, sync_interval
- NATS server discussions #6242 — Embedded JetStream account setup, PR #6261 fix
- Jepsen NATS 2.12.1 — sync_interval durability finding, single-node recommendation
- coder/websocket GitHub — v1.8.14, API, features
- go-chi/chi GitHub — v5.2.5, middleware, routing
- golang-jwt/jwt v5 — JWT library API
- Catppuccin palette — Mocha hex values verified
- SvelteKit adapter-static docs — Configuration, fallback, prerender
- Svelte 5 runes blog — $state, $derived, $effect API
- ICM Wikipedia — Malmuth-Harville algorithm, factorial complexity
Secondary (MEDIUM confidence)
- Liip blog: Embed SvelteKit into Go — Complete go:embed + adapter-static pattern, verified with official docs
- Replay Poker table rebalancing — TDA-compliant balancing algorithm description
- PokerSoup blind calculator — Blind structure wizard algorithm approach
- Poker TDA 2024 rules — Official TDA breaking/balancing rules
- GTO Wizard PKO theory — PKO bounty mechanics (half-split)
- HoldemResources ICM — Monte Carlo ICM approximation for large fields
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)