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 7 | Path | Package | Role |
8 8 |---|---|---|
9 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). |
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). |
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 +| | | **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 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 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 15 | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. |
15 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 29 ## Non-negotiable rules
18 30
19 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 41 pnpm install
30 42 createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required
31 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 45 pnpm --filter @spinza/game-core test # engine unit tests
34 46 pnpm --filter @spinza/api test # integration tests (needs local DB/Redis)
35 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 11 import { healthRoutes } from "./routes/health";
12 12 import { adminRoutes } from "./routes/admin";
13 13 import { crashRoutes } from "./routes/crash";
14 +import { arcadeRoutes } from "./routes/arcade";
14 15 import { refreshSettings } from "./lib/settings";
15 16
16 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 72 await app.register(gameRoutes);
72 73 await app.register(rewardRoutes);
73 74 await app.register(crashRoutes);
75 + await app.register(arcadeRoutes);
74 76 await app.register(adminRoutes);
75 77
76 78 return app;
modified apps/api/src/routes/admin.ts +6 −5
@@ -1,7 +1,7 @@
1 1 import type { FastifyInstance } from "fastify";
2 2 import os from "node:os";
3 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 5 import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core";
6 6 import { simulateParallel } from "@spinza/simulator";
7 7 import { GAME_LIFECYCLE } from "@spinza/shared";
@@ -153,13 +153,14 @@ export async function adminRoutes(app: FastifyInstance) {
153 153 return {
154 154 games: rows.map(({ game, stats }) => {
155 155 const def = getGame(game.slug);
156 + const other = CRASH_BY_SLUG.get(game.slug) ?? ARCADE_BY_SLUG.get(game.slug);
156 157 return {
157 158 ...game,
158 159 enabled: flags[`game.${game.slug}.enabled`] ?? true,
159 − rtp: def?.rtp ?? null,
160 − payScale: def?.payScale ?? null,
160 + rtp: def?.rtp ?? other?.rtp ?? null,
161 + payScale: def?.payScale ?? (other && "payScale" in other ? other.payScale : null),
161 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 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 177 from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`);
177 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 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 1 import type { FastifyInstance } from "fastify";
2 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 4 import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared";
5 5 import { requireUser } from "../plugins/auth";
6 6 import { errors } from "../lib/errors";
@@ -90,6 +90,22 @@ export async function gameRoutes(app: FastifyInstance) {
90 90 };
91 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 109 const def = getGame(slug);
94 110 if (!def) throw errors.notFound("Game not found");
95 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 4 import { syncGames } from "@spinza/database/sync-games";
5 5 import { verifyCommitment } from "@spinza/game-core";
6 6 import { buildApp } from "../src/app";
7 −import { closeRedis } from "../src/lib/redis";
7 +import { closeRedis, redis } from "../src/lib/redis";
8 8 import type { FastifyInstance } from "fastify";
9 9
10 10 let app: FastifyInstance;
@@ -18,6 +18,9 @@ beforeAll(async () => {
18 18 process.env.NODE_ENV = "test";
19 19 app = await buildApp();
20 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 24 await syncGames(db);
22 25 await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`);
23 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 57 if (settled.status === "cashed") {
55 58 expect(settled.cashoutMultiplier).toBe(1);
56 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 62 const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));
60 63 expect(rounds).toHaveLength(1);
modified apps/api/test/wallet.test.ts +4 −1
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto";
7 7 import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database";
8 8 import { syncGames } from "@spinza/database/sync-games";
9 9 import { buildApp } from "../src/app";
10 −import { closeRedis } from "../src/lib/redis";
10 +import { closeRedis, redis } from "../src/lib/redis";
11 11 import type { FastifyInstance } from "fastify";
12 12
13 13 let app: FastifyInstance;
@@ -20,6 +20,9 @@ beforeAll(async () => {
20 20 process.env.NODE_ENV = "test";
21 21 app = await buildApp();
22 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 26 await syncGames(db);
24 27 // Force-publish one game for tests regardless of certification.
25 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 10 import fs from "node:fs";
11 11 import path from "node:path";
12 12 import { fileURLToPath } from "node:url";
13 −import { certify, certifyCrash, formatCertification, simulateCrash, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core";
14 −import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG } from "@spinza/games";
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, ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "@spinza/games";
15 15 import { calibrate, simulateParallel } from "./index";
16 16
17 17 const here = path.dirname(fileURLToPath(import.meta.url));
@@ -79,7 +79,21 @@ async function main() {
79 79
80 80 if (cmd === "calibrate") {
81 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 97 const g = RAW_GAMES.find((x) => x.slug === slug)!;
84 98 console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`);
85 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 108 fs.mkdirSync(certDir, { recursive: true });
95 109 const spins = num("spins", 1_000_000);
96 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 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 123 const crashDef = CRASH_BY_SLUG.get(slug);
100 124 if (crashDef) {
101 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 2 import { notFound } from "next/navigation";
3 3 import { apiServer } from "@/lib/api-server";
4 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 6 import type { ClientDefinition } from "@/components/game/types";
7 7 import { GameClient } from "@/components/game/game-client";
8 8 import { CrashClient } from "@/components/crash/crash-client";
9 +import { ArcadeClient } from "@/components/arcade/arcade-client";
9 10
10 11 type Params = { params: Promise<{ slug: string }> };
11 12
@@ -18,8 +19,9 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
18 19
19 20 export default async function GamePage({ params }: Params) {
20 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 23 if (!data) notFound();
23 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 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