SPB Git

spb/spinza

Public

Beyond Slots: arcade engine (instant + ladder), 5 originals with APIs, 5 game UIs, 15 new posters, admin/docs updates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 8, 2026) parent e589373 Browse files

43 changed files +11,415 −41

43 changed files
modified CLAUDE.md +15 −3
@@ -7,13 +7,25 @@ Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credit
7 | Path | Package | Role | 7 | Path | Package | Role |
8 |---|---|---| 8 |---|---|---|
9 | `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. | 9 | `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. |
10−| `packages/game-core` | `@spinza/game-core` | **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). | 10+| `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). |
11−| `games/` | `@spinza/games` | 20 original game definitions (`src/<slug>/index.ts`), `registry.ts`, `calibration.json` (payScale per slug+version, written by the simulator), `certifications/<slug>.json` (PASS/FAIL reports). | 11+| | | **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). |
12+| `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). |
12 | `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`. | 13 | `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`. |
13 | `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. | 14 | `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. |
14 | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. | 15 | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. |
15 | `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`. | 16 | `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`. |
16 17
18+## Game families & API
19+
20+| Family | Kind | Play API | Client |
21+|---|---|---|---|
22+| Slots (20) | `slot` | `POST /api/games/:slug/spin` | `components/game/*` (PixiJS renderer) |
23+| 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) |
24+| Beyond Slots instant (3) | `arcade` | `POST /api/arcade/:slug/play {bet, clientRoundId, input}` | `components/arcade/{dropzone,gridbreak,orbit}.tsx` |
25+| Beyond Slots ladder (2) | `arcade` | `POST /api/arcade/:slug/start` → `POST /api/arcade/:slug/act {roundId, action}` (`arcade_sessions`) | `components/arcade/{vault,escape}.tsx` |
26+
27+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`.
28+
17 ## Non-negotiable rules 29 ## Non-negotiable rules
18 30
19 - Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins. 31 - Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins.
@@ -29,7 +41,7 @@ Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credit
29 pnpm install 41 pnpm install
30 createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required 42 createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required
31 pnpm dev:api # 8231 pnpm dev:web # 8230 43 pnpm dev:api # 8231 pnpm dev:web # 8230
32−pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all --spins 10000000 --threads 26 | pnpm sim certify all --spins 10000000 44+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
33 pnpm --filter @spinza/game-core test # engine unit tests 45 pnpm --filter @spinza/game-core test # engine unit tests
34 pnpm --filter @spinza/api test # integration tests (needs local DB/Redis) 46 pnpm --filter @spinza/api test # integration tests (needs local DB/Redis)
35 pnpm admin:create <username> [password] # prints TOTP secret once 47 pnpm admin:create <username> [password] # prints TOTP secret once
modified apps/api/src/app.ts +2 −0
@@ -11,6 +11,7 @@ import { rewardRoutes } from "./routes/rewards";
11 import { healthRoutes } from "./routes/health"; 11 import { healthRoutes } from "./routes/health";
12 import { adminRoutes } from "./routes/admin"; 12 import { adminRoutes } from "./routes/admin";
13 import { crashRoutes } from "./routes/crash"; 13 import { crashRoutes } from "./routes/crash";
14+import { arcadeRoutes } from "./routes/arcade";
14 import { refreshSettings } from "./lib/settings"; 15 import { refreshSettings } from "./lib/settings";
15 16
16 const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"]; 17 const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"];
@@ -71,6 +72,7 @@ export async function buildApp(): Promise<FastifyInstance> {
71 await app.register(gameRoutes); 72 await app.register(gameRoutes);
72 await app.register(rewardRoutes); 73 await app.register(rewardRoutes);
73 await app.register(crashRoutes); 74 await app.register(crashRoutes);
75+ await app.register(arcadeRoutes);
74 await app.register(adminRoutes); 76 await app.register(adminRoutes);
75 77
76 return app; 78 return app;
modified apps/api/src/routes/admin.ts +6 −5
@@ -1,7 +1,7 @@
1 import type { FastifyInstance } from "fastify"; 1 import type { FastifyInstance } from "fastify";
2 import os from "node:os"; 2 import os from "node:os";
3 import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database"; 3 import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database";
4−import { GAMES, getGame } from "@spinza/games"; 4+import { GAMES, getGame, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games";
5 import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core"; 5 import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core";
6 import { simulateParallel } from "@spinza/simulator"; 6 import { simulateParallel } from "@spinza/simulator";
7 import { GAME_LIFECYCLE } from "@spinza/shared"; 7 import { GAME_LIFECYCLE } from "@spinza/shared";
@@ -153,13 +153,14 @@ export async function adminRoutes(app: FastifyInstance) {
153 return { 153 return {
154 games: rows.map(({ game, stats }) => { 154 games: rows.map(({ game, stats }) => {
155 const def = getGame(game.slug); 155 const def = getGame(game.slug);
156+ const other = CRASH_BY_SLUG.get(game.slug) ?? ARCADE_BY_SLUG.get(game.slug);
156 return { 157 return {
157 ...game, 158 ...game,
158 enabled: flags[`game.${game.slug}.enabled`] ?? true, 159 enabled: flags[`game.${game.slug}.enabled`] ?? true,
159− rtp: def?.rtp ?? null, 160+ rtp: def?.rtp ?? other?.rtp ?? null,
160− payScale: def?.payScale ?? null, 161+ payScale: def?.payScale ?? (other && "payScale" in other ? other.payScale : null),
161 stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null, 162 stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null,
162− validation: def ? validateDefinition(def) : [{ level: "error", message: "definition missing from library" }], 163+ validation: def ? validateDefinition(def) : other ? [] : [{ level: "error", message: "definition missing from library" }],
163 }; 164 };
164 }), 165 }),
165 }; 166 };
@@ -175,7 +176,7 @@ export async function adminRoutes(app: FastifyInstance) {
175 const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players 176 const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players
176 from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`); 177 from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`);
177 const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10); 178 const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10);
178− return { game: g, definition: getGame(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs }; 179+ return { game: g, definition: getGame(slug) ?? CRASH_BY_SLUG.get(slug) ?? ARCADE_BY_SLUG.get(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs };
179 }); 180 });
180 181
181 app.post("/api/admin/games/:slug/lifecycle", async (req) => { 182 app.post("/api/admin/games/:slug/lifecycle", async (req) => {
added apps/api/src/routes/arcade.ts +218 −0
@@ -0,0 +1,218 @@
1+import type { FastifyInstance } from "fastify";
2+import { and, arcadeSessions, db, desc, eq, games, sql } from "@spinza/database";
3+import { CryptoRng, ladderAdvance, ladderStart, ladderView, resolveInstant, type ArcadeGameDefinition, type LadderAction, type LadderState } from "@spinza/game-core";
4+import { ARCADE_BY_SLUG } from "@spinza/games";
5+import { BET_LEVELS, classifyWin } from "@spinza/shared";
6+import { z } from "zod";
7+import { errors } from "../lib/errors";
8+import { newRoundId } from "../lib/crypto";
9+import { flag, maintenance } from "../lib/settings";
10+import { rateLimit, redis } from "../lib/redis";
11+import { requireUser } from "../plugins/auth";
12+import { applyCredit, lockWallet, saveWallet } from "../services/wallet";
13+import { settleRound } from "../services/settle";
14+import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";
15+
16+type SessionRow = typeof arcadeSessions.$inferSelect;
17+
18+function guard(slug: string): { def: ArcadeGameDefinition } {
19+ const def = ARCADE_BY_SLUG.get(slug);
20+ if (!def) throw errors.notFound("Unknown game");
21+ const m = maintenance();
22+ if (m.enabled) throw errors.maintenance(m.message);
23+ if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable.");
24+ return { def };
25+}
26+
27+function checkBet(def: ArcadeGameDefinition, bet: number) {
28+ if (!(BET_LEVELS as readonly number[]).includes(bet) || bet < def.minBet || bet > def.maxBet) throw errors.badRequest("Invalid bet for this game.");
29+}
30+
31+export async function arcadeRoutes(app: FastifyInstance) {
32+ /* ------------------------------------------------------------ instant */
33+ app.post("/api/arcade/:slug/play", async (req) => {
34+ const user = requireUser(req);
35+ const { slug } = req.params as { slug: string };
36+ const { def } = guard(slug);
37+ if (def.mode !== "instant") throw errors.badRequest("This game is played step by step — use /start.");
38+ const retry = await rateLimit(`arcade:${user.id}`, 180, 60);
39+ if (retry) throw errors.rateLimited(retry);
40+ const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid(), input: z.record(z.string(), z.unknown()).default({}) }).parse(req.body);
41+ checkBet(def, body.bet);
42+ const game = await db.query.games.findFirst({ where: eq(games.slug, slug) });
43+ if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");
44+
45+ const existing = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`);
46+ if (existing.rows.length) {
47+ const r = existing.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown };
48+ return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true };
49+ }
50+ const started = Date.now();
51+ const roundId = newRoundId();
52+ const rng = new CryptoRng();
53+ try {
54+ const res = await db.transaction(async (tx) => {
55+ const wallet = await lockWallet(tx, user.id);
56+ if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet);
57+ const outcome = resolveInstant(def, body.bet, body.input, rng);
58+ await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug });
59+ if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: slug, multiplier: outcome.multiplier });
60+ const settled = await settleRound(tx, wallet, {
61+ roundId,
62+ gameId: game.id,
63+ gameSlug: slug,
64+ gameVersion: def.version,
65+ clientRoundId: body.clientRoundId,
66+ bet: body.bet,
67+ win: outcome.totalWin,
68+ multiplier: outcome.multiplier,
69+ result: { kind: "arcade", ...outcome } as unknown as Record<string, unknown>,
70+ features: outcome.features,
71+ freeSpins: false,
72+ bonus: outcome.features.some((f) => f === "Deep Drop" || f === "Supernova" || f === "Chain reaction"),
73+ jackpotTier: null,
74+ rngReference: rng.reference(),
75+ durationMs: Date.now() - started,
76+ });
77+ await saveWallet(tx, wallet);
78+ return { outcome, balance: wallet.balance, settled };
79+ });
80+ redis().zadd("live:players", Date.now(), user.id).catch(() => {});
81+ return { roundId, outcome: res.outcome, win: res.outcome.totalWin, multiplier: res.outcome.multiplier, balance: res.balance, winClass: res.settled.winClass, xp: res.settled.xp, unlocked: res.settled.unlocked };
82+ } catch (e) {
83+ if (pgCode(e) === PG_UNIQUE_VIOLATION) {
84+ const again = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`);
85+ if (again.rows.length) {
86+ const r = again.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown };
87+ return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true };
88+ }
89+ }
90+ throw e;
91+ }
92+ });
93+
94+ /* ------------------------------------------------------------- ladder */
95+ app.get("/api/arcade/:slug/current", async (req) => {
96+ const user = requireUser(req);
97+ const { slug } = req.params as { slug: string };
98+ const row = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.gameSlug, slug), eq(arcadeSessions.status, "running")), orderBy: desc(arcadeSessions.startedAt) });
99+ return { session: row ? { roundId: row.roundId, ...ladderView(row.state as unknown as LadderState) } : null };
100+ });
101+
102+ app.post("/api/arcade/:slug/start", async (req) => {
103+ const user = requireUser(req);
104+ const { slug } = req.params as { slug: string };
105+ const { def } = guard(slug);
106+ if (def.mode !== "ladder") throw errors.badRequest("This game resolves in one shot — use /play.");
107+ const retry = await rateLimit(`arcade:${user.id}`, 180, 60);
108+ if (retry) throw errors.rateLimited(retry);
109+ const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid() }).parse(req.body);
110+ checkBet(def, body.bet);
111+ const game = await db.query.games.findFirst({ where: eq(games.slug, slug) });
112+ if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");
113+
114+ const existing = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) });
115+ if (existing) return { session: { roundId: existing.roundId, ...ladderView(existing.state as unknown as LadderState) }, balance: null, replayed: true };
116+ const running = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.status, "running")) });
117+ if (running) throw errors.conflict("ROUND_IN_PROGRESS", `You have an unfinished ${running.gameSlug} run — finish or cash it out first.`);
118+
119+ const roundId = newRoundId();
120+ const rng = new CryptoRng();
121+ try {
122+ const result = await db.transaction(async (tx) => {
123+ const wallet = await lockWallet(tx, user.id);
124+ if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet);
125+ await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug });
126+ const state = ladderStart(def, body.bet, rng);
127+ let row: SessionRow;
128+ [row] = await tx
129+ .insert(arcadeSessions)
130+ .values({ roundId, userId: user.id, gameId: game.id, gameSlug: slug, gameVersion: def.version, clientRoundId: body.clientRoundId, bet: body.bet, state: state as unknown as Record<string, unknown>, status: state.status })
131+ .returning();
132+ let settled: Awaited<ReturnType<typeof settleRound>> | null = null;
133+ if (state.status !== "running") {
134+ // Busted (or completed) on the mandatory first step.
135+ settled = await finalize(tx, wallet, row, state, def, rng.reference());
136+ [row] = await tx.update(arcadeSessions).set({ status: state.status, win: state.win, settledAt: new Date(), updatedAt: new Date() }).where(eq(arcadeSessions.id, row.id)).returning();
137+ }
138+ await saveWallet(tx, wallet);
139+ return { row, state, balance: wallet.balance, settled };
140+ });
141+ redis().zadd("live:players", Date.now(), user.id).catch(() => {});
142+ return { session: { roundId, ...ladderView(result.state) }, balance: result.balance, progression: result.settled ? { xp: result.settled.xp, unlocked: result.settled.unlocked } : null };
143+ } catch (e) {
144+ if (pgCode(e) === PG_UNIQUE_VIOLATION) {
145+ const again = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) });
146+ if (again) return { session: { roundId: again.roundId, ...ladderView(again.state as unknown as LadderState) }, balance: null, replayed: true };
147+ }
148+ throw e;
149+ }
150+ });
151+
152+ app.post("/api/arcade/:slug/act", async (req) => {
153+ const user = requireUser(req);
154+ const { slug } = req.params as { slug: string };
155+ const { def } = guard(slug);
156+ const body = z.object({ roundId: z.string(), action: z.object({ type: z.enum(["continue", "cashout"]), offerId: z.string().optional() }) }).parse(req.body);
157+ const rng = new CryptoRng();
158+ const res = await db.transaction(async (tx) => {
159+ const lock = await tx.execute(sql`select id from arcade_sessions where round_id = ${body.roundId} and user_id = ${user.id} for update`);
160+ if (!lock.rows.length) throw errors.notFound("Run not found");
161+ const row = (await tx.query.arcadeSessions.findFirst({ where: eq(arcadeSessions.roundId, body.roundId) }))!;
162+ const state = row.state as unknown as LadderState;
163+ if (row.status !== "running") {
164+ return { row, state, balance: null as number | null, settled: null as Awaited<ReturnType<typeof settleRound>> | null };
165+ }
166+ let next: LadderState;
167+ try {
168+ next = ladderAdvance(def, state, rng, body.action as LadderAction);
169+ } catch (e) {
170+ throw errors.badRequest((e as Error).message);
171+ }
172+ let settled: Awaited<ReturnType<typeof settleRound>> | null = null;
173+ let balance: number | null = null;
174+ if (next.status !== "running") {
175+ const wallet = await lockWallet(tx, user.id);
176+ settled = await finalize(tx, wallet, row, next, def, rng.reference());
177+ await saveWallet(tx, wallet);
178+ balance = wallet.balance;
179+ }
180+ const [updated] = await tx
181+ .update(arcadeSessions)
182+ .set({ state: next as unknown as Record<string, unknown>, status: next.status, win: next.win, updatedAt: new Date(), settledAt: next.status !== "running" ? new Date() : null })
183+ .where(eq(arcadeSessions.id, row.id))
184+ .returning();
185+ return { row: updated, state: next, balance, settled };
186+ });
187+ return { session: { roundId: res.row.roundId, ...ladderView(res.state) }, balance: res.balance, winClass: res.state.status === "running" ? null : classifyWin(res.state.win / res.state.bet), progression: res.settled ? { xp: res.settled.xp, unlocked: res.settled.unlocked } : null };
188+ });
189+
190+ app.get("/api/arcade/:slug/history", async (req) => {
191+ const { slug } = req.params as { slug: string };
192+ if (!ARCADE_BY_SLUG.has(slug)) throw errors.notFound();
193+ const rows = await db.execute(sql`select multiplier, win, bet, created_at, result->'summary' as summary, result->>'status' as status, (result->>'stage')::int as stage from game_rounds where game_slug = ${slug} order by created_at desc limit 20`);
194+ return { history: rows.rows };
195+ });
196+}
197+
198+/** Credit the win (if any) and write the shared round bookkeeping for a finished ladder run. */
199+async function finalize(tx: Parameters<typeof settleRound>[0], wallet: Parameters<typeof settleRound>[1], row: SessionRow, state: LadderState, def: ArcadeGameDefinition, rngReference: string) {
200+ if (state.win > 0) await applyCredit(tx, wallet, "WIN", state.win, row.roundId, { game: def.slug, multiplier: state.win / row.bet });
201+ return settleRound(tx, wallet, {
202+ roundId: row.roundId,
203+ gameId: row.gameId,
204+ gameSlug: def.slug,
205+ gameVersion: def.version,
206+ clientRoundId: row.clientRoundId,
207+ bet: row.bet,
208+ win: state.win,
209+ multiplier: row.bet ? state.win / row.bet : 0,
210+ result: { kind: "arcade", mode: "ladder", status: state.status, stage: state.stage, current: state.current, log: state.log, extra: state.extra } as unknown as Record<string, unknown>,
211+ features: [state.status === "completed" ? (def.slug === "the-vault" ? "Jackpot" : "Summit") : state.status === "cashed" ? "Cash out" : "Bust", ...new Set(state.log.map((l) => l.kind))],
212+ freeSpins: false,
213+ bonus: state.status === "completed",
214+ jackpotTier: state.status === "completed" && def.slug === "the-vault" ? "grand" : null,
215+ rngReference,
216+ durationMs: Date.now() - row.startedAt.getTime(),
217+ });
218+}
modified apps/api/src/routes/games.ts +17 −1
@@ -1,6 +1,6 @@
1 import type { FastifyInstance } from "fastify"; 1 import type { FastifyInstance } from "fastify";
2 import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database"; 2 import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database";
3−import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG } from "@spinza/games"; 3+import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games";
4 import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared"; 4 import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared";
5 import { requireUser } from "../plugins/auth"; 5 import { requireUser } from "../plugins/auth";
6 import { errors } from "../lib/errors"; 6 import { errors } from "../lib/errors";
@@ -90,6 +90,22 @@ export async function gameRoutes(app: FastifyInstance) {
90 }; 90 };
91 return { game: info, definition: crashDef }; 91 return { game: info, definition: crashDef };
92 } 92 }
93+ const arcadeDef = ARCADE_BY_SLUG.get(slug);
94+ if (arcadeDef) {
95+ const s = g.summary as Record<string, unknown>;
96+ let favorite = false;
97+ if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) }));
98+ const info: GameInfo = {
99+ ...toCard(g, { favorite }),
100+ rtp: arcadeDef.rtp,
101+ hitFrequency: ((s.certification as { hitRate?: number } | null)?.hitRate) ?? null,
102+ description: arcadeDef.description,
103+ rules: arcadeDef.rules,
104+ paytable: [],
105+ certification: (s.certification as GameInfo["certification"] | null) ?? null,
106+ };
107+ return { game: info, definition: arcadeDef };
108+ }
93 const def = getGame(slug); 109 const def = getGame(slug);
94 if (!def) throw errors.notFound("Game not found"); 110 if (!def) throw errors.notFound("Game not found");
95 const s = g.summary as Record<string, unknown>; 111 const s = g.summary as Record<string, unknown>;
added apps/api/test/arcade.test.ts +113 −0
@@ -0,0 +1,113 @@
1+import { afterAll, beforeAll, describe, expect, it } from "vitest";
2+import { randomUUID } from "node:crypto";
3+import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database";
4+import { syncGames } from "@spinza/database/sync-games";
5+import { buildApp } from "../src/app";
6+import { closeRedis, redis } from "../src/lib/redis";
7+import type { FastifyInstance } from "fastify";
8+
9+let app: FastifyInstance;
10+let cookie = "";
11+let userId = "";
12+const username = `a_${randomUUID().slice(0, 8)}`;
13+const origin = "http://localhost:8230";
14+const headers = () => ({ origin, cookie: `spinza_session=${cookie}` });
15+
16+beforeAll(async () => {
17+ process.env.NODE_ENV = "test";
18+ app = await buildApp();
19+ await app.ready();
20+ const keys = await redis().keys("rl:*");
21+ if (keys.length) await redis().del(...keys);
22+ await syncGames(db);
23+ await db.execute(sql`update games set lifecycle = 'published' where slug in ('dropzone','grid-break','orbit','the-vault','escape-99')`);
24+ const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });
25+ cookie = res.cookies.find((c) => c.name === "spinza_session")!.value;
26+ userId = res.json().user.id;
27+});
28+
29+afterAll(async () => {
30+ if (userId) await db.delete(users).where(eq(users.id, userId));
31+ await app.close();
32+ await closeDb();
33+ await closeRedis();
34+});
35+
36+describe("arcade instant games", () => {
37+ it("dropzone resolves a drop and is idempotent", async () => {
38+ const id = randomUUID();
39+ const a = await app.inject({ method: "POST", url: "/api/arcade/dropzone/play", headers: headers(), payload: { bet: 100, clientRoundId: id, input: { risk: "high", lane: 0 } } });
40+ expect(a.statusCode).toBe(200);
41+ const body = a.json();
42+ expect(body.outcome.steps).toHaveLength(12);
43+ expect(body.outcome.summary.startLane).toBe(0);
44+ expect(body.outcome.summary.table).toHaveLength(13);
45+ const b = await app.inject({ method: "POST", url: "/api/arcade/dropzone/play", headers: headers(), payload: { bet: 100, clientRoundId: id, input: { risk: "high", lane: 0 } } });
46+ expect(b.json().roundId).toBe(body.roundId);
47+ expect(b.json().replayed).toBe(true);
48+ });
49+
50+ it("grid-break and orbit resolve with valid inputs and reject bad bets", async () => {
51+ const g = await app.inject({ method: "POST", url: "/api/arcade/grid-break/play", headers: headers(), payload: { bet: 20, clientRoundId: randomUUID(), input: { column: 3 } } });
52+ expect(g.statusCode).toBe(200);
53+ expect(g.json().outcome.steps.length).toBeGreaterThanOrEqual(1);
54+ const o = await app.inject({ method: "POST", url: "/api/arcade/orbit/play", headers: headers(), payload: { bet: 20, clientRoundId: randomUUID(), input: { angle: 90 } } });
55+ expect(o.statusCode).toBe(200);
56+ expect(o.json().outcome.summary.orbits.length).toBeGreaterThanOrEqual(3);
57+ const bad = await app.inject({ method: "POST", url: "/api/arcade/orbit/play", headers: headers(), payload: { bet: 33, clientRoundId: randomUUID(), input: {} } });
58+ expect(bad.statusCode).toBe(400);
59+ });
60+});
61+
62+describe("arcade ladder games", () => {
63+ it("the vault: start resolves the first layer, then secure or bust; ledger stays consistent", async () => {
64+ const start = await app.inject({ method: "POST", url: "/api/arcade/the-vault/start", headers: headers(), payload: { bet: 100, clientRoundId: randomUUID() } });
65+ expect(start.statusCode).toBe(200);
66+ let s = start.json().session;
67+ expect(["running", "busted"]).toContain(s.status);
68+ if (s.status === "running") {
69+ expect(s.stage).toBe(1);
70+ expect(s.current).toBeCloseTo(1.2, 2);
71+ expect(s.offers).toHaveLength(1);
72+ const act = await app.inject({ method: "POST", url: "/api/arcade/the-vault/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "cashout" } } });
73+ expect(act.statusCode).toBe(200);
74+ s = act.json().session;
75+ expect(s.status).toBe("cashed");
76+ expect(s.win).toBe(120);
77+ expect(act.json().balance).toBeGreaterThan(0);
78+ // Acting again on a settled run is a no-op.
79+ const again = await app.inject({ method: "POST", url: "/api/arcade/the-vault/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "continue" } } });
80+ expect(again.json().session.status).toBe("cashed");
81+ }
82+ const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId));
83+ const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) });
84+ expect(Number(total)).toBe(w!.balance);
85+ const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));
86+ expect(rounds.length).toBeGreaterThanOrEqual(4);
87+ });
88+
89+ it("escape 99: only one run at a time; climbing advances floors", async () => {
90+ const start = await app.inject({ method: "POST", url: "/api/arcade/escape-99/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } });
91+ expect(start.statusCode).toBe(200);
92+ let s = start.json().session;
93+ if (s.status === "running") {
94+ const dup = await app.inject({ method: "POST", url: "/api/arcade/escape-99/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } });
95+ expect(dup.statusCode).toBe(409);
96+ const cur = await app.inject({ method: "GET", url: "/api/arcade/escape-99/current", headers: headers() });
97+ expect(cur.json().session.roundId).toBe(s.roundId);
98+ for (let i = 0; i < 5 && s.status === "running"; i++) {
99+ const offer = s.offers[0];
100+ const act = await app.inject({ method: "POST", url: "/api/arcade/escape-99/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "continue", offerId: offer.id } } });
101+ expect(act.statusCode).toBe(200);
102+ const next = act.json().session;
103+ if (next.status === "running") expect(next.stage).toBe(s.stage + offer.advance);
104+ s = next;
105+ }
106+ if (s.status === "running") {
107+ const out = await app.inject({ method: "POST", url: "/api/arcade/escape-99/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "cashout" } } });
108+ expect(out.json().session.status).toBe("cashed");
109+ expect(out.json().session.win).toBeGreaterThan(0);
110+ }
111+ }
112+ });
113+});
modified apps/api/test/crash.test.ts +5 −2
@@ -4,7 +4,7 @@ import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets }
4 import { syncGames } from "@spinza/database/sync-games"; 4 import { syncGames } from "@spinza/database/sync-games";
5 import { verifyCommitment } from "@spinza/game-core"; 5 import { verifyCommitment } from "@spinza/game-core";
6 import { buildApp } from "../src/app"; 6 import { buildApp } from "../src/app";
7−import { closeRedis } from "../src/lib/redis"; 7+import { closeRedis, redis } from "../src/lib/redis";
8 import type { FastifyInstance } from "fastify"; 8 import type { FastifyInstance } from "fastify";
9 9
10 let app: FastifyInstance; 10 let app: FastifyInstance;
@@ -18,6 +18,9 @@ beforeAll(async () => {
18 process.env.NODE_ENV = "test"; 18 process.env.NODE_ENV = "test";
19 app = await buildApp(); 19 app = await buildApp();
20 await app.ready(); 20 await app.ready();
21+ // Tests register several accounts per run: reset the per-IP rate limits first.
22+ const keys = await redis().keys("rl:*");
23+ if (keys.length) await redis().del(...keys);
21 await syncGames(db); 24 await syncGames(db);
22 await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`); 25 await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`);
23 const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); 26 const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });
@@ -54,7 +57,7 @@ describe("crash games", () => {
54 if (settled.status === "cashed") { 57 if (settled.status === "cashed") {
55 expect(settled.cashoutMultiplier).toBe(1); 58 expect(settled.cashoutMultiplier).toBe(1);
56 expect(settled.win).toBe(100); 59 expect(settled.win).toBe(100);
57− expect(out.json().balance).toBe(10000); 60+ expect(out.json().balance).toBeGreaterThanOrEqual(10000); // bet returned (+ possible first-round achievement reward)
58 } 61 }
59 const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); 62 const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));
60 expect(rounds).toHaveLength(1); 63 expect(rounds).toHaveLength(1);
modified apps/api/test/wallet.test.ts +4 −1
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto";
7 import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; 7 import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database";
8 import { syncGames } from "@spinza/database/sync-games"; 8 import { syncGames } from "@spinza/database/sync-games";
9 import { buildApp } from "../src/app"; 9 import { buildApp } from "../src/app";
10−import { closeRedis } from "../src/lib/redis"; 10+import { closeRedis, redis } from "../src/lib/redis";
11 import type { FastifyInstance } from "fastify"; 11 import type { FastifyInstance } from "fastify";
12 12
13 let app: FastifyInstance; 13 let app: FastifyInstance;
@@ -20,6 +20,9 @@ beforeAll(async () => {
20 process.env.NODE_ENV = "test"; 20 process.env.NODE_ENV = "test";
21 app = await buildApp(); 21 app = await buildApp();
22 await app.ready(); 22 await app.ready();
23+ // Tests register several accounts per run: reset the per-IP rate limits first.
24+ const keys = await redis().keys("rl:*");
25+ if (keys.length) await redis().del(...keys);
23 await syncGames(db); 26 await syncGames(db);
24 // Force-publish one game for tests regardless of certification. 27 // Force-publish one game for tests regardless of certification.
25 await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`); 28 await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`);
added apps/api/vitest.config.ts +5 −0
@@ -0,0 +1,5 @@
1+import { defineConfig } from "vitest/config";
2+
3+// Integration tests share one Postgres database: run files sequentially so game
4+// library syncs and force-publish statements do not race each other.
5+export default defineConfig({ test: { fileParallelism: false, testTimeout: 20_000 } });
modified apps/simulator/src/cli.ts +28 −4
@@ -10,8 +10,8 @@
10 import fs from "node:fs"; 10 import fs from "node:fs";
11 import path from "node:path"; 11 import path from "node:path";
12 import { fileURLToPath } from "node:url"; 12 import { fileURLToPath } from "node:url";
13−import { certify, certifyCrash, formatCertification, simulateCrash, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core"; 13+import { certify, certifyCrash, certifyArcade, calibrateArcade, formatCertification, simulateCrash, simulateArcade, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core";
14−import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG } from "@spinza/games"; 14+import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG, ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "@spinza/games";
15 import { calibrate, simulateParallel } from "./index"; 15 import { calibrate, simulateParallel } from "./index";
16 16
17 const here = path.dirname(fileURLToPath(import.meta.url)); 17 const here = path.dirname(fileURLToPath(import.meta.url));
@@ -79,7 +79,21 @@ async function main() {
79 79
80 if (cmd === "calibrate") { 80 if (cmd === "calibrate") {
81 const cal = readCalibration(); 81 const cal = readCalibration();
82− for (const slug of targets(sel)) { 82+ const list = sel === "arcade" ? RAW_ARCADE_GAMES.filter((g) => g.mode === "instant").map((g) => g.slug) : targets(sel);
83+ for (const slug of list) {
84+ const arcade = RAW_ARCADE_GAMES.find((x) => x.slug === slug);
85+ if (arcade) {
86+ if (arcade.mode !== "instant") {
87+ console.log(`\n${arcade.name} is a ladder game — RTP is analytic, no calibration needed.`);
88+ continue;
89+ }
90+ console.log(`\nCalibrating ${arcade.name} (${slug}@${arcade.version}, arcade) target ${(arcade.rtp * 100).toFixed(2)}%`);
91+ const { payScale, result } = calibrateArcade(arcade, num("spins", 400_000), num("iterations", 4), console.log);
92+ cal[slug] = { payScale, version: arcade.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) };
93+ fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n");
94+ console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`);
95+ continue;
96+ }
83 const g = RAW_GAMES.find((x) => x.slug === slug)!; 97 const g = RAW_GAMES.find((x) => x.slug === slug)!;
84 console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`); 98 console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`);
85 const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log }); 99 const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log });
@@ -94,8 +108,18 @@ async function main() {
94 fs.mkdirSync(certDir, { recursive: true }); 108 fs.mkdirSync(certDir, { recursive: true });
95 const spins = num("spins", 1_000_000); 109 const spins = num("spins", 1_000_000);
96 let failed = 0; 110 let failed = 0;
97− const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : targets(sel); 111+ const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : sel === "arcade" ? ARCADE_GAMES.map((g) => g.slug) : targets(sel);
98 for (const slug of list) { 112 for (const slug of list) {
113+ const arcadeDef = ARCADE_BY_SLUG.get(slug);
114+ if (arcadeDef) {
115+ console.log(`\nCertifying ${arcadeDef.name} (${slug}@${arcadeDef.version}, arcade ${arcadeDef.mode}) payScale=${arcadeDef.payScale} — ${spins.toLocaleString("en-US")} rounds`);
116+ const res = simulateArcade(arcadeDef, { spins });
117+ const report = certifyArcade(arcadeDef, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) });
118+ fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n");
119+ console.log(formatCertification(report));
120+ if (report.status === "FAIL") failed++;
121+ continue;
122+ }
99 const crashDef = CRASH_BY_SLUG.get(slug); 123 const crashDef = CRASH_BY_SLUG.get(slug);
100 if (crashDef) { 124 if (crashDef) {
101 console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`); 125 console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`);
modified apps/web/src/app/games/[slug]/page.tsx +4 −2
@@ -2,10 +2,11 @@ import type { Metadata } from "next";
2 import { notFound } from "next/navigation"; 2 import { notFound } from "next/navigation";
3 import { apiServer } from "@/lib/api-server"; 3 import { apiServer } from "@/lib/api-server";
4 import type { GameInfo } from "@spinza/shared"; 4 import type { GameInfo } from "@spinza/shared";
5−import type { CrashGameDefinition } from "@spinza/game-core/client"; 5+import type { ArcadeGameDefinition, CrashGameDefinition } from "@spinza/game-core/client";
6 import type { ClientDefinition } from "@/components/game/types"; 6 import type { ClientDefinition } from "@/components/game/types";
7 import { GameClient } from "@/components/game/game-client"; 7 import { GameClient } from "@/components/game/game-client";
8 import { CrashClient } from "@/components/crash/crash-client"; 8 import { CrashClient } from "@/components/crash/crash-client";
9+import { ArcadeClient } from "@/components/arcade/arcade-client";
9 10
10 type Params = { params: Promise<{ slug: string }> }; 11 type Params = { params: Promise<{ slug: string }> };
11 12
@@ -18,8 +19,9 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
18 19
19 export default async function GamePage({ params }: Params) { 20 export default async function GamePage({ params }: Params) {
20 const { slug } = await params; 21 const { slug } = await params;
21− const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition }>(`/api/games/${slug}`); 22+ const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition | ArcadeGameDefinition }>(`/api/games/${slug}`);
22 if (!data) notFound(); 23 if (!data) notFound();
23 if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />; 24 if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />;
25+ if (data.game.kind === "arcade") return <ArcadeClient game={data.game} definition={data.definition as ArcadeGameDefinition} />;
24 return <GameClient game={data.game} definition={data.definition as ClientDefinition} />; 26 return <GameClient game={data.game} definition={data.definition as ClientDefinition} />;
25 } 27 }
added apps/web/src/components/arcade/arcade-client.tsx +31 −0
@@ -0,0 +1,31 @@
1+"use client";
2+
3+/**
4+ * Dispatcher for the Beyond Slots originals: picks the scene component from
5+ * `definition.presentation.scene` and hosts it in the shared ArcadeShell.
6+ * (Minimal harness — the integrator owns the final version.)
7+ */
8+import type { ComponentType } from "react";
9+import type { GameInfo } from "@spinza/shared";
10+import type { ArcadeGameDefinition } from "@spinza/game-core/client";
11+import { ArcadeShell } from "./arcade-shell";
12+import type { ArcadeGameProps } from "./contract";
13+import { VaultGame } from "./vault";
14+import { EscapeGame } from "./escape";
15+import { DropzoneGame } from "./dropzone";
16+import { GridbreakGame } from "./gridbreak";
17+import { OrbitGame } from "./orbit";
18+
19+const SCENES: Partial<Record<ArcadeGameDefinition["presentation"]["scene"], ComponentType<ArcadeGameProps>>> = {
20+ vault: VaultGame,
21+ escape: EscapeGame,
22+ dropzone: DropzoneGame,
23+ gridbreak: GridbreakGame,
24+ orbit: OrbitGame,
25+};
26+
27+export function ArcadeClient({ game, definition }: { game: GameInfo; definition: ArcadeGameDefinition }) {
28+ const Game = SCENES[definition.presentation.scene];
29+ if (!Game) return null;
30+ return <ArcadeShell game={game} definition={definition} Game={Game} />;
31+}
added apps/web/src/components/arcade/arcade-shell.tsx +200 −0
@@ -0,0 +1,200 @@
1+"use client";
2+
3+import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
4+import Link from "next/link";
5+import { useRouter } from "next/navigation";
6+import { AnimatePresence, motion } from "framer-motion";
7+import { ArrowLeft, Heart, Info, Minus, Plus, Volume2, VolumeX } from "lucide-react";
8+import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared";
9+import type { ArcadeGameDefinition } from "@spinza/game-core/client";
10+import { api } from "@/lib/api";
11+import { useSession } from "@/lib/store";
12+import { cn } from "@/lib/utils";
13+import { Credits, Sheet, Tabs } from "@/components/ui";
14+import { getSound } from "@/components/game/sound";
15+import type { ArcadeGameProps } from "./contract";
16+
17+/**
18+ * Shared chrome for the Beyond Slots originals: top bar, bet controls,
19+ * balance, win banner, info sheet. The game component fills the middle.
20+ */
21+export function ArcadeShell({ game, definition, Game }: { game: GameInfo; definition: ArcadeGameDefinition; Game: ComponentType<ArcadeGameProps> }) {
22+ const router = useRouter();
23+ const { status, wallet, settings } = useSession();
24+ const [bet, setBet] = useState(100);
25+ const [busy, setBusy] = useState(false);
26+ const [last, setLast] = useState<{ win: number; multiplier: number } | null>(null);
27+ const [banner, setBanner] = useState<{ label: string; win: number; multiplier: number } | null>(null);
28+ const [infoSheet, setInfoSheet] = useState(false);
29+ const [favorite, setFavorite] = useState(!!game.favorite);
30+ const soundOn = settings?.soundEnabled ?? true;
31+ const palette = definition.presentation.palette;
32+ const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]);
33+ const balance = wallet?.balance ?? 0;
34+ const betIndex = bets.indexOf(bet);
35+
36+ useEffect(() => {
37+ if (status === "guest") router.replace(`/login?next=/games/${game.slug}`);
38+ }, [status, router, game.slug]);
39+
40+ useEffect(() => {
41+ const s = getSound();
42+ s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 });
43+ s.setAmbience(definition.presentation.ambience);
44+ api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {});
45+ return () => s.destroy();
46+ }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience, game.slug]);
47+
48+ const sound = useCallback<ArcadeGameProps["sound"]>((name) => {
49+ const s = getSound();
50+ s.unlock();
51+ switch (name) {
52+ case "click":
53+ s.click();
54+ break;
55+ case "tick":
56+ s.tick();
57+ break;
58+ case "win":
59+ s.win(2);
60+ break;
61+ case "bigWin":
62+ s.bigWin();
63+ break;
64+ case "lose":
65+ s.error();
66+ break;
67+ case "bonus":
68+ s.bonus();
69+ break;
70+ }
71+ }, []);
72+
73+ const onResult = useCallback<ArcadeGameProps["onResult"]>((r) => {
74+ setLast(r);
75+ const cls = classifyWin(r.multiplier);
76+ if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") {
77+ setBanner({ label: WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN", win: r.win, multiplier: r.multiplier });
78+ setTimeout(() => setBanner(null), 2600);
79+ }
80+ }, []);
81+
82+ const toggleFavorite = async () => {
83+ setFavorite((f) => !f);
84+ try {
85+ const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });
86+ setFavorite(r.favorite);
87+ } catch {
88+ setFavorite((f) => !f);
89+ }
90+ };
91+
92+ if (status === "guest") return null;
93+
94+ return (
95+ <div className="fixed inset-0 flex flex-col" style={{ background: `radial-gradient(120% 80% at 50% 0%, ${palette.surface} 0%, ${palette.bg} 60%, #050608 100%)` }}>
96+ <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>
97+ <div className="flex items-center gap-2">
98+ <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby">
99+ <ArrowLeft className="h-5 w-5" />
100+ </Link>
101+ <div className="leading-tight">
102+ <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>
103+ <div className="text-[11px] text-fg-3">Spinza Original · Beyond Slots</div>
104+ </div>
105+ </div>
106+ <div className="flex items-center gap-1">
107+ <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite">
108+ <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />
109+ </button>
110+ <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Game info">
111+ <Info className="h-5 w-5" />
112+ </button>
113+ <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Sound">
114+ {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
115+ </button>
116+ </div>
117+ </div>
118+
119+ <div className="relative flex-1 min-h-0">
120+ <Game game={game} definition={definition} bet={bet} onBusy={setBusy} onResult={onResult} sound={sound} reduceMotion={settings?.reduceMotion ?? false} />
121+ <AnimatePresence>
122+ {banner ? (
123+ <motion.div key={banner.win} initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="pointer-events-none absolute inset-0 grid place-items-center bg-black/40" style={{ zIndex: 5 }}>
124+ <div className="text-center">
125+ <div className="text-[clamp(34px,9vw,72px)] font-extrabold uppercase tracking-tight shimmer-text">{banner.label}</div>
126+ <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(banner.win)}</div>
127+ <div className="text-sm text-fg-2">{formatMultiplier(banner.multiplier)} the bet</div>
128+ </div>
129+ </motion.div>
130+ ) : null}
131+ </AnimatePresence>
132+ </div>
133+
134+ <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)", borderTop: `1px solid ${palette.primary}55` }}>
135+ <div className="mx-auto flex max-w-3xl items-center justify-between gap-3">
136+ <div>
137+ <div className="eyebrow">Balance</div>
138+ <Credits amount={balance} size="md" />
139+ </div>
140+ <div className="flex items-center gap-1">
141+ <button disabled={busy || betIndex <= 0} onClick={() => setBet(bets[betIndex - 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Decrease bet">
142+ <Minus className="h-4 w-4" />
143+ </button>
144+ <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2">
145+ <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>
146+ <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>
147+ </div>
148+ <button disabled={busy || betIndex >= bets.length - 1} onClick={() => setBet(bets[betIndex + 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Increase bet">
149+ <Plus className="h-4 w-4" />
150+ </button>
151+ </div>
152+ <div className="text-right">
153+ <div className="eyebrow">Last win</div>
154+ <div className={cn("text-base font-semibold tabular", last && last.win > 0 ? "text-credit" : "text-fg-4")}>{last && last.win > 0 ? `${formatSC(last.win)} · ${formatMultiplier(last.multiplier)}` : "—"}</div>
155+ </div>
156+ </div>
157+ </div>
158+
159+ <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />
160+ </div>
161+ );
162+}
163+
164+function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ArcadeGameDefinition }) {
165+ const [tab, setTab] = useState<"rules" | "about">("rules");
166+ return (
167+ <Sheet open={open} onClose={onClose} title={game.name} side="right">
168+ <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" />
169+ {tab === "rules" ? (
170+ <ul className="space-y-3 text-sm text-fg-2">
171+ {game.rules.map((r, i) => (
172+ <li key={i} className="flex gap-3">
173+ <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-surface-2 text-[11px] font-bold text-fg-3">{i + 1}</span>
174+ <span>{r}</span>
175+ </li>
176+ ))}
177+ </ul>
178+ ) : (
179+ <div className="space-y-4 text-sm text-fg-2">
180+ <p>{game.description}</p>
181+ <dl className="grid grid-cols-2 gap-3">
182+ {[
183+ ["Volatility", game.volatility],
184+ ["RTP", `${(game.rtp * 100).toFixed(2)}%`],
185+ ["Max win", formatMultiplier(definition.maxMultiplier)],
186+ ["Mode", definition.mode === "ladder" ? "Step by step" : "Instant"],
187+ ].map(([k, v]) => (
188+ <div key={k} className="surface rounded-md p-3">
189+ <dt className="eyebrow">{k}</dt>
190+ <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd>
191+ </div>
192+ ))}
193+ </dl>
194+ {game.certification ? <p className="text-[12px] text-fg-3">Certified on {game.certification.spins.toLocaleString("en-US")} simulated rounds · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%.</p> : null}
195+ <p className="text-[12px] text-fg-3">Spinza Credits are fictional and have no cash value.</p>
196+ </div>
197+ )}
198+ </Sheet>
199+ );
200+}
added apps/web/src/components/arcade/contract.ts +175 −0
@@ -0,0 +1,175 @@
1+"use client";
2+
3+/**
4+ * Shared contract for the five "Beyond Slots" arcade games. Each game module
5+ * exports a React component with the `ArcadeGameProps` signature and uses the
6+ * helpers below to talk to the API. Outcomes are always resolved server-side.
7+ */
8+import { useCallback, useEffect, useRef, useState } from "react";
9+import type { ArcadeGameDefinition, ArcadeOutcome, LadderState } from "@spinza/game-core/client";
10+import type { GameInfo } from "@spinza/shared";
11+import { api, ApiClientError } from "@/lib/api";
12+import { toast, useSession } from "@/lib/store";
13+
14+export interface ArcadeGameProps {
15+ game: GameInfo;
16+ definition: ArcadeGameDefinition;
17+ /** Current bet chosen in the shared shell. */
18+ bet: number;
19+ /** Tell the shell whether the game is mid-round (locks bet controls). */
20+ onBusy: (busy: boolean) => void;
21+ /** Report the last result to the shell (win banner + counters). */
22+ onResult: (r: { win: number; multiplier: number }) => void;
23+ /** Play a UI sound: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus". */
24+ sound: (name: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus") => void;
25+ reduceMotion: boolean;
26+}
27+
28+export interface Progression {
29+ xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number };
30+ unlocked: { achievements: string[]; missions: string[] };
31+}
32+
33+export interface InstantResponse<T extends ArcadeOutcome = ArcadeOutcome> {
34+ roundId: string;
35+ outcome: T;
36+ win: number;
37+ multiplier: number;
38+ balance: number;
39+ winClass: string;
40+ xp?: Progression["xp"];
41+ unlocked?: Progression["unlocked"];
42+ replayed?: boolean;
43+}
44+
45+export type LadderView = Omit<LadderState, "game" | "version"> & { roundId: string; game: string; version: string; canCashout: boolean };
46+
47+export interface LadderResponse {
48+ session: LadderView;
49+ balance: number | null;
50+ winClass?: string | null;
51+ progression: Progression | null;
52+ replayed?: boolean;
53+}
54+
55+function applyProgression(p: Progression | null | undefined) {
56+ if (!p) return;
57+ const s = useSession.getState();
58+ s.setUserXp(p.xp.total, p.xp.level);
59+ if (p.xp.leveledUp) toast({ title: `Level ${p.xp.level} reached`, description: `+${p.xp.levelReward.toLocaleString("en-US")} SC level reward`, tone: "credit" });
60+ for (const a of p.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" });
61+ for (const m of p.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" });
62+}
63+
64+export function describeArcadeError(e: unknown): string {
65+ if (e instanceof ApiClientError) return e.message;
66+ return "Connection lost. Try again.";
67+}
68+
69+/** Instant games: one call → resolved outcome. Handles optimistic balance and progression toasts. */
70+export function useInstantPlay<T extends ArcadeOutcome>(slug: string) {
71+ const [busy, setBusy] = useState(false);
72+ const [error, setError] = useState<string | null>(null);
73+ const setBalance = useSession((s) => s.setBalance);
74+ const play = useCallback(
75+ async (bet: number, input: Record<string, unknown>): Promise<InstantResponse<T> | null> => {
76+ if (busy) return null;
77+ const balance = useSession.getState().wallet?.balance ?? 0;
78+ if (balance < bet) {
79+ setError("Not enough Spinza Credits for this bet.");
80+ return null;
81+ }
82+ setBusy(true);
83+ setError(null);
84+ setBalance(balance - bet);
85+ try {
86+ const res = await api<InstantResponse<T>>(`/api/arcade/${slug}/play`, { json: { bet, clientRoundId: crypto.randomUUID(), input } });
87+ setBalance(res.balance);
88+ applyProgression(res.xp && res.unlocked ? { xp: res.xp, unlocked: res.unlocked } : null);
89+ return res;
90+ } catch (e) {
91+ setBalance(balance);
92+ setError(describeArcadeError(e));
93+ return null;
94+ } finally {
95+ setBusy(false);
96+ }
97+ },
98+ [busy, slug, setBalance],
99+ );
100+ return { play, busy, error, clearError: () => setError(null) };
101+}
102+
103+/** Ladder games: start → act(continue|cashout) until the session ends. Resumes a running session on mount. */
104+export function useLadder(slug: string) {
105+ const [session, setSession] = useState<LadderView | null>(null);
106+ const [busy, setBusy] = useState(false);
107+ const [error, setError] = useState<string | null>(null);
108+ const setBalance = useSession((s) => s.setBalance);
109+ const mounted = useRef(false);
110+
111+ useEffect(() => {
112+ if (mounted.current) return;
113+ mounted.current = true;
114+ api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`)
115+ .then((r) => {
116+ if (r.session) setSession(r.session);
117+ })
118+ .catch(() => {});
119+ }, [slug]);
120+
121+ const start = useCallback(
122+ async (bet: number): Promise<LadderResponse | null> => {
123+ if (busy) return null;
124+ const balance = useSession.getState().wallet?.balance ?? 0;
125+ if (balance < bet) {
126+ setError("Not enough Spinza Credits for this bet.");
127+ return null;
128+ }
129+ setBusy(true);
130+ setError(null);
131+ try {
132+ const res = await api<LadderResponse>(`/api/arcade/${slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID() } });
133+ if (res.balance !== null) setBalance(res.balance);
134+ else setBalance(balance - bet);
135+ setSession(res.session);
136+ applyProgression(res.progression);
137+ return res;
138+ } catch (e) {
139+ if (e instanceof ApiClientError && e.code === "ROUND_IN_PROGRESS") {
140+ const cur = await api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`).catch(() => null);
141+ if (cur?.session) setSession(cur.session);
142+ }
143+ setError(describeArcadeError(e));
144+ return null;
145+ } finally {
146+ setBusy(false);
147+ }
148+ },
149+ [busy, slug, setBalance],
150+ );
151+
152+ const act = useCallback(
153+ async (action: { type: "continue"; offerId?: string } | { type: "cashout" }): Promise<LadderResponse | null> => {
154+ if (!session || busy) return null;
155+ setBusy(true);
156+ setError(null);
157+ try {
158+ const res = await api<LadderResponse>(`/api/arcade/${slug}/act`, { json: { roundId: session.roundId, action } });
159+ setSession(res.session);
160+ if (res.balance !== null) setBalance(res.balance);
161+ applyProgression(res.progression);
162+ return res;
163+ } catch (e) {
164+ setError(describeArcadeError(e));
165+ return null;
166+ } finally {
167+ setBusy(false);
168+ }
169+ },
170+ [session, busy, slug, setBalance],
171+ );
172+
173+ const reset = useCallback(() => setSession(null), []);
174+ return { session, start, act, reset, busy, error, clearError: () => setError(null) };
175+}
added apps/web/src/components/arcade/dropzone.tsx +903 −0
@@ -0,0 +1,903 @@
1+"use client";
2+
3+/**
4+ * DROPZONE — browser side. The server resolves the whole descent; this file
5+ * only animates `outcome.steps` (half-lane x per row, gates, portals), the
6+ * landing bucket and the optional Deep Drop extension on a 2D canvas.
7+ */
8+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9+import { AnimatePresence, motion } from "framer-motion";
10+import { ChevronLeft, ChevronRight } from "lucide-react";
11+import { formatMultiplier, formatSC } from "@spinza/shared";
12+import type { ArcadeGameDefinition, ArcadeOutcome, DropStep, DropzoneConfig } from "@spinza/game-core/client";
13+import { cn } from "@/lib/utils";
14+import { useInstantPlay, type ArcadeGameProps } from "./contract";
15+
16+type Risk = "low" | "medium" | "high";
17+
18+type DropSummary = {
19+ risk: Risk;
20+ startLane: number;
21+ bucket: number;
22+ bucketValue: number;
23+ gateMult: number;
24+ deep: { steps: DropStep[]; bucket: number; value: number } | null;
25+ table: number[];
26+};
27+
28+interface DropOutcome extends ArcadeOutcome {
29+ steps: DropStep[];
30+ summary: DropSummary;
31+}
32+
33+/* ------------------------------------------------------------------ math */
34+/* Pure replica of the server's displayed bucket values (see game-core/arcade/dropzone.ts). */
35+
36+function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] {
37+ const width = cfg.lanes * 2;
38+ let dist: number[] = new Array<number>(width).fill(0);
39+ dist[startLane * 2 + 1] = 1;
40+ for (let row = 0; row < cfg.rows; row++) {
41+ const next: number[] = new Array<number>(width).fill(0);
42+ for (let x = 0; x < width; x++) {
43+ if (!dist[x]) continue;
44+ next[Math.max(0, x - 1)] += dist[x] / 2;
45+ next[Math.min(width - 1, x + 1)] += dist[x] / 2;
46+ }
47+ dist = next;
48+ }
49+ const buckets: number[] = new Array<number>(cfg.lanes).fill(0);
50+ for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x];
51+ return buckets;
52+}
53+
54+function laneNormalizer(cfg: DropzoneConfig, risk: Risk, startLane: number): number {
55+ const table = cfg.buckets[risk];
56+ const center = Math.floor(cfg.lanes / 2);
57+ const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0);
58+ return ev(center) / ev(startLane);
59+}
60+
61+function displayedBuckets(def: ArcadeGameDefinition, cfg: DropzoneConfig, risk: Risk, startLane: number): number[] {
62+ const norm = laneNormalizer(cfg, risk, startLane);
63+ return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100);
64+}
65+
66+/* --------------------------------------------------------------- helpers */
67+
68+const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
69+const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
70+const easeInQuad = (t: number) => t * t;
71+const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);
72+const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
73+const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
74+
75+function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> {
76+ return new Promise((resolve) => {
77+ const start = performance.now();
78+ const frame = (now: number) => {
79+ if (!alive()) return resolve();
80+ const t = Math.min(1, (now - start) / Math.max(1, ms));
81+ fn(t);
82+ if (t < 1) requestAnimationFrame(frame);
83+ else resolve();
84+ };
85+ requestAnimationFrame(frame);
86+ });
87+}
88+
89+function rgba(hex: string, a: number): string {
90+ const h = hex.replace("#", "");
91+ const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
92+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
93+}
94+
95+function mixHex(a: string, b: string, t: number): string {
96+ const pa = parseInt(a.replace("#", ""), 16);
97+ const pb = parseInt(b.replace("#", ""), 16);
98+ const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t));
99+ return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
100+}
101+
102+/* ----------------------------------------------------------------- scene */
103+
104+interface Particle {
105+ x: number;
106+ y: number;
107+ vx: number;
108+ vy: number;
109+ life: number;
110+ max: number;
111+ color: string;
112+ size: number;
113+}
114+
115+interface Capsule {
116+ x: number; // half-lane units
117+ y: number; // row units (0 = release line, r+1 = after row r)
118+ deep: boolean;
119+ visible: boolean;
120+ alpha: number;
121+ squash: number; // 1 = round
122+ glow: number;
123+}
124+
125+interface Scene {
126+ t: number;
127+ lane: number;
128+ risk: Risk;
129+ values: number[];
130+ phase: "idle" | "dropping";
131+ camY: number; // in row units
132+ capsule: Capsule;
133+ trail: { x: number; y: number; deep: boolean; life: number }[];
134+ gates: { x: number; y: number; value: number; at: number }[];
135+ portals: { x: number; to: number; y: number; at: number }[];
136+ landed: number | null;
137+ landedAt: number;
138+ deepReveal: number;
139+ deepLanded: number | null;
140+ deepLandedAt: number;
141+ particles: Particle[];
142+ flash: { text: string; at: number; color: string } | null;
143+ reduceMotion: boolean;
144+}
145+
146+const MARKER_UNITS = 1.2;
147+const BUCKET_UNITS = 1.5;
148+const DEEP_GAP_UNITS = 0.9;
149+
150+interface Layout {
151+ w: number;
152+ h: number;
153+ pad: number;
154+ hl: number;
155+ rowH: number;
156+ left: number;
157+ deepLeft: number;
158+ deepStart: number; // row units where the deep release line sits
159+ totalDeepUnits: number;
160+}
161+
162+function layoutFor(w: number, h: number, cfg: DropzoneConfig): Layout {
163+ const mainUnits = MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.5;
164+ let hl = (Math.min(w, 620) - 20) / (cfg.lanes * 2);
165+ let rowH = Math.min((h - 20) / mainUnits, hl * 2.1);
166+ if (rowH < hl * 1.05) {
167+ // very short viewport: shrink horizontally to keep pegs readable
168+ hl = rowH / 1.05;
169+ rowH = hl * 1.05;
170+ }
171+ // Centre the tower vertically when the viewport is taller than needed (tall phones).
172+ const pad = Math.max(10, (h - mainUnits * rowH) / 2);
173+ const left = (w - hl * cfg.lanes * 2) / 2;
174+ const deepLeft = left + ((cfg.lanes * 2 - cfg.deepBuckets.length * 2) / 2) * hl;
175+ const deepStart = cfg.rows + BUCKET_UNITS + DEEP_GAP_UNITS;
176+ return { w, h, pad, hl, rowH, left, deepLeft, deepStart, totalDeepUnits: deepStart + cfg.deepRows + BUCKET_UNITS };
177+}
178+
179+function toPx(L: Layout, s: Scene, x: number, y: number, deep: boolean): { px: number; py: number } {
180+ const px = (deep ? L.deepLeft : L.left) + (x + 0.5) * L.hl;
181+ const py = L.pad + (MARKER_UNITS + (deep ? L.deepStart + y : y) - s.camY) * L.rowH;
182+ return { px, py };
183+}
184+
185+function heat(v: number, max: number): number {
186+ if (max <= 0) return 0;
187+ return clamp(Math.log1p(v) / Math.log1p(max), 0, 1);
188+}
189+
190+/* --------------------------------------------------------------- drawing */
191+
192+interface Palette {
193+ primary: string;
194+ secondary: string;
195+ glow: string;
196+ bg: string;
197+ surface: string;
198+}
199+
200+function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: DropzoneConfig, palette: Palette) {
201+ const { w, h, hl, rowH } = L;
202+ ctx.clearRect(0, 0, w, h);
203+ const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`;
204+
205+ // Tower glass backdrop
206+ const towerX = L.left - hl * 0.6;
207+ const towerW = hl * cfg.lanes * 2 + hl * 1.2;
208+ const towerTop = L.pad + (MARKER_UNITS - 0.6 - s.camY) * rowH;
209+ const towerBottom = L.pad + (MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.15 - s.camY) * rowH;
210+ const bg = ctx.createLinearGradient(0, towerTop, 0, towerBottom);
211+ bg.addColorStop(0, "rgba(255,255,255,0.035)");
212+ bg.addColorStop(1, "rgba(255,255,255,0.012)");
213+ ctx.fillStyle = bg;
214+ ctx.beginPath();
215+ ctx.roundRect(towerX, towerTop, towerW, towerBottom - towerTop, 18);
216+ ctx.fill();
217+ ctx.strokeStyle = rgba(palette.primary, 0.18);
218+ ctx.lineWidth = 1;
219+ ctx.stroke();
220+
221+ // Side rails
222+ ctx.strokeStyle = rgba(palette.primary, 0.35);
223+ ctx.lineWidth = 2;
224+ ctx.beginPath();
225+ ctx.moveTo(towerX, towerTop + 14);
226+ ctx.lineTo(towerX, towerBottom - 14);
227+ ctx.moveTo(towerX + towerW, towerTop + 14);
228+ ctx.lineTo(towerX + towerW, towerBottom - 14);
229+ ctx.stroke();
230+
231+ // Pegs (main)
232+ const pegR = Math.max(2, hl * 0.17);
233+ for (let r = 0; r < cfg.rows; r++) {
234+ const parity = r % 2 === 0 ? 1 : 0;
235+ const y = L.pad + (MARKER_UNITS + r + 0.5 - s.camY) * rowH;
236+ if (y < -10 || y > h + 10) continue;
237+ for (let x = parity; x < cfg.lanes * 2; x += 2) {
238+ const px = L.left + (x + 0.5) * hl;
239+ ctx.beginPath();
240+ ctx.arc(px, y, pegR, 0, Math.PI * 2);
241+ ctx.fillStyle = "rgba(255,255,255,0.55)";
242+ ctx.fill();
243+ ctx.beginPath();
244+ ctx.arc(px, y, pegR * 2.2, 0, Math.PI * 2);
245+ ctx.fillStyle = rgba(palette.primary, 0.08);
246+ ctx.fill();
247+ }
248+ }
249+
250+ // Buckets (main)
251+ const maxV = Math.max(...s.values, 1);
252+ const bTop = L.pad + (MARKER_UNITS + cfg.rows + 0.15 - s.camY) * rowH;
253+ const bH = (BUCKET_UNITS - 0.3) * rowH;
254+ for (let k = 0; k < cfg.lanes; k++) {
255+ const x0 = L.left + k * 2 * hl + 1.5;
256+ const bw = 2 * hl - 3;
257+ const v = s.values[k] ?? 0;
258+ const t = heat(v, maxV);
259+ const base = v >= 50 ? "#ffd66b" : mixHex(palette.primary, palette.secondary, t);
260+ const lit = s.landed === k;
261+ const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.landedAt) * 8) : 0;
262+ ctx.beginPath();
263+ ctx.roundRect(x0, bTop, bw, bH, Math.min(6, hl * 0.35));
264+ ctx.fillStyle = lit ? rgba(base.startsWith("#") ? base : palette.glow, 0.55 + 0.35 * pulse) : rgba("#ffffff", 0.05 + t * 0.06);
265+ ctx.fill();
266+ ctx.strokeStyle = lit ? "#ffffff" : base.startsWith("#") ? rgba(base, 0.35 + t * 0.4) : base;
267+ ctx.lineWidth = lit ? 2 : 1;
268+ ctx.stroke();
269+ // Value
270+ const label = v > 0 && v < 0.01 ? "<0.01×" : formatMultiplier(v);
271+ const size = Math.max(8, Math.min(13, hl * 0.72));
272+ ctx.font = font(size, 800);
273+ ctx.textAlign = "center";
274+ ctx.textBaseline = "middle";
275+ ctx.fillStyle = lit ? "#0b1020" : base.startsWith("#") ? base : mixHex(palette.primary, palette.secondary, t);
276+ ctx.fillText(label, x0 + bw / 2, bTop + bH / 2, bw - 2);
277+ }
278+
279+ // Deep section
280+ if (s.deepReveal > 0) {
281+ ctx.save();
282+ ctx.globalAlpha = s.deepReveal;
283+ const dTop = L.pad + (MARKER_UNITS + L.deepStart - 0.6 - s.camY) * rowH;
284+ const dBottom = L.pad + (MARKER_UNITS + L.totalDeepUnits + 0.15 - s.camY) * rowH;
285+ const dX = L.deepLeft - hl * 0.6;
286+ const dW = hl * cfg.deepBuckets.length * 2 + hl * 1.2;
287+ const dg = ctx.createLinearGradient(0, dTop, 0, dBottom);
288+ dg.addColorStop(0, rgba(palette.secondary, 0.12));
289+ dg.addColorStop(1, rgba(palette.secondary, 0.03));
290+ ctx.fillStyle = dg;
291+ ctx.beginPath();
292+ ctx.roundRect(dX, dTop, dW, dBottom - dTop, 18);
293+ ctx.fill();
294+ ctx.strokeStyle = rgba(palette.secondary, 0.4);
295+ ctx.lineWidth = 1.5;
296+ ctx.stroke();
297+ // Funnel between main buckets and deep tower
298+ ctx.strokeStyle = rgba(palette.secondary, 0.5);
299+ ctx.setLineDash([4, 6]);
300+ ctx.beginPath();
301+ ctx.moveTo(towerX + 8, towerBottom);
302+ ctx.lineTo(dX + 8, dTop);
303+ ctx.moveTo(towerX + towerW - 8, towerBottom);
304+ ctx.lineTo(dX + dW - 8, dTop);
305+ ctx.stroke();
306+ ctx.setLineDash([]);
307+ ctx.font = font(11, 800);
308+ ctx.textAlign = "center";
309+ ctx.textBaseline = "middle";
310+ ctx.fillStyle = palette.secondary;
311+ ctx.fillText("DEEP DROP", w / 2, dTop + 14);
312+ for (let r = 0; r < cfg.deepRows; r++) {
313+ const parity = r % 2 === 0 ? 1 : 0;
314+ const y = L.pad + (MARKER_UNITS + L.deepStart + r + 0.5 - s.camY) * rowH;
315+ if (y < -10 || y > h + 10) continue;
316+ for (let x = parity; x < cfg.deepBuckets.length * 2; x += 2) {
317+ const px = L.deepLeft + (x + 0.5) * hl;
318+ ctx.beginPath();
319+ ctx.arc(px, y, pegR, 0, Math.PI * 2);
320+ ctx.fillStyle = rgba(palette.secondary, 0.8);
321+ ctx.fill();
322+ }
323+ }
324+ const dbTop = L.pad + (MARKER_UNITS + L.deepStart + cfg.deepRows + 0.15 - s.camY) * rowH;
325+ for (let k = 0; k < cfg.deepBuckets.length; k++) {
326+ const x0 = L.deepLeft + k * 2 * hl + 1.5;
327+ const bw = 2 * hl - 3;
328+ const v = cfg.deepBuckets[k];
329+ const lit = s.deepLanded === k;
330+ const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.deepLandedAt) * 8) : 0;
331+ const col = v === 0 ? "#ff5c7a" : v >= 10 ? "#ffd66b" : palette.secondary;
332+ ctx.beginPath();
333+ ctx.roundRect(x0, dbTop, bw, bH, Math.min(6, hl * 0.35));
334+ ctx.fillStyle = lit ? rgba(col, 0.55 + 0.35 * pulse) : rgba(col, 0.1);
335+ ctx.fill();
336+ ctx.strokeStyle = lit ? "#ffffff" : rgba(col, 0.5);
337+ ctx.lineWidth = lit ? 2 : 1;
338+ ctx.stroke();
339+ ctx.font = font(Math.max(9, Math.min(13, hl * 0.78)), 800);
340+ ctx.fillStyle = lit ? "#0b1020" : col;
341+ ctx.fillText(`×${v}`, x0 + bw / 2, dbTop + bH / 2, bw - 2);
342+ }
343+ ctx.restore();
344+ }
345+
346+ // Gate rings
347+ for (const g of s.gates) {
348+ const age = s.t - g.at;
349+ const { px, py } = toPx(L, s, g.x, g.y, false);
350+ const k = Math.max(0, 1 - age / 1.6);
351+ ctx.save();
352+ ctx.globalAlpha = 0.35 + 0.65 * k;
353+ ctx.strokeStyle = palette.secondary;
354+ ctx.lineWidth = 2 + 2 * k;
355+ ctx.shadowColor = palette.secondary;
356+ ctx.shadowBlur = 16 * k;
357+ ctx.beginPath();
358+ ctx.ellipse(px, py, hl * (1.05 + 0.4 * Math.min(1, age * 3)), hl * 0.42, 0, 0, Math.PI * 2);
359+ ctx.stroke();
360+ ctx.shadowBlur = 0;
361+ ctx.font = font(Math.max(11, hl * 0.95), 900);
362+ ctx.textAlign = "center";
363+ ctx.textBaseline = "middle";
364+ ctx.fillStyle = "#ffffff";
365+ const rise = Math.min(1, age * 1.4);
366+ ctx.fillText(`×${g.value}`, px, py - hl * 1.1 - rise * hl * 0.8);
367+ ctx.restore();
368+ }
369+
370+ // Portals
371+ for (const p of s.portals) {
372+ const age = s.t - p.at;
373+ const k = Math.max(0, 1 - age / 1.8);
374+ const a = toPx(L, s, p.x, p.y, false);
375+ const b = toPx(L, s, p.to, p.y, false);
376+ ctx.save();
377+ ctx.globalAlpha = 0.25 + 0.75 * k;
378+ ctx.strokeStyle = "#a78bfa";
379+ ctx.lineWidth = 1.5;
380+ ctx.setLineDash([3, 4]);
381+ ctx.beginPath();
382+ ctx.moveTo(a.px, a.py);
383+ ctx.lineTo(b.px, b.py);
384+ ctx.stroke();
385+ ctx.setLineDash([]);
386+ for (const q of [a, b]) {
387+ ctx.beginPath();
388+ ctx.ellipse(q.px, q.py, hl * 0.75, hl * 0.35, 0, 0, Math.PI * 2);
389+ ctx.strokeStyle = "#c4b5fd";
390+ ctx.lineWidth = 2;
391+ ctx.shadowColor = "#a78bfa";
392+ ctx.shadowBlur = 12 * k;
393+ ctx.stroke();
394+ }
395+ ctx.restore();
396+ }
397+
398+ // Trail
399+ for (const tr of s.trail) {
400+ const { px, py } = toPx(L, s, tr.x, tr.y, tr.deep);
401+ ctx.beginPath();
402+ ctx.arc(px, py, hl * 0.32 * tr.life, 0, Math.PI * 2);
403+ ctx.fillStyle = rgba(palette.glow, 0.25 * tr.life);
404+ ctx.fill();
405+ }
406+
407+ // Release marker (idle)
408+ if (s.phase === "idle") {
409+ const { px, py } = toPx(L, s, s.lane * 2 + 1, -0.75, false);
410+ const bob = Math.sin(s.t * 2.2) * 2;
411+ ctx.save();
412+ ctx.strokeStyle = rgba(palette.primary, 0.35);
413+ ctx.setLineDash([2, 6]);
414+ ctx.beginPath();
415+ ctx.moveTo(px, py + hl * 0.6);
416+ ctx.lineTo(px, L.pad + (MARKER_UNITS + cfg.rows - s.camY) * rowH);
417+ ctx.stroke();
418+ ctx.setLineDash([]);
419+ ctx.fillStyle = rgba(palette.primary, 0.9);
420+ ctx.beginPath();
421+ ctx.moveTo(px - hl * 0.7, py - hl * 0.35 + bob);
422+ ctx.lineTo(px + hl * 0.7, py - hl * 0.35 + bob);
423+ ctx.lineTo(px, py + hl * 0.45 + bob);
424+ ctx.closePath();
425+ ctx.fill();
426+ ctx.restore();
427+ }
428+
429+ // Capsule
430+ if (s.capsule.visible && s.capsule.alpha > 0) {
431+ const c = s.capsule;
432+ const { px, py } = toPx(L, s, c.x, c.y, c.deep);
433+ const r = hl * 0.42;
434+ ctx.save();
435+ ctx.globalAlpha = c.alpha;
436+ ctx.translate(px, py);
437+ ctx.scale(1 / Math.sqrt(c.squash), Math.sqrt(c.squash));
438+ ctx.shadowColor = palette.glow;
439+ ctx.shadowBlur = 14 + 22 * c.glow;
440+ const grad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r);
441+ grad.addColorStop(0, "#ffffff");
442+ grad.addColorStop(0.35, palette.glow);
443+ grad.addColorStop(1, palette.primary);
444+ ctx.fillStyle = grad;
445+ ctx.beginPath();
446+ ctx.arc(0, 0, r, 0, Math.PI * 2);
447+ ctx.fill();
448+ ctx.shadowBlur = 0;
449+ ctx.strokeStyle = "rgba(255,255,255,0.75)";
450+ ctx.lineWidth = 1.2;
451+ ctx.stroke();
452+ ctx.restore();
453+ }
454+
455+ // Particles
456+ for (const p of s.particles) {
457+ const k = p.life / p.max;
458+ ctx.globalAlpha = k;
459+ ctx.fillStyle = p.color;
460+ ctx.beginPath();
461+ ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2);
462+ ctx.fill();
463+ }
464+ ctx.globalAlpha = 1;
465+
466+ // Centre flash text (deep multiplier etc.)
467+ if (s.flash) {
468+ const age = s.t - s.flash.at;
469+ if (age < 1.6) {
470+ const k = age < 0.25 ? easeOutBack(age / 0.25) : 1;
471+ const fade = age > 1.1 ? 1 - (age - 1.1) / 0.5 : 1;
472+ ctx.save();
473+ ctx.globalAlpha = Math.max(0, fade);
474+ ctx.translate(w / 2, h * 0.42);
475+ ctx.scale(k, k);
476+ ctx.font = font(Math.min(54, w * 0.13), 900);
477+ ctx.textAlign = "center";
478+ ctx.textBaseline = "middle";
479+ ctx.shadowColor = s.flash.color;
480+ ctx.shadowBlur = 30;
481+ ctx.fillStyle = s.flash.color;
482+ ctx.fillText(s.flash.text, 0, 0);
483+ ctx.restore();
484+ }
485+ }
486+}
487+
488+/* ------------------------------------------------------------- component */
489+
490+const RISKS: { id: Risk; label: string }[] = [
491+ { id: "low", label: "Low" },
492+ { id: "medium", label: "Medium" },
493+ { id: "high", label: "High" },
494+];
495+
496+export function DropzoneGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {
497+ const cfg = definition.config as unknown as DropzoneConfig;
498+ const palette = definition.presentation.palette;
499+ const { play, error, clearError } = useInstantPlay<DropOutcome>(definition.slug);
500+ const [risk, setRisk] = useState<Risk>("medium");
501+ const [lane, setLane] = useState(Math.floor(cfg.lanes / 2));
502+ const [phase, setPhase] = useState<"idle" | "dropping">("idle");
503+ const [live, setLive] = useState<string | null>(null);
504+ const [result, setResult] = useState<{ win: number; multiplier: number; parts: string } | null>(null);
505+ const canvasRef = useRef<HTMLCanvasElement>(null);
506+ const sceneRef = useRef<Scene | null>(null);
507+ const aliveRef = useRef(true);
508+ const phaseRef = useRef<"idle" | "dropping">("idle");
509+ const values = useMemo(() => displayedBuckets(definition, cfg, risk, lane), [definition, cfg, risk, lane]);
510+
511+ const getScene = useCallback((): Scene => {
512+ if (!sceneRef.current) {
513+ const mid = Math.floor(cfg.lanes / 2);
514+ sceneRef.current = {
515+ t: 0,
516+ lane: mid,
517+ risk: "medium",
518+ values: displayedBuckets(definition, cfg, "medium", mid),
519+ phase: "idle",
520+ camY: 0,
521+ capsule: { x: mid * 2 + 1, y: 0, deep: false, visible: false, alpha: 1, squash: 1, glow: 0 },
522+ trail: [],
523+ gates: [],
524+ portals: [],
525+ landed: null,
526+ landedAt: 0,
527+ deepReveal: 0,
528+ deepLanded: null,
529+ deepLandedAt: 0,
530+ particles: [],
531+ flash: null,
532+ reduceMotion: false,
533+ };
534+ }
535+ return sceneRef.current;
536+ }, [definition, cfg]);
537+
538+ /* Render loop */
539+ useEffect(() => {
540+ aliveRef.current = true;
541+ const canvas = canvasRef.current;
542+ if (!canvas) return;
543+ const ctx = canvas.getContext("2d");
544+ if (!ctx) return;
545+ const s = getScene();
546+ let raf = 0;
547+ let last = performance.now();
548+ const loop = (now: number) => {
549+ const dt = Math.min(0.05, (now - last) / 1000);
550+ last = now;
551+ s.t += dt;
552+ const dpr = Math.min(2, window.devicePixelRatio || 1);
553+ const rect = canvas.getBoundingClientRect();
554+ const W = Math.max(1, Math.round(rect.width * dpr));
555+ const H = Math.max(1, Math.round(rect.height * dpr));
556+ if (canvas.width !== W || canvas.height !== H) {
557+ canvas.width = W;
558+ canvas.height = H;
559+ }
560+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
561+ const L = layoutFor(rect.width, rect.height, cfg);
562+ // particles
563+ for (let i = s.particles.length - 1; i >= 0; i--) {
564+ const p = s.particles[i];
565+ p.life -= dt;
566+ p.x += p.vx * dt;
567+ p.y += p.vy * dt;
568+ p.vy += 420 * dt;
569+ p.vx *= 0.98;
570+ if (p.life <= 0) s.particles.splice(i, 1);
571+ }
572+ for (let i = s.trail.length - 1; i >= 0; i--) {
573+ s.trail[i].life -= dt * 3.2;
574+ if (s.trail[i].life <= 0) s.trail.splice(i, 1);
575+ }
576+ if (s.phase === "dropping" && s.capsule.visible && !s.reduceMotion) s.trail.push({ x: s.capsule.x, y: s.capsule.y, deep: s.capsule.deep, life: 1 });
577+ s.capsule.glow = Math.max(0, s.capsule.glow - dt * 1.5);
578+ drawScene(ctx, L, s, cfg, palette);
579+ raf = requestAnimationFrame(loop);
580+ };
581+ raf = requestAnimationFrame(loop);
582+ return () => {
583+ aliveRef.current = false;
584+ cancelAnimationFrame(raf);
585+ };
586+ }, [cfg, palette, getScene]);
587+
588+ /* Sync UI state into the scene */
589+ useEffect(() => {
590+ const s = getScene();
591+ s.lane = lane;
592+ s.risk = risk;
593+ s.values = values;
594+ s.reduceMotion = reduceMotion;
595+ if (s.phase === "idle") {
596+ s.capsule.x = lane * 2 + 1;
597+ }
598+ }, [lane, risk, values, reduceMotion, getScene]);
599+
600+ const laneFromPointer = useCallback(
601+ (e: React.PointerEvent<HTMLCanvasElement>) => {
602+ const rect = e.currentTarget.getBoundingClientRect();
603+ const L = layoutFor(rect.width, rect.height, cfg);
604+ const x = e.clientX - rect.left;
605+ return clamp(Math.floor((x - L.left) / (L.hl * 2)), 0, cfg.lanes - 1);
606+ },
607+ [cfg],
608+ );
609+
610+ const onPointer = useCallback(
611+ (e: React.PointerEvent<HTMLCanvasElement>) => {
612+ if (phaseRef.current !== "idle") return;
613+ if (e.type === "pointermove" && e.buttons === 0) return;
614+ const l = laneFromPointer(e);
615+ setLane((prev) => {
616+ if (prev !== l) sound("tick");
617+ return l;
618+ });
619+ },
620+ [laneFromPointer, sound],
621+ );
622+
623+ const spawnBurst = useCallback((s: Scene, L: Layout, x: number, y: number, deep: boolean, n: number, color: string, speed: number) => {
624+ if (s.reduceMotion) return;
625+ const { px, py } = toPx(L, s, x, y, deep);
626+ for (let i = 0; i < n; i++) {
627+ const a = Math.random() * Math.PI * 2;
628+ const v = speed * (0.4 + Math.random() * 0.8);
629+ s.particles.push({ x: px, y: py, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.4, life: 0.35 + Math.random() * 0.4, max: 0.75, color, size: 1.5 + Math.random() * 2.2 });
630+ }
631+ }, []);
632+
633+ const animate = useCallback(
634+ async (outcome: DropOutcome) => {
635+ const s = getScene();
636+ const canvas = canvasRef.current;
637+ const alive = () => aliveRef.current;
638+ const rm = s.reduceMotion;
639+ const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 600 };
640+ const L = () => layoutFor(rect.width, rect.height, cfg);
641+ const rowMs = rm ? 45 : 150;
642+
643+ // reset
644+ s.phase = "dropping";
645+ s.gates = [];
646+ s.portals = [];
647+ s.landed = null;
648+ s.deepLanded = null;
649+ s.deepReveal = 0;
650+ s.flash = null;
651+ s.particles = [];
652+ s.trail = [];
653+ s.values = outcome.summary.table;
654+ if (s.camY > 0) await tween(rm ? 60 : 320, (t) => (s.camY = lerp(s.camY, 0, easeOutCubic(t))), alive);
655+ s.camY = 0;
656+ const cap = s.capsule;
657+ cap.deep = false;
658+ cap.x = outcome.summary.startLane * 2 + 1;
659+ cap.y = -0.75;
660+ cap.alpha = 1;
661+ cap.squash = 1;
662+ cap.visible = true;
663+ cap.glow = 1;
664+ await tween(rm ? 60 : 220, (t) => (cap.y = lerp(-0.75, 0, easeInQuad(t))), alive);
665+
666+ let gateMult = 1;
667+ const runSteps = async (steps: DropStep[], deep: boolean) => {
668+ for (let i = 0; i < steps.length; i++) {
669+ if (!alive()) return;
670+ const step = steps[i];
671+ const fromX = cap.x;
672+ const toX = step.x;
673+ const r = step.row;
674+ await tween(rowMs, (t) => {
675+ if (t < 0.5) {
676+ cap.y = lerp(r, r + 0.5, easeInQuad(t / 0.5));
677+ cap.squash = 1;
678+ } else {
679+ const k = (t - 0.5) / 0.5;
680+ cap.x = lerp(fromX, toX, easeOutCubic(k));
681+ cap.y = lerp(r + 0.5, r + 1, k);
682+ cap.squash = 1 + 0.45 * Math.sin(k * Math.PI) * (k < 0.5 ? 1 : 0.4);
683+ }
684+ }, alive);
685+ spawnBurst(s, L(), fromX, r + 0.5, deep, 3, "#ffffff", 90);
686+ if (i % 2 === 0) sound("tick");
687+ if (!deep && step.event?.type === "gate") {
688+ gateMult *= step.event.value;
689+ s.gates.push({ x: toX, y: r + 1, value: step.event.value, at: s.t });
690+ cap.glow = 1;
691+ spawnBurst(s, L(), toX, r + 1, false, 14, palette.secondary, 160);
692+ sound("bonus");
693+ setLive(`Gate ×${step.event.value} · total ×${gateMult}`);
694+ await wait(rm ? 80 : 260);
695+ } else if (!deep && step.event?.type === "portal") {
696+ const to = step.event.to;
697+ s.portals.push({ x: toX, to, y: r + 1, at: s.t });
698+ sound("bonus");
699+ setLive(`Portal → lane ${Math.floor(to / 2) + 1}`);
700+ await tween(rm ? 40 : 160, (t) => (cap.alpha = 1 - t), alive);
701+ spawnBurst(s, L(), toX, r + 1, false, 10, "#a78bfa", 120);
702+ cap.x = to;
703+ spawnBurst(s, L(), to, r + 1, false, 10, "#a78bfa", 120);
704+ await tween(rm ? 40 : 160, (t) => (cap.alpha = t), alive);
705+ cap.alpha = 1;
706+ }
707+ }
708+ };
709+
710+ await runSteps(outcome.steps, false);
711+ if (!alive()) return;
712+
713+ // Landing in the main bucket
714+ const bucket = outcome.summary.bucket;
715+ const fromX = cap.x;
716+ await tween(rm ? 60 : 260, (t) => {
717+ cap.x = lerp(fromX, bucket * 2 + 1, easeOutCubic(t));
718+ cap.y = lerp(cfg.rows, cfg.rows + 0.85, easeOutBack(t));
719+ cap.squash = 1 + 0.3 * Math.sin(t * Math.PI);
720+ }, alive);
721+ s.landed = bucket;
722+ s.landedAt = s.t;
723+ cap.glow = 1;
724+ spawnBurst(s, L(), bucket * 2 + 1, cfg.rows + 0.85, false, 18, palette.glow, 180);
725+ const bucketValue = outcome.summary.bucketValue;
726+ setLive(gateMult > 1 ? `${formatMultiplier(bucketValue)} bucket × gates ×${gateMult}` : `${formatMultiplier(bucketValue)} bucket`);
727+ sound(bucketValue * gateMult >= 1 ? "win" : "tick");
728+
729+ // Deep Drop
730+ const deep = outcome.summary.deep;
731+ if (deep) {
732+ await wait(rm ? 80 : 420);
733+ sound("bonus");
734+ s.flash = { text: "DEEP DROP", at: s.t, color: palette.secondary };
735+ const Ld = L();
736+ const visibleUnits = (Ld.h - Ld.pad * 2) / Ld.rowH - MARKER_UNITS;
737+ const targetCam = Math.max(0, Ld.totalDeepUnits + 0.4 - visibleUnits);
738+ const deepStartX = clamp(bucket * 2 + 1, 0, cfg.deepBuckets.length * 2 - 1);
739+ await tween(rm ? 120 : 800, (t) => {
740+ const k = easeOutCubic(t);
741+ s.deepReveal = Math.min(1, t * 1.6);
742+ s.camY = targetCam * k;
743+ }, alive);
744+ // capsule falls through the bucket floor into the deep release line
745+ await tween(rm ? 60 : 360, (t) => {
746+ cap.alpha = t < 0.5 ? 1 - t * 2 : (t - 0.5) * 2;
747+ if (t >= 0.5 && !cap.deep) {
748+ cap.deep = true;
749+ cap.x = deepStartX;
750+ cap.y = -0.6;
751+ }
752+ if (t >= 0.5) cap.y = lerp(-0.6, 0, (t - 0.5) * 2);
753+ }, alive);
754+ cap.alpha = 1;
755+ cap.deep = true;
756+ cap.x = deepStartX;
757+ cap.y = 0;
758+ await runSteps(deep.steps, true);
759+ if (!alive()) return;
760+ const fx = cap.x;
761+ await tween(rm ? 60 : 260, (t) => {
762+ cap.x = lerp(fx, deep.bucket * 2 + 1, easeOutCubic(t));
763+ cap.y = lerp(cfg.deepRows, cfg.deepRows + 0.85, easeOutBack(t));
764+ cap.squash = 1 + 0.3 * Math.sin(t * Math.PI);
765+ }, alive);
766+ s.deepLanded = deep.bucket;
767+ s.deepLandedAt = s.t;
768+ const col = deep.value === 0 ? "#ff5c7a" : deep.value >= 10 ? "#ffd66b" : palette.secondary;
769+ s.flash = { text: `×${deep.value}`, at: s.t, color: col };
770+ spawnBurst(s, L(), deep.bucket * 2 + 1, cfg.deepRows + 0.85, true, 24, col, 220);
771+ setLive(deep.value === 0 ? "Deep bucket ×0 — vanished" : `Deep bucket ×${deep.value}`);
772+ await wait(rm ? 100 : 500);
773+ }
774+
775+ if (!alive()) return;
776+ const parts = gateMult > 1 || deep ? [`${formatMultiplier(bucketValue)} bucket`, gateMult > 1 ? `×${gateMult} gates` : null, deep ? `×${deep.value} deep` : null].filter(Boolean).join(" · ") : `Bucket ${bucket + 1}`;
777+ setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, parts });
778+ if (outcome.totalWin <= 0) sound("lose");
779+ else if (outcome.multiplier >= 15) sound("bigWin");
780+ else sound("win");
781+ onResult({ win: outcome.totalWin, multiplier: outcome.multiplier });
782+ s.phase = "idle";
783+ },
784+ [cfg, palette, getScene, sound, onResult, spawnBurst],
785+ );
786+
787+ const drop = useCallback(async () => {
788+ if (phaseRef.current !== "idle") return;
789+ phaseRef.current = "dropping";
790+ setPhase("dropping");
791+ setResult(null);
792+ setLive(null);
793+ onBusy(true);
794+ sound("click");
795+ const res = await play(bet, { risk, lane });
796+ if (res && aliveRef.current) {
797+ await animate(res.outcome);
798+ }
799+ phaseRef.current = "idle";
800+ if (aliveRef.current) {
801+ setPhase("idle");
802+ const s = sceneRef.current;
803+ if (s) s.phase = "idle";
804+ }
805+ onBusy(false);
806+ }, [play, bet, risk, lane, animate, onBusy, sound]);
807+
808+ const dropping = phase === "dropping";
809+ const maxValue = Math.max(...values);
810+
811+ return (
812+ <div className="absolute inset-0 flex flex-col">
813+ {/* Risk selector */}
814+ <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2">
815+ <div role="radiogroup" aria-label="Risk profile" className="flex h-11 flex-1 max-w-[330px] rounded-md p-1 surface-2">
816+ {RISKS.map((r) => {
817+ const active = risk === r.id;
818+ return (
819+ <button
820+ key={r.id}
821+ role="radio"
822+ aria-checked={active}
823+ disabled={dropping}
824+ onClick={() => {
825+ if (risk !== r.id) sound("click");
826+ setRisk(r.id);
827+ }}
828+ className={cn("flex-1 rounded-[8px] text-[13px] font-semibold transition-all focus-ring disabled:opacity-60", active ? "text-[#061018]" : "text-fg-2 hover:text-fg")}
829+ style={active ? { background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary})`, boxShadow: `0 6px 20px -8px ${palette.primary}` } : undefined}
830+ >
831+ {r.label}
832+ </button>
833+ );
834+ })}
835+ </div>
836+ <div className="text-right leading-tight">
837+ <div className="eyebrow">Top bucket</div>
838+ <div className="text-sm font-bold tabular" style={{ color: palette.glow }}>
839+ {formatMultiplier(maxValue)}
840+ </div>
841+ </div>
842+ </div>
843+
844+ {/* Tower */}
845+ <div className="relative min-h-0 flex-1">
846+ <canvas
847+ ref={canvasRef}
848+ className={cn("absolute inset-0 h-full w-full touch-none", dropping ? "cursor-default" : "cursor-pointer")}
849+ onPointerDown={onPointer}
850+ onPointerMove={onPointer}
851+ aria-label="Drop tower. Tap or drag to choose the release lane."
852+ role="img"
853+ />
854+ <AnimatePresence>
855+ {live && dropping ? (
856+ <motion.div key={live} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: rgba(palette.secondary, 0.18), color: palette.secondary }}>
857+ {live}
858+ </motion.div>
859+ ) : null}
860+ {result && !dropping ? (
861+ <motion.div key="result" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-2 flex -translate-x-1/2 flex-col items-center glass rounded-lg px-4 py-1.5 text-center leading-tight">
862+ <span className={cn("whitespace-nowrap text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No win this drop"}</span>
863+ <span className="whitespace-nowrap text-[11px] text-fg-3">{result.parts}</span>
864+ </motion.div>
865+ ) : null}
866+ {error ? (
867+ <motion.div key="err" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 mx-auto max-w-sm glass rounded-md p-3 text-center text-sm">
868+ <div className="text-fg-2">{error}</div>
869+ <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring">
870+ Dismiss
871+ </button>
872+ </motion.div>
873+ ) : null}
874+ </AnimatePresence>
875+ </div>
876+
877+ {/* Lane + action */}
878+ <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2">
879+ <div className="flex items-center gap-1">
880+ <button disabled={dropping || lane <= 0} onClick={() => { sound("tick"); setLane((l) => Math.max(0, l - 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane left">
881+ <ChevronLeft className="h-4 w-4" />
882+ </button>
883+ <div className="flex h-11 min-w-[72px] flex-col items-center justify-center rounded-md surface-2 px-2">
884+ <span className="text-[10px] uppercase tracking-wider text-fg-3">Lane</span>
885+ <span className="text-sm font-bold tabular">{lane + 1} / {cfg.lanes}</span>
886+ </div>
887+ <button disabled={dropping || lane >= cfg.lanes - 1} onClick={() => { sound("tick"); setLane((l) => Math.min(cfg.lanes - 1, l + 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane right">
888+ <ChevronRight className="h-4 w-4" />
889+ </button>
890+ </div>
891+ <button
892+ onClick={() => void drop()}
893+ disabled={dropping}
894+ className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#061018] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring"
895+ style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 55%, ${mixHex(palette.primary, "#000000", 0.25)})`, boxShadow: `0 0 0 4px ${rgba(palette.primary, 0.18)}, 0 18px 50px -14px ${palette.primary}` }}
896+ aria-label={definition.presentation.verb}
897+ >
898+ {dropping ? "DROPPING…" : `${definition.presentation.verb} · ${formatSC(bet)}`}
899+ </button>
900+ </div>
901+ </div>
902+ );
903+}
added apps/web/src/components/arcade/escape.tsx +538 −0
Large diff (538 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/arcade/gridbreak.tsx +796 −0
Large diff (796 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/arcade/orbit.tsx +917 −0
Large diff (917 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/arcade/vault.tsx +485 −0
Large diff (485 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/lobby/game-art.tsx +1048 −21
Large diff (1,167 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/lobby/symbol-svg.tsx +91 −0
Large diff (97 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/dropzone.json +301 −0
Large diff (301 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/escape-99.json +297 −0
Large diff (297 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/grid-break.json +302 −0
Large diff (302 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/orbit.json +307 −0
Large diff (307 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/the-vault.json +297 −0
Large diff (297 lines) is not loaded by default to keep the page fast.Load diff
added games/src/arcade/index.ts +199 −0
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
modified games/src/calibration.json +21 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified games/src/client.ts +1 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
modified games/src/index.ts +1 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/drizzle/0002_nosy_giant_man.sql +22 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/drizzle/meta/0002_snapshot.json +2988 −0
Large diff (2,989 lines) is not loaded by default to keep the page fast.Load diff
modified packages/database/drizzle/meta/_journal.json +7 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified packages/database/src/schema.ts +26 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
modified packages/database/src/sync-games.ts +46 −2
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added packages/game-core/src/arcade/dropzone.ts +143 −0
Large diff (143 lines) is not loaded by default to keep the page fast.Load diff
added packages/game-core/src/arcade/gridbreak.ts +166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added packages/game-core/src/arcade/index.ts +185 −0
Large diff (185 lines) is not loaded by default to keep the page fast.Load diff
added packages/game-core/src/arcade/ladder.ts +272 −0
Large diff (272 lines) is not loaded by default to keep the page fast.Load diff
added packages/game-core/src/arcade/orbit.ts +121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added packages/game-core/src/arcade/types.ts +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
modified packages/game-core/src/client.ts +5 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified packages/game-core/src/index.ts +1 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme