THE STRATEGY WORKSPACE / Splendor Lab Vanilla rules
THE SYSTEM DESIGN

Clear rules. Clear boundaries.

UML documentation of the implemented TypeScript architecture.

8 architecture views
ONE APPLICATION

Next.js, from board to benchmark.

Pure game rules, an abstract player SDK, isolated bot execution, independent clocks, and persisted evaluation reports. Each boundary has one responsibility.

1. Domain class diagram

The diagram uses interfaces for serializable values and classes for stateful behavior. The rules engine remains a pure module; no UI or database dependencies enter game transitions. Multiplicities describe a single vanilla game. A reservation remembers whether its card came from the public market.

View Mermaid source
classDiagram
  class GameState {
    <<interface>>
    +rulesVersion: string
    +currentPlayer: number
    +phase: Phase
    +turn: number
    +decision: number
    +finalRound: boolean
    +status: playing | finished
    +winners: number[]
  }
  class PlayerState {
    <<interface>>
    +tokens: Tokens
    +cards: CardId[]
    +reserved: Reservation[]
    +nobles: NobleId[]
    +turns: number
  }
  class Card {
    <<interface>>
    +id: string
    +tier: number
    +bonus: Color
    +points: number
    +cost: Cost
  }
  class Noble {
    <<interface>>
    +id: string
    +points: number
    +cost: Cost
  }
  class Action {
    <<union>>
    take
    buy
    reserve
    discard
    noble
  }
  class Observation {
    <<interface>>
    +you: number
    +players: PlayerView[]
    +deckCounts: number[]
    +legalActions: Action[]
    +clock: ClockSnapshot
  }
  class RulesModule {
    <<module>>
    +createGame(options) GameState
    +legalActions(state) Action[]
    +validateAction(state, candidate) Action
    +applyAction(state, action) GameState
    +observe(state, seat) Observation
    +assertInvariants(state) boolean
  }
  GameState "1" *-- "2..4" PlayerState : players
  GameState "1" o-- "90" Card : decks, market, holdings
  GameState "1" o-- "3..5" Noble : active and acquired
  RulesModule ..> GameState : immutable transitions
  RulesModule ..> Action : validates
  RulesModule ..> Observation : filters private information

2. Player, runner, and clock class diagram

`SplendorPlayer` is an actual TypeScript abstract class. Its compiled implementation is also the virtual `splendor` module available inside QuickJS. Helpers share payment logic with the authoritative engine and use only the observation. Subclasses may return an action or a promise. The game state, hidden decks, platform credentials, and clock mutation methods are never exposed to a bot.

View Mermaid source
classDiagram
  class SplendorPlayer {
    <<abstract>>
    +chooseAction(view) ActionOrPromise
    +getSecret(name) stringOrUndefined
    +getSelf(view) PlayerView
    +getLegalActions(view, type) Action[]
    +getBonuses(player) Cost
    +getPoints(player) number
    +getCost(card, player) Cost
    +getPayments(card, player) Tokens[]
    +canAfford(card, player) boolean
    +getAffordableCards(view) Card[]
    +getEligibleNobles(view, player) Noble[]
    +chooseRandomAction(view) Action
  }
  class RandomPlayer {
    +chooseAction(view) Action
  }
  class GreedyPlayer {
    +chooseAction(view) Action
  }
  class BotRunner {
    -worker: Worker
    -pending: Pending
    -failure: string
    +ready: Promise
    +chooseAction(view, budgetMs) Promise
    +close() Promise
  }
  class ChessClock {
    +config: ClockConfig
    -remaining: number[]
    -active: ActiveDecision
    -completedTurns: Set
    +getRemaining(seat) number
    +beginDecision(seat) number
    +endDecision(seat) ClockCharge
    +completeTurn(seat, turnId, assisted) void
    +snapshot() ClockSnapshot
  }
  class ClockConfig {
    <<interface>>
    +initialMs: number = 60000
    +incrementMs: number = 1000
  }
  class SimulationModule {
    <<module>>
    +simulate(options) Promise~GameRecord~
    +replay(record) GameState
  }
  SplendorPlayer <|-- RandomPlayer
  SplendorPlayer <|-- GreedyPlayer
  SimulationModule ..> BotRunner : one per seat
  SimulationModule ..> ChessClock : independent balances
  ChessClock *-- ClockConfig
  BotRunner ..> SplendorPlayer : executes in QuickJS

3. Timed decision sequence

Only the active seat spends time. The host uses a monotonic clock and checks the actual completion deadline; a late response cannot win a race against an overdue timer. Observation generation, authoritative validation, rendering, queue delay, and the opponent's thinking are excluded. IPC and asynchronous response waiting are charged. Initialization has a separate 1-second execution allowance and 5-second local worker startup deadline (15 seconds in Modal), before the bot sees a game observation.

View Mermaid source
sequenceDiagram
  participant Simulation
  participant Rules
  participant Clock as ChessClock
  participant Runner as BotRunner
  participant Bot as QuickJS player
  Simulation->>Rules: observe(state, activeSeat)
  Rules-->>Simulation: filtered observation and legal actions
  Simulation->>Clock: beginDecision(activeSeat)
  Clock-->>Simulation: remaining budget
  Simulation->>Runner: chooseAction(view with clock, budget)
  Runner->>Bot: invoke chooseAction(view)
  alt response arrives before deadline
    Bot-->>Runner: action or fulfilled promise
    Runner-->>Simulation: bounded JSON value
    Simulation->>Clock: endDecision(activeSeat)
    Simulation->>Rules: applyAction(state, candidate)
    alt valid and complete turn
      Rules-->>Simulation: next player state
      Simulation->>Clock: completeTurn(seat, turnId)
      Clock-->>Simulation: add increment once
    else valid but discard or noble choice required
      Rules-->>Simulation: same player's next phase
    else invalid action
      Rules-->>Simulation: InvalidAction with no state mutation
      Simulation->>Simulation: ranked forfeit or logged practice fallback
    end
  else clock expires or bot crashes
    Runner->>Bot: terminate execution
    Runner-->>Simulation: BotFault
    Simulation->>Clock: endDecision(activeSeat)
    Simulation->>Simulation: ranked forfeit or disable bot in practice
  end
  Simulation->>Simulation: append action, clock snapshot, and state hash

4. Turn state machine

The 60 + 1 clock is a competition policy around the vanilla rules. A move is a complete turn, not an individual SDK call. All mandatory decisions share the same remaining balance. No increment is awarded for a rejected move, a timeout, or a turn requiring practice assistance. No pass action or expansion rule is introduced.

View Mermaid source
stateDiagram-v2
  [*] --> Main
  Main --> Discard: take or reserve leaves more than 10 tokens
  Main --> Noble: multiple nobles eligible
  Main --> TurnComplete: no pending choice
  Discard --> Noble: valid return and multiple nobles eligible
  Discard --> TurnComplete: valid return and no pending choice
  Noble --> TurnComplete: choose one eligible noble
  TurnComplete --> Main: next player and one increment
  TurnComplete --> Finished: final round completed
  Main --> Forfeit: invalid action or expired clock in ranked mode
  Discard --> Forfeit: invalid action or expired clock in ranked mode
  Noble --> Forfeit: invalid action or expired clock in ranked mode
  Main --> Incomplete: no legal action or evaluator turn cap
  Finished --> [*]
  Forfeit --> [*]
  Incomplete --> [*]

5. Deployment components

The Next.js API verifies Supabase bearer tokens and owns all authorization. PostgreSQL RPCs atomically reserve per-user capacity. A short API call launches a Modal sandbox and persists its ID; subsequent status requests reconcile results. The daily authenticated recovery endpoint handles abandoned jobs. Sandboxes receive code and configuration over stdin, without database or storage credentials. Per-bot provider keys are decrypted only for an owner-authorized run and injected into that bot’s QuickJS context. Only bounded public HTTPS is exposed to bot code.

View Mermaid source
flowchart LR
  browser["Next.js React UI"] --> auth["Supabase Auth"]
  browser --> routes["Vercel / Next.js route handlers"]
  routes --> db["Supabase PostgreSQL / ownership and quotas"]
  routes --> gateway["Authenticated Cloudflare Worker"]
  gateway --> r2["Private R2 / projects and reports"]
  routes --> modal["Modal Sandbox / persisted run ID"]
  modal --> engine["TypeScript evaluation and pure rules"]
  engine --> clocks["Independent Fischer clocks"]
  engine --> runners["Bot workers / QuickJS / 64 MiB each"]
  modal --> output["Bounded stdout report"]
  output --> routes

6. Submission and qualification sequence

A folder must contain `index.ts`. The server bundles only submitted relative modules and the virtual SDK, then stores an immutable project artifact. Two public baselines play four games against the candidate with swapped seats. The candidate must complete all four without faults or incomplete games. Passing is eligibility evidence; every later game still enforces time and memory limits.

View Mermaid source
sequenceDiagram
  participant User
  participant API as Next.js API
  participant DB as Supabase
  participant R2
  participant Modal
  User->>API: authenticated folder submission
  API->>API: validate paths, sizes, imports and bundle
  API->>DB: reserve quota and create pending version
  API->>R2: store immutable project and compiled source
  API->>DB: atomically claim launch
  API->>Modal: launch 512 MiB qualification
  Modal-->>API: sandbox ID
  API->>DB: persist sandbox ID
  API-->>User: pending version
  User->>API: poll status
  API->>Modal: poll persisted sandbox ID and collect stdout
  Modal-->>API: complete report
  API->>R2: persist report
  API->>DB: mark passed or failed
  API-->>User: qualification result

7. Persistence model

Only the service role can access these tables directly. Route handlers expose qualified keyless public bots, the caller's private and pending versions, and owner-only evaluations and practice sessions. Public bot source is intentionally readable for learning. Source/project hashes identify immutable versions; evaluation reports include source hashes and verified game records. Runtime image IDs are pinned in deployment configuration.

View Mermaid source
erDiagram
  AUTH_USER ||--o{ SPLENDOR_BOT : owns
  AUTH_USER ||--o{ SPLENDOR_EVALUATION : runs
  AUTH_USER ||--o{ PRACTICE_SESSION : plays
  SPLENDOR_BOT {
    uuid id PK
    uuid owner_id FK
    string name
    string project_hash
    string source_hash
    string artifact_key
    string qualification
    string sandbox_id
  }
  SPLENDOR_EVALUATION {
    uuid id PK
    uuid owner_id FK
    json config
    string status
    string sandbox_id
    string report_key
  }
  PRACTICE_SESSION {
    uuid id PK
    uuid owner_id FK
    json state
    json clock
    int revision
    boolean busy
  }

8. Network decision and credential boundary

A bot may perform up to eight public HTTPS requests per decision, with four in flight. Requests and responses are bounded to 256 KiB and 1 MiB. Network access is unavailable during initialization or between decisions. Stored keys are authenticated to their owner and bot version with AES-GCM; a keyed fingerprint distinguishes immutable versions with different credentials. Private-key versions cannot be selected by another account. The trusted host receives no platform credentials inside Modal.

View Mermaid source
sequenceDiagram
  participant Host as Simulation and clock
  participant Bot as QuickJS bot
  participant Bridge as HTTPS bridge
  participant Service as Public LLM or API
  Host->>Bot: chooseAction with remaining deadline
  Bot->>Bot: getSecret for this bot only
  Bot->>Bridge: await fetch
  Bridge->>Bridge: validate URL, resolve public IP and pin socket
  Bridge->>Service: bounded HTTPS request
  Note over Host,Service: Active bot clock continues throughout request waiting
  alt response before deadline
    Service-->>Bridge: bounded response
    Bridge-->>Bot: settle promise and resume within deadline
    Bot-->>Host: candidate action
    Host->>Bridge: abort leftover requests
  else clock expires
    Host->>Bot: terminate worker
    Bridge->>Service: abort outstanding request
    Host->>Host: apply strict or assisted fault policy
  end

The clock contract

60 seconds per bot, plus one second per completed turn. Main actions, returns, and noble choices consume the same remaining balance. Time spent by the rules engine, the UI, and the other player is excluded. Assisted turns earn no increment.

The full design, API contracts, failure policies, and deployment boundaries are maintained in docs/architecture.md.