Appearance
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
| Service | Stack | Role |
|---|---|---|
| game-server | Express + WebSocket | Turn-based game rooms (PokerTable, ArenaTable). BaseTable SDK + ParticipantManager. Zero wallet awareness. Also serves the operator endpoints. |
| evm-bank | Fastify | The 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-server | Fastify + Better Auth | Email/password, JWT (Ed25519/JWKS), SIWE wallet linking. Three-layer identity (users → user_auth → better_auth.*). |
| hosted-agent-service | Fastify + WebSocket | AI agents. Stateless across restarts (seat reconciler). Skill-based: core/ is game-agnostic, skills/<game>/<format>/ is format-specific. |
| litellm | LiteLLM (BerriAI) | LLM routing gateway. Stateless, no DB. Multi-provider with per-call cost capture. |
| engine | Thirdweb Engine | All server-initiated chain writes — eliminates nonce collisions. |
Next.js apps consume these:
| App | Port | Notes |
|---|---|---|
poker | 3674 | The poker UI (cash + tournament + agents) |
admin | 3666 | Operator dashboard — a thin frontend (tables, agents, reconciler, observatory, tournaments); operator endpoints live on game-server + auth-server, money endpoints on the bank |
dealer | 3662 | Numero card game UI |
arena | 3661 | Multiplayer Numero (BaseTable consumer #2) |
frontpage | 3660 | Landing page |
docs | 3663 | This site (VitePress) |
Boundaries
Three structural lines the codebase enforces by check scripts, not just convention:
Game-server has zero wallet awareness. No
Hex/Addresstypes, no viem imports except hash primitives, no@numero/contractsimports, 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 inapps/game-server/src/. See Chain of Custody 4.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.
hosted-agent-service
core/is game-agnostic. No'fold'literals, nosit_downstrings, no poker terms. Skills exportwireMessageMapbuilders 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 = engineseat.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 →
registerAndDepositon-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_testPostgres).
See also
- Adding a Game Type — extending BaseTable for a new game
- Adding a New Action — wire-format + UI-action pipeline
- Auth Server — identity, JWT, SIWE
- Observability — span families, tripwires, queries