felt/docs/felt_phase2_spec.md

23 KiB

Project Felt — Phase 2 Product Specification

Cash Game Operations

Version: 0.1 Draft Date: 2026-02-28 Depends on: Phase 1 (Tournament Management) — shared Leaf, displays, player DB, network


Table of Contents

  1. Overview
  2. Data Architecture
  3. Feature Specification
  4. Display Integration
  5. Player Mobile Experience
  6. API Design
  7. Sync & Cloud Features
  8. Roadmap

1. Overview

What Phase 2 Adds

Phase 1 handles tournaments — structured events with a beginning and end. Phase 2 adds cash games — continuous, open-ended sessions that are the daily revenue driver for most poker rooms.

Cash games have fundamentally different management needs: waitlists instead of registrations, seat availability instead of seating assignments, rake tracking instead of prize pool calculation, session duration instead of bust-out order.

Design Principles

  1. Coexistence. Tournaments and cash games run simultaneously on the same Leaf. Display nodes can show tournament clocks on some screens and cash game status on others.
  2. Shared player database. A player who plays tournaments and cash games has one profile. Their history includes both.
  3. Shared dealer pool. Dealers are assigned to cash tables or tournament tables from the same scheduling system (Phase 3 completes this, but the data model supports it from Phase 2).
  4. Real-time everywhere. Waitlist updates, seat availability, and table status push to displays and mobile devices in real-time via the same WebSocket infrastructure used for tournaments.
  5. Offline-first. Cash game management works fully offline. Cloud sync happens when available.

2. Data Architecture

New Tables (Leaf SQLite + Core PostgreSQL)

-- Game Type Registry
CREATE TABLE game_types (
    id          TEXT PRIMARY KEY,
    name        TEXT NOT NULL,               -- "No-Limit Hold'em"
    short_name  TEXT NOT NULL,               -- "NLH"
    variant     TEXT NOT NULL,               -- holdem, omaha, omaha_hilo, stud, stud_hilo, draw, mixed, short_deck, other
    betting     TEXT NOT NULL,               -- no_limit, pot_limit, fixed_limit, spread_limit
    max_players INTEGER NOT NULL DEFAULT 10, -- Max seats per table for this game
    is_active   BOOLEAN NOT NULL DEFAULT true,
    sort_order  INTEGER NOT NULL DEFAULT 0,
    created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Rake Structures
CREATE TABLE rake_structures (
    id              TEXT PRIMARY KEY,
    name            TEXT NOT NULL,               -- "Standard 5% cap €10"
    type            TEXT NOT NULL,               -- percentage, time_rake, flat
    percentage      REAL,                        -- 0.05 for 5%
    cap             INTEGER,                     -- Max rake per pot (cents)
    time_amount     INTEGER,                     -- Time rake amount per interval (cents)
    time_interval   INTEGER,                     -- Time rake interval (minutes)
    flat_amount     INTEGER,                     -- Flat rake per hand (cents)
    no_flop_no_drop BOOLEAN NOT NULL DEFAULT true,
    notes           TEXT,
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Cash Tables
CREATE TABLE cash_tables (
    id              TEXT PRIMARY KEY,
    table_number    INTEGER NOT NULL,            -- Physical table number
    game_type_id    TEXT NOT NULL REFERENCES game_types(id),
    stakes          TEXT NOT NULL,               -- "1/2", "2/5", "5/10"
    small_blind     INTEGER NOT NULL,            -- Cents
    big_blind       INTEGER NOT NULL,            -- Cents
    straddle        BOOLEAN NOT NULL DEFAULT false,
    min_buyin       INTEGER NOT NULL,            -- Cents (e.g., 10000 = €100)
    max_buyin        INTEGER,                     -- Cents (NULL = uncapped)
    max_players     INTEGER NOT NULL DEFAULT 10,
    rake_id         TEXT REFERENCES rake_structures(id),
    status          TEXT NOT NULL DEFAULT 'closed', -- closed, open, breaking
    dealer_id       TEXT REFERENCES players(id),    -- Current dealer (Phase 3)
    opened_at       DATETIME,
    closed_at       DATETIME,
    must_move_for   TEXT REFERENCES cash_tables(id), -- If this is a must-move table, which main game
    notes           TEXT,
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Seats (real-time seat tracking)
CREATE TABLE cash_seats (
    id          TEXT PRIMARY KEY,
    table_id    TEXT NOT NULL REFERENCES cash_tables(id),
    seat_number INTEGER NOT NULL,
    player_id   TEXT REFERENCES players(id),    -- NULL = empty
    session_id  TEXT REFERENCES cash_sessions(id),
    status      TEXT NOT NULL DEFAULT 'empty',  -- empty, occupied, reserved, away
    occupied_at DATETIME,
    UNIQUE(table_id, seat_number)
);

-- Player Sessions
CREATE TABLE cash_sessions (
    id              TEXT PRIMARY KEY,
    player_id       TEXT NOT NULL REFERENCES players(id),
    table_id        TEXT NOT NULL REFERENCES cash_tables(id),
    seat_number     INTEGER NOT NULL,
    game_type_id    TEXT NOT NULL REFERENCES game_types(id),
    stakes          TEXT NOT NULL,
    buyin_total     INTEGER NOT NULL DEFAULT 0,  -- Total bought in (cents), accumulates with rebuys
    cashout_amount  INTEGER,                     -- Final cashout (cents), NULL = still playing
    started_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    ended_at        DATETIME,
    duration_minutes INTEGER,                    -- Calculated on session close
    notes           TEXT,
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Session Buy-ins (track each individual buy-in/rebuy)
CREATE TABLE cash_session_buyins (
    id          TEXT PRIMARY KEY,
    session_id  TEXT NOT NULL REFERENCES cash_sessions(id),
    amount      INTEGER NOT NULL,               -- Cents
    type        TEXT NOT NULL DEFAULT 'buyin',   -- buyin, rebuy, addon
    created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Waitlists
CREATE TABLE waitlists (
    id              TEXT PRIMARY KEY,
    game_type_id    TEXT NOT NULL REFERENCES game_types(id),
    stakes          TEXT NOT NULL,               -- "1/2", "2/5" — or "any" for game-type-only
    is_active       BOOLEAN NOT NULL DEFAULT true,
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Waitlist Entries
CREATE TABLE waitlist_entries (
    id              TEXT PRIMARY KEY,
    waitlist_id     TEXT NOT NULL REFERENCES waitlists(id),
    player_id       TEXT NOT NULL REFERENCES players(id),
    position        INTEGER NOT NULL,            -- Queue position
    status          TEXT NOT NULL DEFAULT 'waiting', -- waiting, called, seated, expired, removed
    called_at       DATETIME,                    -- When player was notified
    response_deadline DATETIME,                  -- Must respond by
    notification_sent BOOLEAN NOT NULL DEFAULT false,
    notes           TEXT,
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Seat Change Requests
CREATE TABLE seat_change_requests (
    id              TEXT PRIMARY KEY,
    session_id      TEXT NOT NULL REFERENCES cash_sessions(id),
    player_id       TEXT NOT NULL REFERENCES players(id),
    table_id        TEXT NOT NULL REFERENCES cash_tables(id),
    requested_seat  INTEGER,                     -- NULL = any seat, specific number = that seat
    reason          TEXT,                         -- "Prefer seat 1", "Want to move tables"
    status          TEXT NOT NULL DEFAULT 'pending', -- pending, fulfilled, cancelled
    created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    fulfilled_at    DATETIME
);

-- Rake Log (per table per time period)
CREATE TABLE rake_log (
    id          TEXT PRIMARY KEY,
    table_id    TEXT NOT NULL REFERENCES cash_tables(id),
    period_start DATETIME NOT NULL,
    period_end   DATETIME NOT NULL,
    total_rake  INTEGER NOT NULL,               -- Cents
    hands_dealt INTEGER,                        -- If tracked
    notes       TEXT,
    created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Extended Player Table

Phase 2 adds columns to the existing players table:

ALTER TABLE players ADD COLUMN preferred_games TEXT;      -- JSON array of game_type_ids
ALTER TABLE players ADD COLUMN preferred_stakes TEXT;     -- JSON array of stakes strings
ALTER TABLE players ADD COLUMN total_cash_hours REAL DEFAULT 0;  -- Lifetime cash game hours
ALTER TABLE players ADD COLUMN total_cash_sessions INTEGER DEFAULT 0;

3. Feature Specification

3.1 Game Type Registry

The venue defines all games they spread. This is the foundation for waitlists, table assignments, dealer skills (Phase 3), and analytics.

Built-in game types (pre-populated, editable):

Variant Betting Structures
Hold'em No-Limit, Pot-Limit, Fixed-Limit, Spread-Limit
Omaha Pot-Limit, No-Limit, Fixed-Limit
Omaha Hi-Lo Pot-Limit, Fixed-Limit
Stud Fixed-Limit
Stud Hi-Lo Fixed-Limit
Razz Fixed-Limit
Draw (2-7, 5-Card) Fixed-Limit, No-Limit
Short Deck (6+) No-Limit, Ante-only
Mixed (H.O.R.S.E., 8-Game, Dealer's Choice) Rotation

Venues can add custom game types. Each game type has a max players setting (e.g., Hold'em = 10, Short Deck = 6).

3.2 Table Management

Table lifecycle: Closed → Open → Breaking → Closed

Opening a table:

  1. Operator selects game type and stakes from dropdown
  2. System applies default rake structure (editable)
  3. Table status changes to "open"
  4. Display nodes immediately show the table in the "Games Running" view
  5. Waitlist players for this game/stakes are notified

Table status board (operator view):

┌─────────────────────────────────────────────────────────────┐
│  TABLE MANAGEMENT                                           │
│                                                             │
│  Table 1  │ NLH 1/2    │ 8/10 seats  │ ● OPEN  │ [Manage] │
│  Table 2  │ PLO 2/5    │ 6/6 seats   │ ● OPEN  │ [Manage] │
│  Table 3  │ NLH 1/2    │ 9/10 seats  │ ● OPEN  │ [Manage] │
│  Table 4  │ NLH 2/5    │ 5/10 seats  │ ● OPEN  │ [Manage] │
│  Table 5  │ —          │ —           │ ○ CLOSED │ [Open]   │
│  Table 6  │ Tournament │ Tournament  │ ● TOURNY │          │
│                                                             │
│  [Open New Table]  [Close Table]  [Break Table]             │
└─────────────────────────────────────────────────────────────┘

Seat map (per table):

┌──────────────────────────────────────────┐
│  TABLE 1 — NLH 1/2 — 8/10 occupied      │
│                                          │
│     [8]  [9]  [10]                       │
│   [7]            [1]                     │
│   [6]            [2]                     │
│     [5]  [4]  [3]                        │
│                                          │
│  ● = Occupied  ○ = Empty  ⊘ = Reserved   │
│                                          │
│  Seat 1: Mike S. (sat 2h15m, in €200)   │
│  Seat 2: — EMPTY —         [Seat Player] │
│  Seat 3: Anna K. (sat 45m, in €100)     │
│  ...                                     │
└──────────────────────────────────────────┘

3.3 Waitlist Management

Waitlist structure: One waitlist per game-type + stakes combination. If a venue runs NLH 1/2 and NLH 2/5, those are separate waitlists.

Player joins waitlist:

  1. Player requests via mobile PWA or operator adds them
  2. System assigns queue position
  3. Player sees their position on mobile in real-time
  4. When a seat opens, the top player is called

Calling a player:

  1. Seat opens at a table matching the waitlist
  2. System notifies the first player (mobile push + SMS optional)
  3. Player has a configurable response window (default: 5 minutes)
  4. If player responds "on my way" → seat is reserved
  5. If player doesn't respond → marked as expired, next player called
  6. Operator can manually override (skip players, call out of order)

Waitlist display (shown on venue TVs):

┌──────────────────────────────────────────────────┐
│  WAITLIST                                        │
│                                                  │
│  NLH 1/2         │  NLH 2/5      │  PLO 2/5     │
│  1. Mike S.       │  1. David R.   │  No waitlist │
│  2. Anna K.       │  2. Tom H.     │              │
│  3. Chris L.      │                │              │
│  4. Sarah M.      │                │              │
│                   │                │              │
│  Tables: 2 open   │  Tables: 1     │  Tables: 1   │
│  Seats avail: 1   │  Seats: FULL   │  Seats: 2    │
└──────────────────────────────────────────────────┘

3.4 Session Tracking

When a player sits down at a cash table, a session begins. The session tracks:

  • When they sat down
  • All buy-ins and rebuys (amounts and timestamps)
  • When they stood up
  • Cashout amount (optional — not all venues track this)
  • Total duration

Privacy considerations:

  • Buy-in and cashout tracking is venue-configurable. Some venues want full financial tracking for analytics. Some just want to know who's playing.
  • Individual session financials are never visible to other players
  • Aggregated data (average buy-in, average session length) available for analytics
  • Players can see their own session history in their mobile profile

3.5 Must-Move Tables

When a main game is full and demand is high, venues open a "must-move" table. Players at the must-move table get automatically moved to the main table when a seat opens (longest-waiting first).

How it works:

  1. Operator opens Table 3 as a must-move for Table 1 (both NLH 1/2)
  2. System tracks must-move players' time at Table 3
  3. When a seat opens at Table 1, the player who has been at Table 3 the longest is notified
  4. Player moves to Table 1. Their session updates (new table, same game, continuous clock)
  5. If the must-move table gets short-handed (≤4 players), operator can break it and move remaining players to the main game's waitlist

3.6 Rake Tracking

Configurable rake structures:

Type How It Works Example
Percentage with cap X% of each pot, capped at Y 5% up to €10 per hand
Time rake Fixed amount per time interval €6 per 30 minutes per seat
Flat per hand Fixed amount per hand dealt €1 per hand
No rake Used for private games

No-flop-no-drop: Configurable per rake structure. If no flop is dealt, no rake is taken.

Rake is logged per table per period (configurable: per hour, per shift, per session). Venue owner sees daily/weekly/monthly rake revenue in analytics.

3.7 Seat Change Requests

Players can request a seat change within the same game or to a different table:

  1. Player requests via mobile or tells the floor (operator enters)
  2. System queues the request
  3. When the requested seat (or any seat at the target table) opens, the requesting player is offered it
  4. Player accepts → moved. Player declines → request stays in queue.
  5. Seat change requests have lower priority than new waitlist entries (configurable)

4. Display Integration

Cash game views use the same display node infrastructure as tournaments. A display node can be assigned to any of these views:

Cash Game Views

Games Running Board: Shows all active cash games — game type, stakes, seat availability, waitlist depth. Updated in real-time. This is the first thing a player sees when they walk in.

Table Detail View: Shows a specific table's seat map, current players (first name + last initial), and game info. Useful for large rooms where players can't easily see which seats are open.

Waitlist View: Shows all active waitlists, player names, and positions. Players can check their position without asking the floor.

Combined View (Auto-Cycle): Cycles between games running, waitlist, and tournament info. Configurable cycle timing. Most venues will use this on their main lobby TV.

Display Priority

When a tournament and cash games are running simultaneously:

  1. Screens assigned to tournament → show tournament
  2. Screens assigned to cash → show cash
  3. Screens assigned to "auto" → show tournament during active levels, cash during breaks
  4. Info screen playlists continue on screens not assigned to either

5. Player Mobile Experience

Cash Game Features on Mobile PWA

View current games: See all running games, stakes, seat availability without walking to the venue or calling.

Join waitlist remotely: A player at home can join the 2/5 NLH waitlist, see their position, and head to the venue when they're close to the top. Game changer for player retention.

Session dashboard (during play):

┌─────────────────────────────┐
│  YOUR SESSION                │
│                              │
│  Table 1 — NLH 1/2          │
│  Seat 4                      │
│  Playing for: 2h 45m         │
│  Total in: €300              │
│                              │
│  [Request Seat Change]       │
│  [Leave Table]               │
└─────────────────────────────┘

Cash game history: See all past sessions — dates, games played, duration, buy-in/cashout (if tracked). Lifetime stats: total hours played, favorite game type, average session length.


6. API Design

REST (Operator)

Prefix: /api/v1

Cash Tables:

GET    /cash/tables                          List all tables (with current status)
POST   /cash/tables                          Create/configure a table
GET    /cash/tables/:id                      Get table detail (seats, players)
PUT    /cash/tables/:id                      Update table config
POST   /cash/tables/:id/open                Open table (set game, stakes)
POST   /cash/tables/:id/close               Close table (end all sessions)
POST   /cash/tables/:id/break               Start breaking table

Seats:

POST   /cash/tables/:id/seats/:num/sit      Seat a player
POST   /cash/tables/:id/seats/:num/stand    Player stands up
POST   /cash/tables/:id/seats/:num/reserve  Reserve seat (waitlist call)
POST   /cash/tables/:id/seats/:num/away     Mark player away
POST   /cash/tables/:id/transfer            Transfer player between seats/tables

Sessions:

GET    /cash/sessions                        List sessions (active + recent)
GET    /cash/sessions/:id                    Get session detail
POST   /cash/sessions/:id/buyin             Add buy-in/rebuy to session
POST   /cash/sessions/:id/cashout           Close session with cashout

Waitlists:

GET    /cash/waitlists                       List active waitlists
POST   /cash/waitlists/:id/join             Add player to waitlist
POST   /cash/waitlists/:id/call             Call next player
POST   /cash/waitlists/:id/remove           Remove player from waitlist
GET    /cash/waitlists/:id/position          Get player's position (used by mobile)

Seat Changes:

POST   /cash/seat-changes                    Request seat change
GET    /cash/seat-changes                    List pending requests
POST   /cash/seat-changes/:id/fulfill       Fulfill request
POST   /cash/seat-changes/:id/cancel        Cancel request

Game Types:

GET    /cash/game-types                      List all game types
POST   /cash/game-types                      Create custom game type
PUT    /cash/game-types/:id                  Update

Rake:

GET    /cash/rake-structures                 List rake structures
POST   /cash/rake-structures                 Create
GET    /cash/rake/report                     Rake report (date range, per table/game)
POST   /cash/rake/log                        Log rake for a period

WebSocket (Real-Time)

Cash game state updates use the same WebSocket infrastructure as tournaments:

ws://leaf.local/ws/cash/tables       → Real-time table status updates
ws://leaf.local/ws/cash/waitlist     → Waitlist position updates
ws://leaf.local/ws/cash/session/:id  → Player's own session updates

Player Mobile API

GET    /api/v1/me/cash/sessions              My cash session history
GET    /api/v1/me/cash/sessions/stats        My lifetime cash stats
GET    /api/v1/me/cash/waitlist              My current waitlist positions
POST   /api/v1/me/cash/waitlist/join         Join a waitlist
POST   /api/v1/me/cash/waitlist/leave        Leave a waitlist
POST   /api/v1/me/cash/seat-change           Request seat change

7. Sync & Cloud Features

Cash game data syncs to Core using the same NATS JetStream infrastructure as tournaments.

What syncs:

  • Game type registry (Core → Leaf for multi-venue consistency, Leaf → Core for custom types)
  • Session records (Leaf → Core for player history and analytics)
  • Rake logs (Leaf → Core for financial reporting)
  • Waitlist activity is NOT synced (ephemeral, local-only)
  • Seat status is NOT synced in real-time (local-only, too volatile)

Cloud features (require subscription):

  • Cross-venue cash game history for players
  • Remote waitlist joining (player at home joins via play.venue.felt.io)
  • Cash game analytics (revenue per game type, table utilization, peak hours)
  • Multi-venue game type standardization (operator defines games once, pushes to all venues)

8. Roadmap

v2.0 — Core Cash Game

  • Game type registry with built-in types
  • Cash table management (open, close, seat tracking)
  • Waitlist management with player notification
  • Session tracking (start, buy-in, rebuy, cashout, end)
  • Display views: games running board, waitlist, table detail
  • Player mobile: view games, join waitlist, session dashboard
  • Basic rake tracking (percentage with cap)

v2.1 — Advanced Features

  • Must-move table automation
  • Seat change request system
  • All rake types (time rake, flat, no-flop-no-drop)
  • Table transfer with continuous session tracking
  • Waitlist SMS notifications (Twilio integration)
  • Auto-open/close tables based on waitlist depth (configurable threshold)
  • Cash game stats in player profiles

v2.2 — Analytics & Integration

  • Revenue dashboards (rake by game, table, period)
  • Table utilization analytics (seats occupied vs. available over time)
  • Peak hour analysis (when are games fullest?)
  • Player analytics (session frequency, favorite games, average duration)
  • Integration with Phase 3 dealer scheduling (assign dealers to cash tables)

Phase 2 builds on Phase 1's infrastructure — same Leaf, same displays, same network, same player database. Cash games are additive, not a separate product.