Skip to content

Architecture

Numero is a blockchain-backed card game platform. Players deposit a single ERC-20 chip (SKPK) into pooled table contracts, play poker (cash + Sit-and-Go) and the Numero card game off-chain, and withdraw winnings. AI agents play through the same surface as humans.

Services

ServiceStackRole
game-serverExpress + WebSocketTurn-based game rooms (PokerTable, ArenaTable). BaseTable SDK + ParticipantManager. Zero wallet awareness. Also serves the operator endpoints.
evm-bankFastifyThe cage/bank (money-only). Every on-chain operation: deposits, withdrawals, settlement, reconciliation, escape. JWT for browser, service-key for game-server. (solana-bank is the Solana counterpart; game-server selects one via BANK_URL.)
auth-serverFastify + Better AuthEmail/password, JWT (Ed25519/JWKS), SIWE wallet linking. Three-layer identity (usersuser_authbetter_auth.*).
hosted-agent-serviceFastify + WebSocketAI agents. Stateless across restarts (seat reconciler). Skill-based: core/ is game-agnostic, skills/<game>/<format>/ is format-specific.
litellmLiteLLM (BerriAI)LLM routing gateway. Stateless, no DB. Multi-provider with per-call cost capture.
engineThirdweb EngineAll server-initiated chain writes — eliminates nonce collisions.

Next.js apps consume these:

AppPortNotes
poker3674The poker UI (cash + tournament + agents)
admin3666Operator dashboard — a thin frontend (tables, agents, reconciler, observatory, tournaments); operator endpoints live on game-server + auth-server, money endpoints on the bank
dealer3662Numero card game UI
arena3661Multiplayer Numero (BaseTable consumer #2)
frontpage3660Landing page
docs3663This site (VitePress)

Boundaries

Three structural lines the codebase enforces by check scripts, not just convention:

  1. Game-server has zero wallet awareness. No Hex / Address types, no viem imports except hash primitives, no @numero/contracts imports, no "wallet"/"Wallet" matches. Game-server exchanges {userId, tableId, amount} with the bank; the bank is the only service that talks to the chain. Enforced by four grep invariants in apps/game-server/src/. See Chain of Custody 4.

  2. Next.js apps never touch the database. All DB operations route through game-server, the bank, or auth-server. Browser → service → DB. This keeps schema knowledge out of the client and lets the service layer enforce policy.

  3. hosted-agent-service core/ is game-agnostic. No 'fold' literals, no sit_down strings, no poker terms. Skills export wireMessageMap builders so format-specific WS shapes never leak into the orchestrator. Enforced by scripts/check-hosted-agent-core-boundary.sh.

Domain model

JWT sub = users.id UUID. Game-server, the bank, hosted-agent-service all verify against the same JWKS from auth-server. Wallets are linked via SIWE; ownership is user-keyed, not wallet-keyed (a user with five wallets is one player).

Table model

tables row + table_players per-seat state. One game type today: poker (with two modes: cash, sng). Arena is a separate game type. Adding a third game means writing a class that extends BaseTable and implements ~13 abstract methods.

PokerTable handles cash by default; for mode='sng' it instantiates a TournamentOrchestrator and routes lifecycle decisions through it. PokerTable's tournament-aware diff is four small inflection points; see SnG Tournaments. (In the money flow below, "the bank" is the chain-selected evm-bank or solana-bank.)

Persistence-boundary contract

Every game type's durable per-boundary state — replay JSONB, per-participant chip deltas, chain hash anchor — writes inside a single Drizzle transaction wrapped by BaseTable.persistAtBoundary(context). Each game declares its lock-in moment (poker: hand end; arena: round end + game end) and implements persistGameBoundary(tx, context).

If the commit fails, the game-type handler surfaces the error (e.g. emits {type:'error', code:'hand_persist_failed'}) instead of broadcasting hand_result with stale state. Test-mode fault injection lives at POST /internal/test/persist-fail-next. See Bulletproof State.

Chip semantics

One ERC-20 token: SKPK (6 decimals, USDC-backed). The platform accounts in integer chips everywhere off-chain (UI, DB, credit-chips); 1 chip = 1 SKPK. The chip → on-chain base-unit conversion (× 10⁶) happens only at the contract boundary via chipsToTokenUnits (@numero/types) — every deposit, withdraw, and settlement crosses it. (The legacy zero-decimal NUMERO/CTN token is gone.)

  • Cash: table_players.chip_balance = SKPK equity = engine seat.stack().
  • Tournament: table_players.chip_balance = SKPK equity in the prize pool. seat.stack() = tournament chips (ephemeral counters, never on-chain). Conflating these is the load-bearing bug class for tournaments.

The custody invariant dispatches on tables.mode: cash sums engine totalChips(); sng sums chip_balance of unsettled seats. See SnG Tournaments §D3.

Money flow

  • Sit = deposit. One permit signature → registerAndDeposit on-chain → chips credited at the seat.
  • Stand = release seat. WS message marks sitting_out. Funds stay at the seat.
  • Withdraw is independent of seating. You can withdraw available chips while seated (top-up flow), while sitting-out, or at the rail. Always goes through the bank's /api/withdraw.

Settlement happens at withdraw, not at stand-up. The server signs an EIP-712 receipt covering the cumulative session, the contract verifies, the chain pays.

Rake is a participant (player_id='house' row per hand). A reconciler walks unclaimed rows and pushes accumulated rake on-chain per game type with a chain-of-custody proof. See Rake Reconciliation.

Contract

EIP-2535 Diamond with facets: TableFacet, PokerFacet, NumeroFacet, SettlementFacet, AdminFacet, FairnessFacet, TransferFacet. Deployed on Base Sepolia (production target: Base mainnet).

Hand-written ABIs are forbidden. @numero/contracts/abis re-exports Hardhat-generated types via the <Name>$Type['abi'] interface so viem gets full function-name + arg + return-type inference.

Telemetry

OpenTelemetry everywhere. Local: Jaeger at :4318 (boot via InDusk local-telemetry). Production: Dash0.

Every service that emits manual spans declares its contract in apps/<service>/src/telemetry-contract.ts. pnpm typecheck chains into a telemetry-contract check which fails the build if a contract entry has no matching callsite (refactor dropped a span) or vice-versa (new span without declaration). Services enforced today: game-server, evm-bank, hosted-agent-service, frontend poker, playtest-npc-service.

Per-hand hand_envelopes row captures OHH JSON, span topology, chip reconciliation, and anomaly rule output. The observatory dashboard at admin /observatory surfaces anything weird. See Bulletproof Observability.

Environment management (Doppler)

Env vars live in Doppler (project numero, local + prd environment roots with per-service leaf configs). Gitignored .env.<profile> files are materialized per app from Doppler:

bash
pnpm env:pull local       # materialize apps/*/.env.local + root .env.local from Doppler
docker compose up -d      # start the stack (hand-written, committed compose files)

Docker-compose files are hand-written and committed: the root docker-compose.yml (local) / docker-compose.production.yml / docker-compose.test.yml each include: per-app fragments at apps/<name>/docker-compose{,.production,.test}.yml. .env.test files are committed with safe non-secret defaults (the test profile is not in Doppler). See How env vars work and Doppler Config Hierarchy.

Domains

  • skillspoker.com — production
  • Local dev and the test profile run behind a local reverse proxy (the test profile uses a separate numero_test Postgres).

See also