Spinza — repository guide
Spinza (www.spinza.dev) is a premium fictional social casino. Virtual credits only: no deposits, no withdrawals, no purchases, no crypto, no cash value, no prizes. The full product brief is in docs/SPEC-original.md; this file is the condensed working guide.
Layout (pnpm workspaces + Turborepo)
| Path | Package | Role |
|---|---|---|
packages/shared |
@spinza/shared |
Economy constants (10,000 SC start, bet levels, daily rewards, XP curve), zod schemas, formatting (formatSC — never a currency sign), API DTO types. |
packages/game-core |
@spinza/game-core |
Also: src/crash/ (crash engine: P(crash ≥ x) = rtp/x, SHA-256 commit/reveal, curves exp/power/steps, pacing events, multiplierAt shared with the browser via crash/curve.ts) and src/arcade/ (instant resolvers + ladder engine m_k = rtp·Π(1/p_i) — strategy-independent RTP). |
Server-only game engine: CryptoRng (crypto.randomBytes, rejection sampling — Math.random is banned for outcomes), reels, ways/lines evaluation, wild/scatter/cascade/hold-respin/pick-bonus/meter/heat/mystery/quantum/dynamic-grid features, runSpin(), simulate(), certify(), validateDefinition(), defineGame(). ./client export = types only (safe for the browser). |
||
games/ |
@spinza/games |
20 slot definitions (src/<slug>/index.ts, registry.ts), 10 Risk Games (src/crash/index.ts, cash-out/crash engine), 5 Spinza Originals — Beyond Slots (src/arcade/index.ts: Dropzone, Grid//Break, Orbit = instant; The Vault, Escape 99 = ladder), calibration.json (payScale per slug+version), certifications/<slug>.json (PASS/FAIL reports for all 35 games). |
packages/database |
@spinza/database |
Drizzle schema (25 tables, snake_case casing), migrations in drizzle/, seed.ts (games sync, achievements, missions, levels, flags, settings), sync-games.ts. |
apps/api |
@spinza/api |
Fastify on 127.0.0.1:8231. Auth (argon2id, opaque 256-bit sessions hashed, recovery codes), atomic spin (services/spin.ts), wallet ledger (services/wallet.ts), progression, rewards, leaderboards, admin (TOTP), health. |
apps/simulator |
@spinza/simulator |
CLI `pnpm sim <validate |
apps/web |
@spinza/web |
Next 16 (port 8230). /api/* is rewritten to the Fastify service. Game screen = src/components/game/* (PixiJS renderer, procedural symbols, WebAudio sound). Admin console under /admin. |
Game families & API
| Family | Kind | Play API | Client |
|---|---|---|---|
| Slots (20) | slot |
POST /api/games/:slug/spin |
components/game/* (PixiJS renderer) |
| Risk Games (10) | crash |
POST /api/crash/:slug/start → poll GET /api/crash/:slug/rounds/:id → POST /api/crash/:slug/cashout (auto cash-out settles server-side; crash_rounds table; lazy settlement) |
components/crash/* (2D canvas scenes) |
| Beyond Slots instant (3) | arcade |
POST /api/arcade/:slug/play {bet, clientRoundId, input} |
components/arcade/{dropzone,gridbreak,orbit}.tsx |
| Beyond Slots ladder (2) | arcade |
POST /api/arcade/:slug/start → POST /api/arcade/:slug/act {roundId, action} (arcade_sessions) |
components/arcade/{vault,escape}.tsx |
Every settled round of any family goes through apps/api/src/services/settle.ts (settleRound: game_rounds row, stats, XP, achievements, missions, leaderboards). Lobby categories come from summary.kind/category written by sync-games.ts.
Non-negotiable rules
- Outcomes are decided only in
apps/apiviarunSpin. The browser animatesresult.steps; it never computes wins. - Every balance change goes through
lockWallet→applyCredit(ledger row) →saveWalletinside one Postgres transaction.sum(credit_transactions.amount) == wallets.balanceis an invariant checked in admin. - Spin idempotency: unique
(user_id, client_round_id); duplicates (even concurrent) return the stored round withreplayed: true. - A game is
publishedonly ifgames/certifications/<slug>.jsonisPASSfor its exactversion. Changing pays/weights ⇒ bumpversion, re-runpnpm sim calibrate <slug>thenpnpm sim certify <slug>. - Never log passwords, hashes, recovery codes, session tokens (pino redact list in
apps/api/src/app.ts). - No payments, no
$/€anywhere in UI copy. Credits are formatted10,000 SC.
Commands
pnpm install
createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required
pnpm dev:api # 8231 pnpm dev:web # 8230
pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all|arcade --spins 10000000 --threads 26 | pnpm sim certify all|crash|arcade --spins 10000000
pnpm --filter @spinza/game-core test # engine unit tests
pnpm --filter @spinza/api test # integration tests (needs local DB/Redis)
pnpm admin:create <username> [password] # prints TOTP secret onceDeployment (MacLustr)
mld deploy spinza from the laptop (~/Desktop/cluster-skill/mld). Manifest with secrets: M1M32:~/dispatch/apps/spinza.json (copy: deploy/spinza.mld.json, gitignored). Node M3U96a, PM2 spinza-api (8231 loopback) + spinza-web (8230) + spinza-ngrok (www.spinza.dev). Postgres db spinza + Redis local to the node. Hooks: install → migrate + seed → next build. Backups: deploy/backup.sh (daily launchd on the node).
Gotchas
- drizzle-orm ≥0.44 wraps pg errors: use
pgCode(e)(apps/api/src/lib/pg.ts), note.code. - Way/line pays are in bet units (
betDivisor: 25 for 5-reel ways, 50 for 6+ reels,lines.lengthfor line games); scatter/bonus/coin values are × total bet.payScalefromcalibration.jsonscales all non-jackpot wins. - Certification tolerance is statistical:
max(0.4%, 3σ/√n)capped at 1.5% — high-volatility games need ≥10M spins. - Turbopack: import workspace packages without
.jsextensions; browser code imports@spinza/game-core/clientonly. - PixiJS renderer must be created client-side only (
GameClientis"use client";Application.initis async).