Architecture

Layered, one-way dependencies

Each layer could be ripped out without breaking the one below.

client/  React UI — sends actions to the server, renders state from the server
  ↓
server/  Express + ws + Postgres — runs the reducer authoritatively,
         persists state on every action, broadcasts to the room
  ↓
engine/  Pure reducer + bet catalog + chip registry. No I/O.
         JSON-serializable state. Deterministic from a seed.
  • The engine knows nothing about the server protocol or React.
  • The server knows nothing about React; it speaks HTTP + WS and consumes the engine.
  • The client knows nothing about Postgres; it speaks HTTP + WS to the server.

The server is authoritative — it is the only thing that runs the reducer, holds the seed, and anti-cheats by construction. Clients send actions and receive state.

Engine purity (load-bearing)

The engine is a pure reducer over JSON-serializable state with a seeded RNG. These rules are non-negotiable because they are what make online co-op, save/resume, replay, deterministic tests, and “add a chip without rewriting the engine” all the same problem, solved once:

  • No I/O in engine/ — no fetch, no fs, no DB calls, no console.log outside debug.
  • No Math.random() in engine/ — the reducer consumes from state.rng only.
  • No Date.now() in engine/ — the server stamps timestamps if needed.
  • No closures in state. No class instances. No Date objects. No refs.
  • TableState round-trips through JSON.stringify losslessly.

The determinism / JSON round-trip test in engine/src/reducer.test.ts is the canary for these invariants. Don’t disable it.

Note: engine/tsconfig.json excludes **/*.test.ts, so tsc / CI does not typecheck test files (vitest strips types via esbuild). A @ts-expect-error fence in a test is a false fence — compile-time guarantees must be enforced in src/ (the build), not in a test.

Key files

FileWhat it is
engine/src/state.tsTableState, PlacedBet, Phase, Settlement. Read first to understand the shape.
engine/src/actions.tsThe Action union. Every input to the engine.
engine/src/bets.tsThe bet catalog. New bets live here.
engine/src/chips.tsThe chip contract + registry. New chips live here.
engine/src/resolver.tsPure (state, roll) → settlements. The casino-faithful math.
engine/src/reducer.tsThe only function that mutates state. RNG, phase transitions, money flow.
server/src/rooms.tsRoomManager: the only place that calls reduce(). In-memory + write-through to Postgres.
server/src/ws.tsWebSocket attach at /ws/rooms/:id. Initial state on connect, broadcast on every action.
server/src/db.tsThe rooms table (single jsonb blob per room).
client/src/api.tsuseGameRoom(): room-create-or-restore + WS + state subscription.

Auth (per-bettor opaque tokens)

POST /api/rooms returns tokens: { p1: "<hex>", ... } — one 128-bit hex token per bettor, minted once, never returned again. WS attach uses ?player=<id>&token=<token>; HTTP POST /api/rooms/:id/actions requires Authorization: Bearer <token> for player-owned actions. Shared actions (ROLL, START_ROUND) stay token-free. Tokens are stored in rooms.tokens JSONB (immutable after creation, never broadcast). Legacy rooms (NULL tokens) stay in unbound mode. The WS message handler also enforces token validation on unbound connections to tokenized rooms — closing the impersonation bypass.

Full user accounts (signup / login / JWT) are deferred — the anonymous-token scheme is appropriate for co-op rooms shared by link or via the lobby.