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/— nofetch, nofs, no DB calls, noconsole.logoutside debug. - No
Math.random()inengine/— the reducer consumes fromstate.rngonly. - No
Date.now()inengine/— the server stamps timestamps if needed. - No closures in state. No class instances. No
Dateobjects. No refs. TableStateround-trips throughJSON.stringifylosslessly.
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.jsonexcludes**/*.test.ts, sotsc/ CI does not typecheck test files (vitest strips types via esbuild). A@ts-expect-errorfence in a test is a false fence — compile-time guarantees must be enforced insrc/(the build), not in a test.
Key files
| File | What it is |
|---|---|
engine/src/state.ts | TableState, PlacedBet, Phase, Settlement. Read first to understand the shape. |
engine/src/actions.ts | The Action union. Every input to the engine. |
engine/src/bets.ts | The bet catalog. New bets live here. |
engine/src/chips.ts | The chip contract + registry. New chips live here. |
engine/src/resolver.ts | Pure (state, roll) → settlements. The casino-faithful math. |
engine/src/reducer.ts | The only function that mutates state. RNG, phase transitions, money flow. |
server/src/rooms.ts | RoomManager: the only place that calls reduce(). In-memory + write-through to Postgres. |
server/src/ws.ts | WebSocket attach at /ws/rooms/:id. Initial state on connect, broadcast on every action. |
server/src/db.ts | The rooms table (single jsonb blob per room). |
client/src/api.ts | useGameRoom(): 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.