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
Unified Split
modified CLAUDE.md +15 −3
@@ -7,13 +7,25 @@ Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credit
7 | Path | Package | Role | 7 | Path | Package | Role |
8 |---|---|---| 8 |---|---|---|
9 | `packages/shared` | `@spinza/shared` | Economy constants (10,000 SC start, bet levels, daily rewards, XP curve), zod schemas, formatting (`formatSC` — never a currency sign), API DTO types. | 9 | `packages/shared` | `@spinza/shared` | Economy constants (10,000 SC start, bet levels, daily rewards, XP curve), zod schemas, formatting (`formatSC` — never a currency sign), API DTO types. |
10−| `packages/game-core` | `@spinza/game-core` | **Server-only game engine**: `CryptoRng` (crypto.randomBytes, rejection sampling — `Math.random` is banned for outcomes), reels, ways/lines evaluation, wild/scatter/cascade/hold-respin/pick-bonus/meter/heat/mystery/quantum/dynamic-grid features, `runSpin()`, `simulate()`, `certify()`, `validateDefinition()`, `defineGame()`. `./client` export = types only (safe for the browser). | 10+| `packages/game-core` | `@spinza/game-core` | **Also**: `src/crash/` (crash engine: `P(crash ≥ x) = rtp/x`, SHA-256 commit/reveal, curves exp/power/steps, pacing events, `multiplierAt` shared with the browser via `crash/curve.ts`) and `src/arcade/` (instant resolvers + ladder engine `m_k = rtp·Π(1/p_i)` — strategy-independent RTP). |
11−| `games/` | `@spinza/games` | 20 original game definitions (`src/<slug>/index.ts`), `registry.ts`, `calibration.json` (payScale per slug+version, written by the simulator), `certifications/<slug>.json` (PASS/FAIL reports). | 11+| | | **Server-only game engine**: `CryptoRng` (crypto.randomBytes, rejection sampling — `Math.random` is banned for outcomes), reels, ways/lines evaluation, wild/scatter/cascade/hold-respin/pick-bonus/meter/heat/mystery/quantum/dynamic-grid features, `runSpin()`, `simulate()`, `certify()`, `validateDefinition()`, `defineGame()`. `./client` export = types only (safe for the browser). |
12+| `games/` | `@spinza/games` | 20 slot definitions (`src/<slug>/index.ts`, `registry.ts`), 10 **Risk Games** (`src/crash/index.ts`, cash-out/crash engine), 5 **Spinza Originals — Beyond Slots** (`src/arcade/index.ts`: Dropzone, Grid//Break, Orbit = instant; The Vault, Escape 99 = ladder), `calibration.json` (payScale per slug+version), `certifications/<slug>.json` (PASS/FAIL reports for all 35 games). |
12 | `packages/database` | `@spinza/database` | Drizzle schema (25 tables, snake_case casing), migrations in `drizzle/`, `seed.ts` (games sync, achievements, missions, levels, flags, settings), `sync-games.ts`. | 13 | `packages/database` | `@spinza/database` | Drizzle schema (25 tables, snake_case casing), migrations in `drizzle/`, `seed.ts` (games sync, achievements, missions, levels, flags, settings), `sync-games.ts`. |
13 | `apps/api` | `@spinza/api` | Fastify on 127.0.0.1:8231. Auth (argon2id, opaque 256-bit sessions hashed, recovery codes), atomic spin (`services/spin.ts`), wallet ledger (`services/wallet.ts`), progression, rewards, leaderboards, admin (TOTP), health. | 14 | `apps/api` | `@spinza/api` | Fastify on 127.0.0.1:8231. Auth (argon2id, opaque 256-bit sessions hashed, recovery codes), atomic spin (`services/spin.ts`), wallet ledger (`services/wallet.ts`), progression, rewards, leaderboards, admin (TOTP), health. |
14 | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. | 15 | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. |
15 | `apps/web` | `@spinza/web` | Next 16 (port 8230). `/api/*` is rewritten to the Fastify service. Game screen = `src/components/game/*` (PixiJS renderer, procedural symbols, WebAudio sound). Admin console under `/admin`. | 16 | `apps/web` | `@spinza/web` | Next 16 (port 8230). `/api/*` is rewritten to the Fastify service. Game screen = `src/components/game/*` (PixiJS renderer, procedural symbols, WebAudio sound). Admin console under `/admin`. |
16 17
18+## Game families & API
19+
20+| Family | Kind | Play API | Client |
21+|---|---|---|---|
22+| Slots (20) | `slot` | `POST /api/games/:slug/spin` | `components/game/*` (PixiJS renderer) |
23+| Risk Games (10) | `crash` | `POST /api/crash/:slug/start` → poll `GET /api/crash/:slug/rounds/:id` → `POST /api/crash/:slug/cashout` (auto cash-out settles server-side; `crash_rounds` table; lazy settlement) | `components/crash/*` (2D canvas scenes) |
24+| Beyond Slots instant (3) | `arcade` | `POST /api/arcade/:slug/play {bet, clientRoundId, input}` | `components/arcade/{dropzone,gridbreak,orbit}.tsx` |
25+| Beyond Slots ladder (2) | `arcade` | `POST /api/arcade/:slug/start` → `POST /api/arcade/:slug/act {roundId, action}` (`arcade_sessions`) | `components/arcade/{vault,escape}.tsx` |
26+
27+Every settled round of any family goes through `apps/api/src/services/settle.ts` (`settleRound`: game_rounds row, stats, XP, achievements, missions, leaderboards). Lobby categories come from `summary.kind`/`category` written by `sync-games.ts`.
28+
17 ## Non-negotiable rules 29 ## Non-negotiable rules
18 30
19 - Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins. 31 - Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins.
@@ -29,7 +41,7 @@ Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credit
29 pnpm install 41 pnpm install
30 createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required 42 createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required
31 pnpm dev:api # 8231 pnpm dev:web # 8230 43 pnpm dev:api # 8231 pnpm dev:web # 8230
32−pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all --spins 10000000 --threads 26 | pnpm sim certify all --spins 10000000 44+pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all|arcade --spins 10000000 --threads 26 | pnpm sim certify all|crash|arcade --spins 10000000
33 pnpm --filter @spinza/game-core test # engine unit tests 45 pnpm --filter @spinza/game-core test # engine unit tests
34 pnpm --filter @spinza/api test # integration tests (needs local DB/Redis) 46 pnpm --filter @spinza/api test # integration tests (needs local DB/Redis)
35 pnpm admin:create <username> [password] # prints TOTP secret once 47 pnpm admin:create <username> [password] # prints TOTP secret once
modified apps/api/src/app.ts +2 −0
@@ -11,6 +11,7 @@ import { rewardRoutes } from "./routes/rewards";
11 import { healthRoutes } from "./routes/health"; 11 import { healthRoutes } from "./routes/health";
12 import { adminRoutes } from "./routes/admin"; 12 import { adminRoutes } from "./routes/admin";
13 import { crashRoutes } from "./routes/crash"; 13 import { crashRoutes } from "./routes/crash";
14+import { arcadeRoutes } from "./routes/arcade";
14 import { refreshSettings } from "./lib/settings"; 15 import { refreshSettings } from "./lib/settings";
15 16
16 const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"]; 17 const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"];
@@ -71,6 +72,7 @@ export async function buildApp(): Promise<FastifyInstance> {
71 await app.register(gameRoutes); 72 await app.register(gameRoutes);
72 await app.register(rewardRoutes); 73 await app.register(rewardRoutes);
73 await app.register(crashRoutes); 74 await app.register(crashRoutes);
75+ await app.register(arcadeRoutes);
74 await app.register(adminRoutes); 76 await app.register(adminRoutes);
75 77
76 return app; 78 return app;
modified apps/api/src/routes/admin.ts +6 −5
@@ -1,7 +1,7 @@
1 import type { FastifyInstance } from "fastify"; 1 import type { FastifyInstance } from "fastify";
2 import os from "node:os"; 2 import os from "node:os";
3 import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database"; 3 import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database";
4−import { GAMES, getGame } from "@spinza/games"; 4+import { GAMES, getGame, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games";
5 import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core"; 5 import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core";
6 import { simulateParallel } from "@spinza/simulator"; 6 import { simulateParallel } from "@spinza/simulator";
7 import { GAME_LIFECYCLE } from "@spinza/shared"; 7 import { GAME_LIFECYCLE } from "@spinza/shared";
@@ -153,13 +153,14 @@ export async function adminRoutes(app: FastifyInstance) {
153 return { 153 return {
154 games: rows.map(({ game, stats }) => { 154 games: rows.map(({ game, stats }) => {
155 const def = getGame(game.slug); 155 const def = getGame(game.slug);
156+ const other = CRASH_BY_SLUG.get(game.slug) ?? ARCADE_BY_SLUG.get(game.slug);
156 return { 157 return {
157 ...game, 158 ...game,
158 enabled: flags[`game.${game.slug}.enabled`] ?? true, 159 enabled: flags[`game.${game.slug}.enabled`] ?? true,
159− rtp: def?.rtp ?? null, 160+ rtp: def?.rtp ?? other?.rtp ?? null,
160− payScale: def?.payScale ?? null, 161+ payScale: def?.payScale ?? (other && "payScale" in other ? other.payScale : null),
161 stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null, 162 stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null,
162− validation: def ? validateDefinition(def) : [{ level: "error", message: "definition missing from library" }], 163+ validation: def ? validateDefinition(def) : other ? [] : [{ level: "error", message: "definition missing from library" }],
163 }; 164 };
164 }), 165 }),
165 }; 166 };
@@ -175,7 +176,7 @@ export async function adminRoutes(app: FastifyInstance) {
175 const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players 176 const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players
176 from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`); 177 from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`);
177 const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10); 178 const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10);
178− return { game: g, definition: getGame(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs }; 179+ return { game: g, definition: getGame(slug) ?? CRASH_BY_SLUG.get(slug) ?? ARCADE_BY_SLUG.get(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs };
179 }); 180 });
180 181
181 app.post("/api/admin/games/:slug/lifecycle", async (req) => { 182 app.post("/api/admin/games/:slug/lifecycle", async (req) => {
added apps/api/src/routes/arcade.ts +218 −0
@@ -0,0 +1,218 @@
1+import type { FastifyInstance } from "fastify";
2+import { and, arcadeSessions, db, desc, eq, games, sql } from "@spinza/database";
3+import { CryptoRng, ladderAdvance, ladderStart, ladderView, resolveInstant, type ArcadeGameDefinition, type LadderAction, type LadderState } from "@spinza/game-core";
4+import { ARCADE_BY_SLUG } from "@spinza/games";
5+import { BET_LEVELS, classifyWin } from "@spinza/shared";
6+import { z } from "zod";
7+import { errors } from "../lib/errors";
8+import { newRoundId } from "../lib/crypto";
9+import { flag, maintenance } from "../lib/settings";
10+import { rateLimit, redis } from "../lib/redis";
11+import { requireUser } from "../plugins/auth";
12+import { applyCredit, lockWallet, saveWallet } from "../services/wallet";
13+import { settleRound } from "../services/settle";
14+import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";
15+
16+type SessionRow = typeof arcadeSessions.$inferSelect;
17+
18+function guard(slug: string): { def: ArcadeGameDefinition } {
19+ const def = ARCADE_BY_SLUG.get(slug);
20+ if (!def) throw errors.notFound("Unknown game");
21+ const m = maintenance();
22+ if (m.enabled) throw errors.maintenance(m.message);
23+ if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable.");
24+ return { def };
25+}
26+
27+function checkBet(def: ArcadeGameDefinition, bet: number) {
28+ if (!(BET_LEVELS as readonly number[]).includes(bet) || bet < def.minBet || bet > def.maxBet) throw errors.badRequest("Invalid bet for this game.");
29+}
30+
31+export async function arcadeRoutes(app: FastifyInstance) {
32+ /* ------------------------------------------------------------ instant */
33+ app.post("/api/arcade/:slug/play", async (req) => {
34+ const user = requireUser(req);
35+ const { slug } = req.params as { slug: string };
36+ const { def } = guard(slug);
37+ if (def.mode !== "instant") throw errors.badRequest("This game is played step by step — use /start.");
38+ const retry = await rateLimit(`arcade:${user.id}`, 180, 60);
39+ if (retry) throw errors.rateLimited(retry);
40+ const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid(), input: z.record(z.string(), z.unknown()).default({}) }).parse(req.body);
41+ checkBet(def, body.bet);
42+ const game = await db.query.games.findFirst({ where: eq(games.slug, slug) });
43+ if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");
44+
45+ const existing = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`);
46+ if (existing.rows.length) {
47+ const r = existing.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown };
48+ return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true };
49+ }
50+ const started = Date.now();
51+ const roundId = newRoundId();
52+ const rng = new CryptoRng();
53+ try {
54+ const res = await db.transaction(async (tx) => {
55+ const wallet = await lockWallet(tx, user.id);
56+ if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet);
57+ const outcome = resolveInstant(def, body.bet, body.input, rng);
58+ await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug });
59+ if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: slug, multiplier: outcome.multiplier });
60+ const settled = await settleRound(tx, wallet, {
61+ roundId,
62+ gameId: game.id,
63+ gameSlug: slug,
64+ gameVersion: def.version,
65+ clientRoundId: body.clientRoundId,
66+ bet: body.bet,
67+ win: outcome.totalWin,
68+ multiplier: outcome.multiplier,
69+ result: { kind: "arcade", ...outcome } as unknown as Record<string, unknown>,
70+ features: outcome.features,
71+ freeSpins: false,
72+ bonus: outcome.features.some((f) => f === "Deep Drop" || f === "Supernova" || f === "Chain reaction"),
73+ jackpotTier: null,
74+ rngReference: rng.reference(),
75+ durationMs: Date.now() - started,
76+ });
77+ await saveWallet(tx, wallet);
78+ return { outcome, balance: wallet.balance, settled };
79+ });
80+ redis().zadd("live:players", Date.now(), user.id).catch(() => {});
81+ return { roundId, outcome: res.outcome, win: res.outcome.totalWin, multiplier: res.outcome.multiplier, balance: res.balance, winClass: res.settled.winClass, xp: res.settled.xp, unlocked: res.settled.unlocked };
82+ } catch (e) {
83+ if (pgCode(e) === PG_UNIQUE_VIOLATION) {
84+ const again = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`);
85+ if (again.rows.length) {
86+ const r = again.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown };
87+ return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true };
88+ }
89+ }
90+ throw e;
91+ }
92+ });
93+
94+ /* ------------------------------------------------------------- ladder */
95+ app.get("/api/arcade/:slug/current", async (req) => {
96+ const user = requireUser(req);
97+ const { slug } = req.params as { slug: string };
98+ const row = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.gameSlug, slug), eq(arcadeSessions.status, "running")), orderBy: desc(arcadeSessions.startedAt) });
99+ return { session: row ? { roundId: row.roundId, ...ladderView(row.state as unknown as LadderState) } : null };
100+ });
101+
102+ app.post("/api/arcade/:slug/start", async (req) => {
103+ const user = requireUser(req);
104+ const { slug } = req.params as { slug: string };
105+ const { def } = guard(slug);
106+ if (def.mode !== "ladder") throw errors.badRequest("This game resolves in one shot — use /play.");
107+ const retry = await rateLimit(`arcade:${user.id}`, 180, 60);
108+ if (retry) throw errors.rateLimited(retry);
109+ const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid() }).parse(req.body);
110+ checkBet(def, body.bet);
111+ const game = await db.query.games.findFirst({ where: eq(games.slug, slug) });
112+ if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");
113+
114+ const existing = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) });
115+ if (existing) return { session: { roundId: existing.roundId, ...ladderView(existing.state as unknown as LadderState) }, balance: null, replayed: true };
116+ const running = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.status, "running")) });
117+ if (running) throw errors.conflict("ROUND_IN_PROGRESS", `You have an unfinished ${running.gameSlug} run — finish or cash it out first.`);
118+
119+ const roundId = newRoundId();
120+ const rng = new CryptoRng();
121+ try {
122+ const result = await db.transaction(async (tx) => {
123+ const wallet = await lockWallet(tx, user.id);
124+ if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet);
125+ await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug });
126+ const state = ladderStart(def, body.bet, rng);
127+ let row: SessionRow;
128+ [row] = await tx
129+ .insert(arcadeSessions)
130+ .values({ roundId, userId: user.id, gameId: game.id, gameSlug: slug, gameVersion: def.version, clientRoundId: body.clientRoundId, bet: body.bet, state: state as unknown as Record<string, unknown>, status: state.status })
131+ .returning();
132+ let settled: Awaited<ReturnType<typeof settleRound>> | null = null;
133+ if (state.status !== "running") {
134+ // Busted (or completed) on the mandatory first step.
135+ settled = await finalize(tx, wallet, row, state, def, rng.reference());
136+ [row] = await tx.update(arcadeSessions).set({ status: state.status, win: state.win, settledAt: new Date(), updatedAt: new Date() }).where(eq(arcadeSessions.id, row.id)).returning();
137+ }
138+ await saveWallet(tx, wallet);
139+ return { row, state, balance: wallet.balance, settled };
140+ });
141+ redis().zadd("live:players", Date.now(), user.id).catch(() => {});
142+ return { session: { roundId, ...ladderView(result.state) }, balance: result.balance, progression: result.settled ? { xp: result.settled.xp, unlocked: result.settled.unlocked } : null };
143+ } catch (e) {
144+ if (pgCode(e) === PG_UNIQUE_VIOLATION) {
145+ const again = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) });
146+ if (again) return { session: { roundId: again.roundId, ...ladderView(again.state as unknown as LadderState) }, balance: null, replayed: true };
147+ }
148+ throw e;
149+ }
150+ });
151+
152+ app.post("/api/arcade/:slug/act", async (req) => {
153+ const user = requireUser(req);
154+ const { slug } = req.params as { slug: string };
155+ const { def } = guard(slug);
156+ const body = z.object({ roundId: z.string(), action: z.object({ type: z.enum(["continue", "cashout"]), offerId: z.string().optional() }) }).parse(req.body);
157+ const rng = new CryptoRng();
158+ const res = await db.transaction(async (tx) => {
159+ const lock = await tx.execute(sql`select id from arcade_sessions where round_id = ${body.roundId} and user_id = ${user.id} for update`);
160+ if (!lock.rows.length) throw errors.notFound("Run not found");
161+ const row = (await tx.query.arcadeSessions.findFirst({ where: eq(arcadeSessions.roundId, body.roundId) }))!;
162+ const state = row.state as unknown as LadderState;
163+ if (row.status !== "running") {
164+ return { row, state, balance: null as number | null, settled: null as Awaited<ReturnType<typeof settleRound>> | null };
165+ }
166+ let next: LadderState;
167+ try {
168+ next = ladderAdvance(def, state, rng, body.action as LadderAction);
169+ } catch (e) {
170+ throw errors.badRequest((e as Error).message);
171+ }
172+ let settled: Awaited<ReturnType<typeof settleRound>> | null = null;
173+ let balance: number | null = null;
174+ if (next.status !== "running") {
175+ const wallet = await lockWallet(tx, user.id);
176+ settled = await finalize(tx, wallet, row, next, def, rng.reference());
177+ await saveWallet(tx, wallet);
178+ balance = wallet.balance;
179+ }
180+ const [updated] = await tx
181+ .update(arcadeSessions)
182+ .set({ state: next as unknown as Record<string, unknown>, status: next.status, win: next.win, updatedAt: new Date(), settledAt: next.status !== "running" ? new Date() : null })
183+ .where(eq(arcadeSessions.id, row.id))
184+ .returning();
185+ return { row: updated, state: next, balance, settled };
186+ });
187+ return { session: { roundId: res.row.roundId, ...ladderView(res.state) }, balance: res.balance, winClass: res.state.status === "running" ? null : classifyWin(res.state.win / res.state.bet), progression: res.settled ? { xp: res.settled.xp, unlocked: res.settled.unlocked } : null };
188+ });
189+
190+ app.get("/api/arcade/:slug/history", async (req) => {
191+ const { slug } = req.params as { slug: string };
192+ if (!ARCADE_BY_SLUG.has(slug)) throw errors.notFound();
193+ const rows = await db.execute(sql`select multiplier, win, bet, created_at, result->'summary' as summary, result->>'status' as status, (result->>'stage')::int as stage from game_rounds where game_slug = ${slug} order by created_at desc limit 20`);
194+ return { history: rows.rows };
195+ });
196+}
197+
198+/** Credit the win (if any) and write the shared round bookkeeping for a finished ladder run. */
199+async function finalize(tx: Parameters<typeof settleRound>[0], wallet: Parameters<typeof settleRound>[1], row: SessionRow, state: LadderState, def: ArcadeGameDefinition, rngReference: string) {
200+ if (state.win > 0) await applyCredit(tx, wallet, "WIN", state.win, row.roundId, { game: def.slug, multiplier: state.win / row.bet });
201+ return settleRound(tx, wallet, {
202+ roundId: row.roundId,
203+ gameId: row.gameId,
204+ gameSlug: def.slug,
205+ gameVersion: def.version,
206+ clientRoundId: row.clientRoundId,
207+ bet: row.bet,
208+ win: state.win,
209+ multiplier: row.bet ? state.win / row.bet : 0,
210+ result: { kind: "arcade", mode: "ladder", status: state.status, stage: state.stage, current: state.current, log: state.log, extra: state.extra } as unknown as Record<string, unknown>,
211+ features: [state.status === "completed" ? (def.slug === "the-vault" ? "Jackpot" : "Summit") : state.status === "cashed" ? "Cash out" : "Bust", ...new Set(state.log.map((l) => l.kind))],
212+ freeSpins: false,
213+ bonus: state.status === "completed",
214+ jackpotTier: state.status === "completed" && def.slug === "the-vault" ? "grand" : null,
215+ rngReference,
216+ durationMs: Date.now() - row.startedAt.getTime(),
217+ });
218+}
modified apps/api/src/routes/games.ts +17 −1
@@ -1,6 +1,6 @@
1 import type { FastifyInstance } from "fastify"; 1 import type { FastifyInstance } from "fastify";
2 import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database"; 2 import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database";
3−import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG } from "@spinza/games"; 3+import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games";
4 import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared"; 4 import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared";
5 import { requireUser } from "../plugins/auth"; 5 import { requireUser } from "../plugins/auth";
6 import { errors } from "../lib/errors"; 6 import { errors } from "../lib/errors";
@@ -90,6 +90,22 @@ export async function gameRoutes(app: FastifyInstance) {
90 }; 90 };
91 return { game: info, definition: crashDef }; 91 return { game: info, definition: crashDef };
92 } 92 }
93+ const arcadeDef = ARCADE_BY_SLUG.get(slug);
94+ if (arcadeDef) {
95+ const s = g.summary as Record<string, unknown>;
96+ let favorite = false;
97+ if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) }));
98+ const info: GameInfo = {
99+ ...toCard(g, { favorite }),
100+ rtp: arcadeDef.rtp,
101+ hitFrequency: ((s.certification as { hitRate?: number } | null)?.hitRate) ?? null,
102+ description: arcadeDef.description,
103+ rules: arcadeDef.rules,
104+ paytable: [],
105+ certification: (s.certification as GameInfo["certification"] | null) ?? null,
106+ };
107+ return { game: info, definition: arcadeDef };
108+ }
93 const def = getGame(slug); 109 const def = getGame(slug);
94 if (!def) throw errors.notFound("Game not found"); 110 if (!def) throw errors.notFound("Game not found");
95 const s = g.summary as Record<string, unknown>; 111 const s = g.summary as Record<string, unknown>;
added apps/api/test/arcade.test.ts +113 −0
@@ -0,0 +1,113 @@
1+import { afterAll, beforeAll, describe, expect, it } from "vitest";
2+import { randomUUID } from "node:crypto";
3+import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database";
4+import { syncGames } from "@spinza/database/sync-games";
5+import { buildApp } from "../src/app";
6+import { closeRedis, redis } from "../src/lib/redis";
7+import type { FastifyInstance } from "fastify";
8+
9+let app: FastifyInstance;
10+let cookie = "";
11+let userId = "";
12+const username = `a_${randomUUID().slice(0, 8)}`;
13+const origin = "http://localhost:8230";
14+const headers = () => ({ origin, cookie: `spinza_session=${cookie}` });
15+
16+beforeAll(async () => {
17+ process.env.NODE_ENV = "test";
18+ app = await buildApp();
19+ await app.ready();
20+ const keys = await redis().keys("rl:*");
21+ if (keys.length) await redis().del(...keys);
22+ await syncGames(db);
23+ await db.execute(sql`update games set lifecycle = 'published' where slug in ('dropzone','grid-break','orbit','the-vault','escape-99')`);
24+ const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });
25+ cookie = res.cookies.find((c) => c.name === "spinza_session")!.value;
26+ userId = res.json().user.id;
27+});
28+
29+afterAll(async () => {
30+ if (userId) await db.delete(users).where(eq(users.id, userId));
31+ await app.close();
32+ await closeDb();
33+ await closeRedis();
34+});
35+
36+describe("arcade instant games", () => {
37+ it("dropzone resolves a drop and is idempotent", async () => {
38+ const id = randomUUID();
39+ const a = await app.inject({ method: "POST", url: "/api/arcade/dropzone/play", headers: headers(), payload: { bet: 100, clientRoundId: id, input: { risk: "high", lane: 0 } } });
40+ expect(a.statusCode).toBe(200);
41+ const body = a.json();
42+ expect(body.outcome.steps).toHaveLength(12);
43+ expect(body.outcome.summary.startLane).toBe(0);
44+ expect(body.outcome.summary.table).toHaveLength(13);
45+ const b = await app.inject({ method: "POST", url: "/api/arcade/dropzone/play", headers: headers(), payload: { bet: 100, clientRoundId: id, input: { risk: "high", lane: 0 } } });
46+ expect(b.json().roundId).toBe(body.roundId);
47+ expect(b.json().replayed).toBe(true);
48+ });
49+
50+ it("grid-break and orbit resolve with valid inputs and reject bad bets", async () => {
51+ const g = await app.inject({ method: "POST", url: "/api/arcade/grid-break/play", headers: headers(), payload: { bet: 20, clientRoundId: randomUUID(), input: { column: 3 } } });
52+ expect(g.statusCode).toBe(200);
53+ expect(g.json().outcome.steps.length).toBeGreaterThanOrEqual(1);
54+ const o = await app.inject({ method: "POST", url: "/api/arcade/orbit/play", headers: headers(), payload: { bet: 20, clientRoundId: randomUUID(), input: { angle: 90 } } });
55+ expect(o.statusCode).toBe(200);
56+ expect(o.json().outcome.summary.orbits.length).toBeGreaterThanOrEqual(3);
57+ const bad = await app.inject({ method: "POST", url: "/api/arcade/orbit/play", headers: headers(), payload: { bet: 33, clientRoundId: randomUUID(), input: {} } });
58+ expect(bad.statusCode).toBe(400);
59+ });
60+});
61+
62+describe("arcade ladder games", () => {
63+ it("the vault: start resolves the first layer, then secure or bust; ledger stays consistent", async () => {
64+ const start = await app.inject({ method: "POST", url: "/api/arcade/the-vault/start", headers: headers(), payload: { bet: 100, clientRoundId: randomUUID() } });
65+ expect(start.statusCode).toBe(200);
66+ let s = start.json().session;
67+ expect(["running", "busted"]).toContain(s.status);
68+ if (s.status === "running") {
69+ expect(s.stage).toBe(1);
70+ expect(s.current).toBeCloseTo(1.2, 2);
71+ expect(s.offers).toHaveLength(1);
72+ const act = await app.inject({ method: "POST", url: "/api/arcade/the-vault/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "cashout" } } });
73+ expect(act.statusCode).toBe(200);
74+ s = act.json().session;
75+ expect(s.status).toBe("cashed");
76+ expect(s.win).toBe(120);
77+ expect(act.json().balance).toBeGreaterThan(0);
78+ // Acting again on a settled run is a no-op.
79+ const again = await app.inject({ method: "POST", url: "/api/arcade/the-vault/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "continue" } } });
80+ expect(again.json().session.status).toBe("cashed");
81+ }
82+ const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId));
83+ const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) });
84+ expect(Number(total)).toBe(w!.balance);
85+ const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));
86+ expect(rounds.length).toBeGreaterThanOrEqual(4);
87+ });
88+
89+ it("escape 99: only one run at a time; climbing advances floors", async () => {
90+ const start = await app.inject({ method: "POST", url: "/api/arcade/escape-99/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } });
91+ expect(start.statusCode).toBe(200);
92+ let s = start.json().session;
93+ if (s.status === "running") {
94+ const dup = await app.inject({ method: "POST", url: "/api/arcade/escape-99/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } });
95+ expect(dup.statusCode).toBe(409);
96+ const cur = await app.inject({ method: "GET", url: "/api/arcade/escape-99/current", headers: headers() });
97+ expect(cur.json().session.roundId).toBe(s.roundId);
98+ for (let i = 0; i < 5 && s.status === "running"; i++) {
99+ const offer = s.offers[0];
100+ const act = await app.inject({ method: "POST", url: "/api/arcade/escape-99/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "continue", offerId: offer.id } } });
101+ expect(act.statusCode).toBe(200);
102+ const next = act.json().session;
103+ if (next.status === "running") expect(next.stage).toBe(s.stage + offer.advance);
104+ s = next;
105+ }
106+ if (s.status === "running") {
107+ const out = await app.inject({ method: "POST", url: "/api/arcade/escape-99/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "cashout" } } });
108+ expect(out.json().session.status).toBe("cashed");
109+ expect(out.json().session.win).toBeGreaterThan(0);
110+ }
111+ }
112+ });
113+});
modified apps/api/test/crash.test.ts +5 −2
@@ -4,7 +4,7 @@ import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets }
4 import { syncGames } from "@spinza/database/sync-games"; 4 import { syncGames } from "@spinza/database/sync-games";
5 import { verifyCommitment } from "@spinza/game-core"; 5 import { verifyCommitment } from "@spinza/game-core";
6 import { buildApp } from "../src/app"; 6 import { buildApp } from "../src/app";
7−import { closeRedis } from "../src/lib/redis"; 7+import { closeRedis, redis } from "../src/lib/redis";
8 import type { FastifyInstance } from "fastify"; 8 import type { FastifyInstance } from "fastify";
9 9
10 let app: FastifyInstance; 10 let app: FastifyInstance;
@@ -18,6 +18,9 @@ beforeAll(async () => {
18 process.env.NODE_ENV = "test"; 18 process.env.NODE_ENV = "test";
19 app = await buildApp(); 19 app = await buildApp();
20 await app.ready(); 20 await app.ready();
21+ // Tests register several accounts per run: reset the per-IP rate limits first.
22+ const keys = await redis().keys("rl:*");
23+ if (keys.length) await redis().del(...keys);
21 await syncGames(db); 24 await syncGames(db);
22 await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`); 25 await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`);
23 const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); 26 const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });
@@ -54,7 +57,7 @@ describe("crash games", () => {
54 if (settled.status === "cashed") { 57 if (settled.status === "cashed") {
55 expect(settled.cashoutMultiplier).toBe(1); 58 expect(settled.cashoutMultiplier).toBe(1);
56 expect(settled.win).toBe(100); 59 expect(settled.win).toBe(100);
57− expect(out.json().balance).toBe(10000); 60+ expect(out.json().balance).toBeGreaterThanOrEqual(10000); // bet returned (+ possible first-round achievement reward)
58 } 61 }
59 const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); 62 const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));
60 expect(rounds).toHaveLength(1); 63 expect(rounds).toHaveLength(1);
modified apps/api/test/wallet.test.ts +4 −1
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto";
7 import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; 7 import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database";
8 import { syncGames } from "@spinza/database/sync-games"; 8 import { syncGames } from "@spinza/database/sync-games";
9 import { buildApp } from "../src/app"; 9 import { buildApp } from "../src/app";
10−import { closeRedis } from "../src/lib/redis"; 10+import { closeRedis, redis } from "../src/lib/redis";
11 import type { FastifyInstance } from "fastify"; 11 import type { FastifyInstance } from "fastify";
12 12
13 let app: FastifyInstance; 13 let app: FastifyInstance;
@@ -20,6 +20,9 @@ beforeAll(async () => {
20 process.env.NODE_ENV = "test"; 20 process.env.NODE_ENV = "test";
21 app = await buildApp(); 21 app = await buildApp();
22 await app.ready(); 22 await app.ready();
23+ // Tests register several accounts per run: reset the per-IP rate limits first.
24+ const keys = await redis().keys("rl:*");
25+ if (keys.length) await redis().del(...keys);
23 await syncGames(db); 26 await syncGames(db);
24 // Force-publish one game for tests regardless of certification. 27 // Force-publish one game for tests regardless of certification.
25 await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`); 28 await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`);
added apps/api/vitest.config.ts +5 −0
@@ -0,0 +1,5 @@
1+import { defineConfig } from "vitest/config";
2+
3+// Integration tests share one Postgres database: run files sequentially so game
4+// library syncs and force-publish statements do not race each other.
5+export default defineConfig({ test: { fileParallelism: false, testTimeout: 20_000 } });
modified apps/simulator/src/cli.ts +28 −4
@@ -10,8 +10,8 @@
10 import fs from "node:fs"; 10 import fs from "node:fs";
11 import path from "node:path"; 11 import path from "node:path";
12 import { fileURLToPath } from "node:url"; 12 import { fileURLToPath } from "node:url";
13−import { certify, certifyCrash, formatCertification, simulateCrash, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core"; 13+import { certify, certifyCrash, certifyArcade, calibrateArcade, formatCertification, simulateCrash, simulateArcade, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core";
14−import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG } from "@spinza/games"; 14+import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG, ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "@spinza/games";
15 import { calibrate, simulateParallel } from "./index"; 15 import { calibrate, simulateParallel } from "./index";
16 16
17 const here = path.dirname(fileURLToPath(import.meta.url)); 17 const here = path.dirname(fileURLToPath(import.meta.url));
@@ -79,7 +79,21 @@ async function main() {
79 79
80 if (cmd === "calibrate") { 80 if (cmd === "calibrate") {
81 const cal = readCalibration(); 81 const cal = readCalibration();
82− for (const slug of targets(sel)) { 82+ const list = sel === "arcade" ? RAW_ARCADE_GAMES.filter((g) => g.mode === "instant").map((g) => g.slug) : targets(sel);
83+ for (const slug of list) {
84+ const arcade = RAW_ARCADE_GAMES.find((x) => x.slug === slug);
85+ if (arcade) {
86+ if (arcade.mode !== "instant") {
87+ console.log(`\n${arcade.name} is a ladder game — RTP is analytic, no calibration needed.`);
88+ continue;
89+ }
90+ console.log(`\nCalibrating ${arcade.name} (${slug}@${arcade.version}, arcade) target ${(arcade.rtp * 100).toFixed(2)}%`);
91+ const { payScale, result } = calibrateArcade(arcade, num("spins", 400_000), num("iterations", 4), console.log);
92+ cal[slug] = { payScale, version: arcade.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) };
93+ fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n");
94+ console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`);
95+ continue;
96+ }
83 const g = RAW_GAMES.find((x) => x.slug === slug)!; 97 const g = RAW_GAMES.find((x) => x.slug === slug)!;
84 console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`); 98 console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`);
85 const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log }); 99 const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log });
@@ -94,8 +108,18 @@ async function main() {
94 fs.mkdirSync(certDir, { recursive: true }); 108 fs.mkdirSync(certDir, { recursive: true });
95 const spins = num("spins", 1_000_000); 109 const spins = num("spins", 1_000_000);
96 let failed = 0; 110 let failed = 0;
97− const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : targets(sel); 111+ const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : sel === "arcade" ? ARCADE_GAMES.map((g) => g.slug) : targets(sel);
98 for (const slug of list) { 112 for (const slug of list) {
113+ const arcadeDef = ARCADE_BY_SLUG.get(slug);
114+ if (arcadeDef) {
115+ console.log(`\nCertifying ${arcadeDef.name} (${slug}@${arcadeDef.version}, arcade ${arcadeDef.mode}) payScale=${arcadeDef.payScale} — ${spins.toLocaleString("en-US")} rounds`);
116+ const res = simulateArcade(arcadeDef, { spins });
117+ const report = certifyArcade(arcadeDef, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) });
118+ fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n");
119+ console.log(formatCertification(report));
120+ if (report.status === "FAIL") failed++;
121+ continue;
122+ }
99 const crashDef = CRASH_BY_SLUG.get(slug); 123 const crashDef = CRASH_BY_SLUG.get(slug);
100 if (crashDef) { 124 if (crashDef) {
101 console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`); 125 console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`);
modified apps/web/src/app/games/[slug]/page.tsx +4 −2
@@ -2,10 +2,11 @@ import type { Metadata } from "next";
2 import { notFound } from "next/navigation"; 2 import { notFound } from "next/navigation";
3 import { apiServer } from "@/lib/api-server"; 3 import { apiServer } from "@/lib/api-server";
4 import type { GameInfo } from "@spinza/shared"; 4 import type { GameInfo } from "@spinza/shared";
5−import type { CrashGameDefinition } from "@spinza/game-core/client"; 5+import type { ArcadeGameDefinition, CrashGameDefinition } from "@spinza/game-core/client";
6 import type { ClientDefinition } from "@/components/game/types"; 6 import type { ClientDefinition } from "@/components/game/types";
7 import { GameClient } from "@/components/game/game-client"; 7 import { GameClient } from "@/components/game/game-client";
8 import { CrashClient } from "@/components/crash/crash-client"; 8 import { CrashClient } from "@/components/crash/crash-client";
9+import { ArcadeClient } from "@/components/arcade/arcade-client";
9 10
10 type Params = { params: Promise<{ slug: string }> }; 11 type Params = { params: Promise<{ slug: string }> };
11 12
@@ -18,8 +19,9 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
18 19
19 export default async function GamePage({ params }: Params) { 20 export default async function GamePage({ params }: Params) {
20 const { slug } = await params; 21 const { slug } = await params;
21− const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition }>(`/api/games/${slug}`); 22+ const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition | ArcadeGameDefinition }>(`/api/games/${slug}`);
22 if (!data) notFound(); 23 if (!data) notFound();
23 if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />; 24 if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />;
25+ if (data.game.kind === "arcade") return <ArcadeClient game={data.game} definition={data.definition as ArcadeGameDefinition} />;
24 return <GameClient game={data.game} definition={data.definition as ClientDefinition} />; 26 return <GameClient game={data.game} definition={data.definition as ClientDefinition} />;
25 } 27 }
added apps/web/src/components/arcade/arcade-client.tsx +31 −0
@@ -0,0 +1,31 @@
1+"use client";
2+
3+/**
4+ * Dispatcher for the Beyond Slots originals: picks the scene component from
5+ * `definition.presentation.scene` and hosts it in the shared ArcadeShell.
6+ * (Minimal harness — the integrator owns the final version.)
7+ */
8+import type { ComponentType } from "react";
9+import type { GameInfo } from "@spinza/shared";
10+import type { ArcadeGameDefinition } from "@spinza/game-core/client";
11+import { ArcadeShell } from "./arcade-shell";
12+import type { ArcadeGameProps } from "./contract";
13+import { VaultGame } from "./vault";
14+import { EscapeGame } from "./escape";
15+import { DropzoneGame } from "./dropzone";
16+import { GridbreakGame } from "./gridbreak";
17+import { OrbitGame } from "./orbit";
18+
19+const SCENES: Partial<Record<ArcadeGameDefinition["presentation"]["scene"], ComponentType<ArcadeGameProps>>> = {
20+ vault: VaultGame,
21+ escape: EscapeGame,
22+ dropzone: DropzoneGame,
23+ gridbreak: GridbreakGame,
24+ orbit: OrbitGame,
25+};
26+
27+export function ArcadeClient({ game, definition }: { game: GameInfo; definition: ArcadeGameDefinition }) {
28+ const Game = SCENES[definition.presentation.scene];
29+ if (!Game) return null;
30+ return <ArcadeShell game={game} definition={definition} Game={Game} />;
31+}
added apps/web/src/components/arcade/arcade-shell.tsx +200 −0
@@ -0,0 +1,200 @@
1+"use client";
2+
3+import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
4+import Link from "next/link";
5+import { useRouter } from "next/navigation";
6+import { AnimatePresence, motion } from "framer-motion";
7+import { ArrowLeft, Heart, Info, Minus, Plus, Volume2, VolumeX } from "lucide-react";
8+import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared";
9+import type { ArcadeGameDefinition } from "@spinza/game-core/client";
10+import { api } from "@/lib/api";
11+import { useSession } from "@/lib/store";
12+import { cn } from "@/lib/utils";
13+import { Credits, Sheet, Tabs } from "@/components/ui";
14+import { getSound } from "@/components/game/sound";
15+import type { ArcadeGameProps } from "./contract";
16+
17+/**
18+ * Shared chrome for the Beyond Slots originals: top bar, bet controls,
19+ * balance, win banner, info sheet. The game component fills the middle.
20+ */
21+export function ArcadeShell({ game, definition, Game }: { game: GameInfo; definition: ArcadeGameDefinition; Game: ComponentType<ArcadeGameProps> }) {
22+ const router = useRouter();
23+ const { status, wallet, settings } = useSession();
24+ const [bet, setBet] = useState(100);
25+ const [busy, setBusy] = useState(false);
26+ const [last, setLast] = useState<{ win: number; multiplier: number } | null>(null);
27+ const [banner, setBanner] = useState<{ label: string; win: number; multiplier: number } | null>(null);
28+ const [infoSheet, setInfoSheet] = useState(false);
29+ const [favorite, setFavorite] = useState(!!game.favorite);
30+ const soundOn = settings?.soundEnabled ?? true;
31+ const palette = definition.presentation.palette;
32+ const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]);
33+ const balance = wallet?.balance ?? 0;
34+ const betIndex = bets.indexOf(bet);
35+
36+ useEffect(() => {
37+ if (status === "guest") router.replace(`/login?next=/games/${game.slug}`);
38+ }, [status, router, game.slug]);
39+
40+ useEffect(() => {
41+ const s = getSound();
42+ s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 });
43+ s.setAmbience(definition.presentation.ambience);
44+ api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {});
45+ return () => s.destroy();
46+ }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience, game.slug]);
47+
48+ const sound = useCallback<ArcadeGameProps["sound"]>((name) => {
49+ const s = getSound();
50+ s.unlock();
51+ switch (name) {
52+ case "click":
53+ s.click();
54+ break;
55+ case "tick":
56+ s.tick();
57+ break;
58+ case "win":
59+ s.win(2);
60+ break;
61+ case "bigWin":
62+ s.bigWin();
63+ break;
64+ case "lose":
65+ s.error();
66+ break;
67+ case "bonus":
68+ s.bonus();
69+ break;
70+ }
71+ }, []);
72+
73+ const onResult = useCallback<ArcadeGameProps["onResult"]>((r) => {
74+ setLast(r);
75+ const cls = classifyWin(r.multiplier);
76+ if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") {
77+ setBanner({ label: WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN", win: r.win, multiplier: r.multiplier });
78+ setTimeout(() => setBanner(null), 2600);
79+ }
80+ }, []);
81+
82+ const toggleFavorite = async () => {
83+ setFavorite((f) => !f);
84+ try {
85+ const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });
86+ setFavorite(r.favorite);
87+ } catch {
88+ setFavorite((f) => !f);
89+ }
90+ };
91+
92+ if (status === "guest") return null;
93+
94+ return (
95+ <div className="fixed inset-0 flex flex-col" style={{ background: `radial-gradient(120% 80% at 50% 0%, ${palette.surface} 0%, ${palette.bg} 60%, #050608 100%)` }}>
96+ <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>
97+ <div className="flex items-center gap-2">
98+ <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby">
99+ <ArrowLeft className="h-5 w-5" />
100+ </Link>
101+ <div className="leading-tight">
102+ <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>
103+ <div className="text-[11px] text-fg-3">Spinza Original · Beyond Slots</div>
104+ </div>
105+ </div>
106+ <div className="flex items-center gap-1">
107+ <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite">
108+ <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />
109+ </button>
110+ <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Game info">
111+ <Info className="h-5 w-5" />
112+ </button>
113+ <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Sound">
114+ {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
115+ </button>
116+ </div>
117+ </div>
118+
119+ <div className="relative flex-1 min-h-0">
120+ <Game game={game} definition={definition} bet={bet} onBusy={setBusy} onResult={onResult} sound={sound} reduceMotion={settings?.reduceMotion ?? false} />
121+ <AnimatePresence>
122+ {banner ? (
123+ <motion.div key={banner.win} initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="pointer-events-none absolute inset-0 grid place-items-center bg-black/40" style={{ zIndex: 5 }}>
124+ <div className="text-center">
125+ <div className="text-[clamp(34px,9vw,72px)] font-extrabold uppercase tracking-tight shimmer-text">{banner.label}</div>
126+ <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(banner.win)}</div>
127+ <div className="text-sm text-fg-2">{formatMultiplier(banner.multiplier)} the bet</div>
128+ </div>
129+ </motion.div>
130+ ) : null}
131+ </AnimatePresence>
132+ </div>
133+
134+ <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)", borderTop: `1px solid ${palette.primary}55` }}>
135+ <div className="mx-auto flex max-w-3xl items-center justify-between gap-3">
136+ <div>
137+ <div className="eyebrow">Balance</div>
138+ <Credits amount={balance} size="md" />
139+ </div>
140+ <div className="flex items-center gap-1">
141+ <button disabled={busy || betIndex <= 0} onClick={() => setBet(bets[betIndex - 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Decrease bet">
142+ <Minus className="h-4 w-4" />
143+ </button>
144+ <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2">
145+ <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>
146+ <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>
147+ </div>
148+ <button disabled={busy || betIndex >= bets.length - 1} onClick={() => setBet(bets[betIndex + 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Increase bet">
149+ <Plus className="h-4 w-4" />
150+ </button>
151+ </div>
152+ <div className="text-right">
153+ <div className="eyebrow">Last win</div>
154+ <div className={cn("text-base font-semibold tabular", last && last.win > 0 ? "text-credit" : "text-fg-4")}>{last && last.win > 0 ? `${formatSC(last.win)} · ${formatMultiplier(last.multiplier)}` : "—"}</div>
155+ </div>
156+ </div>
157+ </div>
158+
159+ <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />
160+ </div>
161+ );
162+}
163+
164+function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ArcadeGameDefinition }) {
165+ const [tab, setTab] = useState<"rules" | "about">("rules");
166+ return (
167+ <Sheet open={open} onClose={onClose} title={game.name} side="right">
168+ <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" />
169+ {tab === "rules" ? (
170+ <ul className="space-y-3 text-sm text-fg-2">
171+ {game.rules.map((r, i) => (
172+ <li key={i} className="flex gap-3">
173+ <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-surface-2 text-[11px] font-bold text-fg-3">{i + 1}</span>
174+ <span>{r}</span>
175+ </li>
176+ ))}
177+ </ul>
178+ ) : (
179+ <div className="space-y-4 text-sm text-fg-2">
180+ <p>{game.description}</p>
181+ <dl className="grid grid-cols-2 gap-3">
182+ {[
183+ ["Volatility", game.volatility],
184+ ["RTP", `${(game.rtp * 100).toFixed(2)}%`],
185+ ["Max win", formatMultiplier(definition.maxMultiplier)],
186+ ["Mode", definition.mode === "ladder" ? "Step by step" : "Instant"],
187+ ].map(([k, v]) => (
188+ <div key={k} className="surface rounded-md p-3">
189+ <dt className="eyebrow">{k}</dt>
190+ <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd>
191+ </div>
192+ ))}
193+ </dl>
194+ {game.certification ? <p className="text-[12px] text-fg-3">Certified on {game.certification.spins.toLocaleString("en-US")} simulated rounds · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%.</p> : null}
195+ <p className="text-[12px] text-fg-3">Spinza Credits are fictional and have no cash value.</p>
196+ </div>
197+ )}
198+ </Sheet>
199+ );
200+}
added apps/web/src/components/arcade/contract.ts +175 −0
@@ -0,0 +1,175 @@
1+"use client";
2+
3+/**
4+ * Shared contract for the five "Beyond Slots" arcade games. Each game module
5+ * exports a React component with the `ArcadeGameProps` signature and uses the
6+ * helpers below to talk to the API. Outcomes are always resolved server-side.
7+ */
8+import { useCallback, useEffect, useRef, useState } from "react";
9+import type { ArcadeGameDefinition, ArcadeOutcome, LadderState } from "@spinza/game-core/client";
10+import type { GameInfo } from "@spinza/shared";
11+import { api, ApiClientError } from "@/lib/api";
12+import { toast, useSession } from "@/lib/store";
13+
14+export interface ArcadeGameProps {
15+ game: GameInfo;
16+ definition: ArcadeGameDefinition;
17+ /** Current bet chosen in the shared shell. */
18+ bet: number;
19+ /** Tell the shell whether the game is mid-round (locks bet controls). */
20+ onBusy: (busy: boolean) => void;
21+ /** Report the last result to the shell (win banner + counters). */
22+ onResult: (r: { win: number; multiplier: number }) => void;
23+ /** Play a UI sound: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus". */
24+ sound: (name: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus") => void;
25+ reduceMotion: boolean;
26+}
27+
28+export interface Progression {
29+ xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number };
30+ unlocked: { achievements: string[]; missions: string[] };
31+}
32+
33+export interface InstantResponse<T extends ArcadeOutcome = ArcadeOutcome> {
34+ roundId: string;
35+ outcome: T;
36+ win: number;
37+ multiplier: number;
38+ balance: number;
39+ winClass: string;
40+ xp?: Progression["xp"];
41+ unlocked?: Progression["unlocked"];
42+ replayed?: boolean;
43+}
44+
45+export type LadderView = Omit<LadderState, "game" | "version"> & { roundId: string; game: string; version: string; canCashout: boolean };
46+
47+export interface LadderResponse {
48+ session: LadderView;
49+ balance: number | null;
50+ winClass?: string | null;
51+ progression: Progression | null;
52+ replayed?: boolean;
53+}
54+
55+function applyProgression(p: Progression | null | undefined) {
56+ if (!p) return;
57+ const s = useSession.getState();
58+ s.setUserXp(p.xp.total, p.xp.level);
59+ if (p.xp.leveledUp) toast({ title: `Level ${p.xp.level} reached`, description: `+${p.xp.levelReward.toLocaleString("en-US")} SC level reward`, tone: "credit" });
60+ for (const a of p.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" });
61+ for (const m of p.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" });
62+}
63+
64+export function describeArcadeError(e: unknown): string {
65+ if (e instanceof ApiClientError) return e.message;
66+ return "Connection lost. Try again.";
67+}
68+
69+/** Instant games: one call → resolved outcome. Handles optimistic balance and progression toasts. */
70+export function useInstantPlay<T extends ArcadeOutcome>(slug: string) {
71+ const [busy, setBusy] = useState(false);
72+ const [error, setError] = useState<string | null>(null);
73+ const setBalance = useSession((s) => s.setBalance);
74+ const play = useCallback(
75+ async (bet: number, input: Record<string, unknown>): Promise<InstantResponse<T> | null> => {
76+ if (busy) return null;
77+ const balance = useSession.getState().wallet?.balance ?? 0;
78+ if (balance < bet) {
79+ setError("Not enough Spinza Credits for this bet.");
80+ return null;
81+ }
82+ setBusy(true);
83+ setError(null);
84+ setBalance(balance - bet);
85+ try {
86+ const res = await api<InstantResponse<T>>(`/api/arcade/${slug}/play`, { json: { bet, clientRoundId: crypto.randomUUID(), input } });
87+ setBalance(res.balance);
88+ applyProgression(res.xp && res.unlocked ? { xp: res.xp, unlocked: res.unlocked } : null);
89+ return res;
90+ } catch (e) {
91+ setBalance(balance);
92+ setError(describeArcadeError(e));
93+ return null;
94+ } finally {
95+ setBusy(false);
96+ }
97+ },
98+ [busy, slug, setBalance],
99+ );
100+ return { play, busy, error, clearError: () => setError(null) };
101+}
102+
103+/** Ladder games: start → act(continue|cashout) until the session ends. Resumes a running session on mount. */
104+export function useLadder(slug: string) {
105+ const [session, setSession] = useState<LadderView | null>(null);
106+ const [busy, setBusy] = useState(false);
107+ const [error, setError] = useState<string | null>(null);
108+ const setBalance = useSession((s) => s.setBalance);
109+ const mounted = useRef(false);
110+
111+ useEffect(() => {
112+ if (mounted.current) return;
113+ mounted.current = true;
114+ api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`)
115+ .then((r) => {
116+ if (r.session) setSession(r.session);
117+ })
118+ .catch(() => {});
119+ }, [slug]);
120+
121+ const start = useCallback(
122+ async (bet: number): Promise<LadderResponse | null> => {
123+ if (busy) return null;
124+ const balance = useSession.getState().wallet?.balance ?? 0;
125+ if (balance < bet) {
126+ setError("Not enough Spinza Credits for this bet.");
127+ return null;
128+ }
129+ setBusy(true);
130+ setError(null);
131+ try {
132+ const res = await api<LadderResponse>(`/api/arcade/${slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID() } });
133+ if (res.balance !== null) setBalance(res.balance);
134+ else setBalance(balance - bet);
135+ setSession(res.session);
136+ applyProgression(res.progression);
137+ return res;
138+ } catch (e) {
139+ if (e instanceof ApiClientError && e.code === "ROUND_IN_PROGRESS") {
140+ const cur = await api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`).catch(() => null);
141+ if (cur?.session) setSession(cur.session);
142+ }
143+ setError(describeArcadeError(e));
144+ return null;
145+ } finally {
146+ setBusy(false);
147+ }
148+ },
149+ [busy, slug, setBalance],
150+ );
151+
152+ const act = useCallback(
153+ async (action: { type: "continue"; offerId?: string } | { type: "cashout" }): Promise<LadderResponse | null> => {
154+ if (!session || busy) return null;
155+ setBusy(true);
156+ setError(null);
157+ try {
158+ const res = await api<LadderResponse>(`/api/arcade/${slug}/act`, { json: { roundId: session.roundId, action } });
159+ setSession(res.session);
160+ if (res.balance !== null) setBalance(res.balance);
161+ applyProgression(res.progression);
162+ return res;
163+ } catch (e) {
164+ setError(describeArcadeError(e));
165+ return null;
166+ } finally {
167+ setBusy(false);
168+ }
169+ },
170+ [session, busy, slug, setBalance],
171+ );
172+
173+ const reset = useCallback(() => setSession(null), []);
174+ return { session, start, act, reset, busy, error, clearError: () => setError(null) };
175+}
added apps/web/src/components/arcade/dropzone.tsx +903 −0
@@ -0,0 +1,903 @@
1+"use client";
2+
3+/**
4+ * DROPZONE — browser side. The server resolves the whole descent; this file
5+ * only animates `outcome.steps` (half-lane x per row, gates, portals), the
6+ * landing bucket and the optional Deep Drop extension on a 2D canvas.
7+ */
8+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9+import { AnimatePresence, motion } from "framer-motion";
10+import { ChevronLeft, ChevronRight } from "lucide-react";
11+import { formatMultiplier, formatSC } from "@spinza/shared";
12+import type { ArcadeGameDefinition, ArcadeOutcome, DropStep, DropzoneConfig } from "@spinza/game-core/client";
13+import { cn } from "@/lib/utils";
14+import { useInstantPlay, type ArcadeGameProps } from "./contract";
15+
16+type Risk = "low" | "medium" | "high";
17+
18+type DropSummary = {
19+ risk: Risk;
20+ startLane: number;
21+ bucket: number;
22+ bucketValue: number;
23+ gateMult: number;
24+ deep: { steps: DropStep[]; bucket: number; value: number } | null;
25+ table: number[];
26+};
27+
28+interface DropOutcome extends ArcadeOutcome {
29+ steps: DropStep[];
30+ summary: DropSummary;
31+}
32+
33+/* ------------------------------------------------------------------ math */
34+/* Pure replica of the server's displayed bucket values (see game-core/arcade/dropzone.ts). */
35+
36+function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] {
37+ const width = cfg.lanes * 2;
38+ let dist: number[] = new Array<number>(width).fill(0);
39+ dist[startLane * 2 + 1] = 1;
40+ for (let row = 0; row < cfg.rows; row++) {
41+ const next: number[] = new Array<number>(width).fill(0);
42+ for (let x = 0; x < width; x++) {
43+ if (!dist[x]) continue;
44+ next[Math.max(0, x - 1)] += dist[x] / 2;
45+ next[Math.min(width - 1, x + 1)] += dist[x] / 2;
46+ }
47+ dist = next;
48+ }
49+ const buckets: number[] = new Array<number>(cfg.lanes).fill(0);
50+ for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x];
51+ return buckets;
52+}
53+
54+function laneNormalizer(cfg: DropzoneConfig, risk: Risk, startLane: number): number {
55+ const table = cfg.buckets[risk];
56+ const center = Math.floor(cfg.lanes / 2);
57+ const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0);
58+ return ev(center) / ev(startLane);
59+}
60+
61+function displayedBuckets(def: ArcadeGameDefinition, cfg: DropzoneConfig, risk: Risk, startLane: number): number[] {
62+ const norm = laneNormalizer(cfg, risk, startLane);
63+ return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100);
64+}
65+
66+/* --------------------------------------------------------------- helpers */
67+
68+const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
69+const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
70+const easeInQuad = (t: number) => t * t;
71+const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);
72+const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
73+const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
74+
75+function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> {
76+ return new Promise((resolve) => {
77+ const start = performance.now();
78+ const frame = (now: number) => {
79+ if (!alive()) return resolve();
80+ const t = Math.min(1, (now - start) / Math.max(1, ms));
81+ fn(t);
82+ if (t < 1) requestAnimationFrame(frame);
83+ else resolve();
84+ };
85+ requestAnimationFrame(frame);
86+ });
87+}
88+
89+function rgba(hex: string, a: number): string {
90+ const h = hex.replace("#", "");
91+ const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
92+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
93+}
94+
95+function mixHex(a: string, b: string, t: number): string {
96+ const pa = parseInt(a.replace("#", ""), 16);
97+ const pb = parseInt(b.replace("#", ""), 16);
98+ const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t));
99+ return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
100+}
101+
102+/* ----------------------------------------------------------------- scene */
103+
104+interface Particle {
105+ x: number;
106+ y: number;
107+ vx: number;
108+ vy: number;
109+ life: number;
110+ max: number;
111+ color: string;
112+ size: number;
113+}
114+
115+interface Capsule {
116+ x: number; // half-lane units
117+ y: number; // row units (0 = release line, r+1 = after row r)
118+ deep: boolean;
119+ visible: boolean;
120+ alpha: number;
121+ squash: number; // 1 = round
122+ glow: number;
123+}
124+
125+interface Scene {
126+ t: number;
127+ lane: number;
128+ risk: Risk;
129+ values: number[];
130+ phase: "idle" | "dropping";
131+ camY: number; // in row units
132+ capsule: Capsule;
133+ trail: { x: number; y: number; deep: boolean; life: number }[];
134+ gates: { x: number; y: number; value: number; at: number }[];
135+ portals: { x: number; to: number; y: number; at: number }[];
136+ landed: number | null;
137+ landedAt: number;
138+ deepReveal: number;
139+ deepLanded: number | null;
140+ deepLandedAt: number;
141+ particles: Particle[];
142+ flash: { text: string; at: number; color: string } | null;
143+ reduceMotion: boolean;
144+}
145+
146+const MARKER_UNITS = 1.2;
147+const BUCKET_UNITS = 1.5;
148+const DEEP_GAP_UNITS = 0.9;
149+
150+interface Layout {
151+ w: number;
152+ h: number;
153+ pad: number;
154+ hl: number;
155+ rowH: number;
156+ left: number;
157+ deepLeft: number;
158+ deepStart: number; // row units where the deep release line sits
159+ totalDeepUnits: number;
160+}
161+
162+function layoutFor(w: number, h: number, cfg: DropzoneConfig): Layout {
163+ const mainUnits = MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.5;
164+ let hl = (Math.min(w, 620) - 20) / (cfg.lanes * 2);
165+ let rowH = Math.min((h - 20) / mainUnits, hl * 2.1);
166+ if (rowH < hl * 1.05) {
167+ // very short viewport: shrink horizontally to keep pegs readable
168+ hl = rowH / 1.05;
169+ rowH = hl * 1.05;
170+ }
171+ // Centre the tower vertically when the viewport is taller than needed (tall phones).
172+ const pad = Math.max(10, (h - mainUnits * rowH) / 2);
173+ const left = (w - hl * cfg.lanes * 2) / 2;
174+ const deepLeft = left + ((cfg.lanes * 2 - cfg.deepBuckets.length * 2) / 2) * hl;
175+ const deepStart = cfg.rows + BUCKET_UNITS + DEEP_GAP_UNITS;
176+ return { w, h, pad, hl, rowH, left, deepLeft, deepStart, totalDeepUnits: deepStart + cfg.deepRows + BUCKET_UNITS };
177+}
178+
179+function toPx(L: Layout, s: Scene, x: number, y: number, deep: boolean): { px: number; py: number } {
180+ const px = (deep ? L.deepLeft : L.left) + (x + 0.5) * L.hl;
181+ const py = L.pad + (MARKER_UNITS + (deep ? L.deepStart + y : y) - s.camY) * L.rowH;
182+ return { px, py };
183+}
184+
185+function heat(v: number, max: number): number {
186+ if (max <= 0) return 0;
187+ return clamp(Math.log1p(v) / Math.log1p(max), 0, 1);
188+}
189+
190+/* --------------------------------------------------------------- drawing */
191+
192+interface Palette {
193+ primary: string;
194+ secondary: string;
195+ glow: string;
196+ bg: string;
197+ surface: string;
198+}
199+
200+function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: DropzoneConfig, palette: Palette) {
201+ const { w, h, hl, rowH } = L;
202+ ctx.clearRect(0, 0, w, h);
203+ const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`;
204+
205+ // Tower glass backdrop
206+ const towerX = L.left - hl * 0.6;
207+ const towerW = hl * cfg.lanes * 2 + hl * 1.2;
208+ const towerTop = L.pad + (MARKER_UNITS - 0.6 - s.camY) * rowH;
209+ const towerBottom = L.pad + (MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.15 - s.camY) * rowH;
210+ const bg = ctx.createLinearGradient(0, towerTop, 0, towerBottom);
211+ bg.addColorStop(0, "rgba(255,255,255,0.035)");
212+ bg.addColorStop(1, "rgba(255,255,255,0.012)");
213+ ctx.fillStyle = bg;
214+ ctx.beginPath();
215+ ctx.roundRect(towerX, towerTop, towerW, towerBottom - towerTop, 18);
216+ ctx.fill();
217+ ctx.strokeStyle = rgba(palette.primary, 0.18);
218+ ctx.lineWidth = 1;
219+ ctx.stroke();
220+
221+ // Side rails
222+ ctx.strokeStyle = rgba(palette.primary, 0.35);
223+ ctx.lineWidth = 2;
224+ ctx.beginPath();
225+ ctx.moveTo(towerX, towerTop + 14);
226+ ctx.lineTo(towerX, towerBottom - 14);
227+ ctx.moveTo(towerX + towerW, towerTop + 14);
228+ ctx.lineTo(towerX + towerW, towerBottom - 14);
229+ ctx.stroke();
230+
231+ // Pegs (main)
232+ const pegR = Math.max(2, hl * 0.17);
233+ for (let r = 0; r < cfg.rows; r++) {
234+ const parity = r % 2 === 0 ? 1 : 0;
235+ const y = L.pad + (MARKER_UNITS + r + 0.5 - s.camY) * rowH;
236+ if (y < -10 || y > h + 10) continue;
237+ for (let x = parity; x < cfg.lanes * 2; x += 2) {
238+ const px = L.left + (x + 0.5) * hl;
239+ ctx.beginPath();
240+ ctx.arc(px, y, pegR, 0, Math.PI * 2);
241+ ctx.fillStyle = "rgba(255,255,255,0.55)";
242+ ctx.fill();
243+ ctx.beginPath();
244+ ctx.arc(px, y, pegR * 2.2, 0, Math.PI * 2);
245+ ctx.fillStyle = rgba(palette.primary, 0.08);
246+ ctx.fill();
247+ }
248+ }
249+
250+ // Buckets (main)
251+ const maxV = Math.max(...s.values, 1);
252+ const bTop = L.pad + (MARKER_UNITS + cfg.rows + 0.15 - s.camY) * rowH;
253+ const bH = (BUCKET_UNITS - 0.3) * rowH;
254+ for (let k = 0; k < cfg.lanes; k++) {
255+ const x0 = L.left + k * 2 * hl + 1.5;
256+ const bw = 2 * hl - 3;
257+ const v = s.values[k] ?? 0;
258+ const t = heat(v, maxV);
259+ const base = v >= 50 ? "#ffd66b" : mixHex(palette.primary, palette.secondary, t);
260+ const lit = s.landed === k;
261+ const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.landedAt) * 8) : 0;
262+ ctx.beginPath();
263+ ctx.roundRect(x0, bTop, bw, bH, Math.min(6, hl * 0.35));
264+ ctx.fillStyle = lit ? rgba(base.startsWith("#") ? base : palette.glow, 0.55 + 0.35 * pulse) : rgba("#ffffff", 0.05 + t * 0.06);
265+ ctx.fill();
266+ ctx.strokeStyle = lit ? "#ffffff" : base.startsWith("#") ? rgba(base, 0.35 + t * 0.4) : base;
267+ ctx.lineWidth = lit ? 2 : 1;
268+ ctx.stroke();
269+ // Value
270+ const label = v > 0 && v < 0.01 ? "<0.01×" : formatMultiplier(v);
271+ const size = Math.max(8, Math.min(13, hl * 0.72));
272+ ctx.font = font(size, 800);
273+ ctx.textAlign = "center";
274+ ctx.textBaseline = "middle";
275+ ctx.fillStyle = lit ? "#0b1020" : base.startsWith("#") ? base : mixHex(palette.primary, palette.secondary, t);
276+ ctx.fillText(label, x0 + bw / 2, bTop + bH / 2, bw - 2);
277+ }
278+
279+ // Deep section
280+ if (s.deepReveal > 0) {
281+ ctx.save();
282+ ctx.globalAlpha = s.deepReveal;
283+ const dTop = L.pad + (MARKER_UNITS + L.deepStart - 0.6 - s.camY) * rowH;
284+ const dBottom = L.pad + (MARKER_UNITS + L.totalDeepUnits + 0.15 - s.camY) * rowH;
285+ const dX = L.deepLeft - hl * 0.6;
286+ const dW = hl * cfg.deepBuckets.length * 2 + hl * 1.2;
287+ const dg = ctx.createLinearGradient(0, dTop, 0, dBottom);
288+ dg.addColorStop(0, rgba(palette.secondary, 0.12));
289+ dg.addColorStop(1, rgba(palette.secondary, 0.03));
290+ ctx.fillStyle = dg;
291+ ctx.beginPath();
292+ ctx.roundRect(dX, dTop, dW, dBottom - dTop, 18);
293+ ctx.fill();
294+ ctx.strokeStyle = rgba(palette.secondary, 0.4);
295+ ctx.lineWidth = 1.5;
296+ ctx.stroke();
297+ // Funnel between main buckets and deep tower
298+ ctx.strokeStyle = rgba(palette.secondary, 0.5);
299+ ctx.setLineDash([4, 6]);
300+ ctx.beginPath();
301+ ctx.moveTo(towerX + 8, towerBottom);
302+ ctx.lineTo(dX + 8, dTop);
303+ ctx.moveTo(towerX + towerW - 8, towerBottom);
304+ ctx.lineTo(dX + dW - 8, dTop);
305+ ctx.stroke();
306+ ctx.setLineDash([]);
307+ ctx.font = font(11, 800);
308+ ctx.textAlign = "center";
309+ ctx.textBaseline = "middle";
310+ ctx.fillStyle = palette.secondary;
311+ ctx.fillText("DEEP DROP", w / 2, dTop + 14);
312+ for (let r = 0; r < cfg.deepRows; r++) {
313+ const parity = r % 2 === 0 ? 1 : 0;
314+ const y = L.pad + (MARKER_UNITS + L.deepStart + r + 0.5 - s.camY) * rowH;
315+ if (y < -10 || y > h + 10) continue;
316+ for (let x = parity; x < cfg.deepBuckets.length * 2; x += 2) {
317+ const px = L.deepLeft + (x + 0.5) * hl;
318+ ctx.beginPath();
319+ ctx.arc(px, y, pegR, 0, Math.PI * 2);
320+ ctx.fillStyle = rgba(palette.secondary, 0.8);
321+ ctx.fill();
322+ }
323+ }
324+ const dbTop = L.pad + (MARKER_UNITS + L.deepStart + cfg.deepRows + 0.15 - s.camY) * rowH;
325+ for (let k = 0; k < cfg.deepBuckets.length; k++) {
326+ const x0 = L.deepLeft + k * 2 * hl + 1.5;
327+ const bw = 2 * hl - 3;
328+ const v = cfg.deepBuckets[k];
329+ const lit = s.deepLanded === k;
330+ const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.deepLandedAt) * 8) : 0;
331+ const col = v === 0 ? "#ff5c7a" : v >= 10 ? "#ffd66b" : palette.secondary;
332+ ctx.beginPath();
333+ ctx.roundRect(x0, dbTop, bw, bH, Math.min(6, hl * 0.35));
334+ ctx.fillStyle = lit ? rgba(col, 0.55 + 0.35 * pulse) : rgba(col, 0.1);
335+ ctx.fill();
336+ ctx.strokeStyle = lit ? "#ffffff" : rgba(col, 0.5);
337+ ctx.lineWidth = lit ? 2 : 1;
338+ ctx.stroke();
339+ ctx.font = font(Math.max(9, Math.min(13, hl * 0.78)), 800);
340+ ctx.fillStyle = lit ? "#0b1020" : col;
341+ ctx.fillText(`×${v}`, x0 + bw / 2, dbTop + bH / 2, bw - 2);
342+ }
343+ ctx.restore();
344+ }
345+
346+ // Gate rings
347+ for (const g of s.gates) {
348+ const age = s.t - g.at;
349+ const { px, py } = toPx(L, s, g.x, g.y, false);
350+ const k = Math.max(0, 1 - age / 1.6);
351+ ctx.save();
352+ ctx.globalAlpha = 0.35 + 0.65 * k;
353+ ctx.strokeStyle = palette.secondary;
354+ ctx.lineWidth = 2 + 2 * k;
355+ ctx.shadowColor = palette.secondary;
356+ ctx.shadowBlur = 16 * k;
357+ ctx.beginPath();
358+ ctx.ellipse(px, py, hl * (1.05 + 0.4 * Math.min(1, age * 3)), hl * 0.42, 0, 0, Math.PI * 2);
359+ ctx.stroke();
360+ ctx.shadowBlur = 0;
361+ ctx.font = font(Math.max(11, hl * 0.95), 900);
362+ ctx.textAlign = "center";
363+ ctx.textBaseline = "middle";
364+ ctx.fillStyle = "#ffffff";
365+ const rise = Math.min(1, age * 1.4);
366+ ctx.fillText(`×${g.value}`, px, py - hl * 1.1 - rise * hl * 0.8);
367+ ctx.restore();
368+ }
369+
370+ // Portals
371+ for (const p of s.portals) {
372+ const age = s.t - p.at;
373+ const k = Math.max(0, 1 - age / 1.8);
374+ const a = toPx(L, s, p.x, p.y, false);
375+ const b = toPx(L, s, p.to, p.y, false);
376+ ctx.save();
377+ ctx.globalAlpha = 0.25 + 0.75 * k;
378+ ctx.strokeStyle = "#a78bfa";
379+ ctx.lineWidth = 1.5;
380+ ctx.setLineDash([3, 4]);
381+ ctx.beginPath();
382+ ctx.moveTo(a.px, a.py);
383+ ctx.lineTo(b.px, b.py);
384+ ctx.stroke();
385+ ctx.setLineDash([]);
386+ for (const q of [a, b]) {
387+ ctx.beginPath();
388+ ctx.ellipse(q.px, q.py, hl * 0.75, hl * 0.35, 0, 0, Math.PI * 2);
389+ ctx.strokeStyle = "#c4b5fd";
390+ ctx.lineWidth = 2;
391+ ctx.shadowColor = "#a78bfa";
392+ ctx.shadowBlur = 12 * k;
393+ ctx.stroke();
394+ }
395+ ctx.restore();
396+ }
397+
398+ // Trail
399+ for (const tr of s.trail) {
400+ const { px, py } = toPx(L, s, tr.x, tr.y, tr.deep);
401+ ctx.beginPath();
402+ ctx.arc(px, py, hl * 0.32 * tr.life, 0, Math.PI * 2);
403+ ctx.fillStyle = rgba(palette.glow, 0.25 * tr.life);
404+ ctx.fill();
405+ }
406+
407+ // Release marker (idle)
408+ if (s.phase === "idle") {
409+ const { px, py } = toPx(L, s, s.lane * 2 + 1, -0.75, false);
410+ const bob = Math.sin(s.t * 2.2) * 2;
411+ ctx.save();
412+ ctx.strokeStyle = rgba(palette.primary, 0.35);
413+ ctx.setLineDash([2, 6]);
414+ ctx.beginPath();
415+ ctx.moveTo(px, py + hl * 0.6);
416+ ctx.lineTo(px, L.pad + (MARKER_UNITS + cfg.rows - s.camY) * rowH);
417+ ctx.stroke();
418+ ctx.setLineDash([]);
419+ ctx.fillStyle = rgba(palette.primary, 0.9);
420+ ctx.beginPath();
421+ ctx.moveTo(px - hl * 0.7, py - hl * 0.35 + bob);
422+ ctx.lineTo(px + hl * 0.7, py - hl * 0.35 + bob);
423+ ctx.lineTo(px, py + hl * 0.45 + bob);
424+ ctx.closePath();
425+ ctx.fill();
426+ ctx.restore();
427+ }
428+
429+ // Capsule
430+ if (s.capsule.visible && s.capsule.alpha > 0) {
431+ const c = s.capsule;
432+ const { px, py } = toPx(L, s, c.x, c.y, c.deep);
433+ const r = hl * 0.42;
434+ ctx.save();
435+ ctx.globalAlpha = c.alpha;
436+ ctx.translate(px, py);
437+ ctx.scale(1 / Math.sqrt(c.squash), Math.sqrt(c.squash));
438+ ctx.shadowColor = palette.glow;
439+ ctx.shadowBlur = 14 + 22 * c.glow;
440+ const grad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r);
441+ grad.addColorStop(0, "#ffffff");
442+ grad.addColorStop(0.35, palette.glow);
443+ grad.addColorStop(1, palette.primary);
444+ ctx.fillStyle = grad;
445+ ctx.beginPath();
446+ ctx.arc(0, 0, r, 0, Math.PI * 2);
447+ ctx.fill();
448+ ctx.shadowBlur = 0;
449+ ctx.strokeStyle = "rgba(255,255,255,0.75)";
450+ ctx.lineWidth = 1.2;
451+ ctx.stroke();
452+ ctx.restore();
453+ }
454+
455+ // Particles
456+ for (const p of s.particles) {
457+ const k = p.life / p.max;
458+ ctx.globalAlpha = k;
459+ ctx.fillStyle = p.color;
460+ ctx.beginPath();
461+ ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2);
462+ ctx.fill();
463+ }
464+ ctx.globalAlpha = 1;
465+
466+ // Centre flash text (deep multiplier etc.)
467+ if (s.flash) {
468+ const age = s.t - s.flash.at;
469+ if (age < 1.6) {
470+ const k = age < 0.25 ? easeOutBack(age / 0.25) : 1;
471+ const fade = age > 1.1 ? 1 - (age - 1.1) / 0.5 : 1;
472+ ctx.save();
473+ ctx.globalAlpha = Math.max(0, fade);
474+ ctx.translate(w / 2, h * 0.42);
475+ ctx.scale(k, k);
476+ ctx.font = font(Math.min(54, w * 0.13), 900);
477+ ctx.textAlign = "center";
478+ ctx.textBaseline = "middle";
479+ ctx.shadowColor = s.flash.color;
480+ ctx.shadowBlur = 30;
481+ ctx.fillStyle = s.flash.color;
482+ ctx.fillText(s.flash.text, 0, 0);
483+ ctx.restore();
484+ }
485+ }
486+}
487+
488+/* ------------------------------------------------------------- component */
489+
490+const RISKS: { id: Risk; label: string }[] = [
491+ { id: "low", label: "Low" },
492+ { id: "medium", label: "Medium" },
493+ { id: "high", label: "High" },
494+];
495+
496+export function DropzoneGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {
497+ const cfg = definition.config as unknown as DropzoneConfig;
498+ const palette = definition.presentation.palette;
499+ const { play, error, clearError } = useInstantPlay<DropOutcome>(definition.slug);
500+ const [risk, setRisk] = useState<Risk>("medium");
501+ const [lane, setLane] = useState(Math.floor(cfg.lanes / 2));
502+ const [phase, setPhase] = useState<"idle" | "dropping">("idle");
503+ const [live, setLive] = useState<string | null>(null);
504+ const [result, setResult] = useState<{ win: number; multiplier: number; parts: string } | null>(null);
505+ const canvasRef = useRef<HTMLCanvasElement>(null);
506+ const sceneRef = useRef<Scene | null>(null);
507+ const aliveRef = useRef(true);
508+ const phaseRef = useRef<"idle" | "dropping">("idle");
509+ const values = useMemo(() => displayedBuckets(definition, cfg, risk, lane), [definition, cfg, risk, lane]);
510+
511+ const getScene = useCallback((): Scene => {
512+ if (!sceneRef.current) {
513+ const mid = Math.floor(cfg.lanes / 2);
514+ sceneRef.current = {
515+ t: 0,
516+ lane: mid,
517+ risk: "medium",
518+ values: displayedBuckets(definition, cfg, "medium", mid),
519+ phase: "idle",
520+ camY: 0,
521+ capsule: { x: mid * 2 + 1, y: 0, deep: false, visible: false, alpha: 1, squash: 1, glow: 0 },
522+ trail: [],
523+ gates: [],
524+ portals: [],
525+ landed: null,
526+ landedAt: 0,
527+ deepReveal: 0,
528+ deepLanded: null,
529+ deepLandedAt: 0,
530+ particles: [],
531+ flash: null,
532+ reduceMotion: false,
533+ };
534+ }
535+ return sceneRef.current;
536+ }, [definition, cfg]);
537+
538+ /* Render loop */
539+ useEffect(() => {
540+ aliveRef.current = true;
541+ const canvas = canvasRef.current;
542+ if (!canvas) return;
543+ const ctx = canvas.getContext("2d");
544+ if (!ctx) return;
545+ const s = getScene();
546+ let raf = 0;
547+ let last = performance.now();
548+ const loop = (now: number) => {
549+ const dt = Math.min(0.05, (now - last) / 1000);
550+ last = now;
551+ s.t += dt;
552+ const dpr = Math.min(2, window.devicePixelRatio || 1);
553+ const rect = canvas.getBoundingClientRect();
554+ const W = Math.max(1, Math.round(rect.width * dpr));
555+ const H = Math.max(1, Math.round(rect.height * dpr));
556+ if (canvas.width !== W || canvas.height !== H) {
557+ canvas.width = W;
558+ canvas.height = H;
559+ }
560+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
561+ const L = layoutFor(rect.width, rect.height, cfg);
562+ // particles
563+ for (let i = s.particles.length - 1; i >= 0; i--) {
564+ const p = s.particles[i];
565+ p.life -= dt;
566+ p.x += p.vx * dt;
567+ p.y += p.vy * dt;
568+ p.vy += 420 * dt;
569+ p.vx *= 0.98;
570+ if (p.life <= 0) s.particles.splice(i, 1);
571+ }
572+ for (let i = s.trail.length - 1; i >= 0; i--) {
573+ s.trail[i].life -= dt * 3.2;
574+ if (s.trail[i].life <= 0) s.trail.splice(i, 1);
575+ }
576+ if (s.phase === "dropping" && s.capsule.visible && !s.reduceMotion) s.trail.push({ x: s.capsule.x, y: s.capsule.y, deep: s.capsule.deep, life: 1 });
577+ s.capsule.glow = Math.max(0, s.capsule.glow - dt * 1.5);
578+ drawScene(ctx, L, s, cfg, palette);
579+ raf = requestAnimationFrame(loop);
580+ };
581+ raf = requestAnimationFrame(loop);
582+ return () => {
583+ aliveRef.current = false;
584+ cancelAnimationFrame(raf);
585+ };
586+ }, [cfg, palette, getScene]);
587+
588+ /* Sync UI state into the scene */
589+ useEffect(() => {
590+ const s = getScene();
591+ s.lane = lane;
592+ s.risk = risk;
593+ s.values = values;
594+ s.reduceMotion = reduceMotion;
595+ if (s.phase === "idle") {
596+ s.capsule.x = lane * 2 + 1;
597+ }
598+ }, [lane, risk, values, reduceMotion, getScene]);
599+
600+ const laneFromPointer = useCallback(
601+ (e: React.PointerEvent<HTMLCanvasElement>) => {
602+ const rect = e.currentTarget.getBoundingClientRect();
603+ const L = layoutFor(rect.width, rect.height, cfg);
604+ const x = e.clientX - rect.left;
605+ return clamp(Math.floor((x - L.left) / (L.hl * 2)), 0, cfg.lanes - 1);
606+ },
607+ [cfg],
608+ );
609+
610+ const onPointer = useCallback(
611+ (e: React.PointerEvent<HTMLCanvasElement>) => {
612+ if (phaseRef.current !== "idle") return;
613+ if (e.type === "pointermove" && e.buttons === 0) return;
614+ const l = laneFromPointer(e);
615+ setLane((prev) => {
616+ if (prev !== l) sound("tick");
617+ return l;
618+ });
619+ },
620+ [laneFromPointer, sound],
621+ );
622+
623+ const spawnBurst = useCallback((s: Scene, L: Layout, x: number, y: number, deep: boolean, n: number, color: string, speed: number) => {
624+ if (s.reduceMotion) return;
625+ const { px, py } = toPx(L, s, x, y, deep);
626+ for (let i = 0; i < n; i++) {
627+ const a = Math.random() * Math.PI * 2;
628+ const v = speed * (0.4 + Math.random() * 0.8);
629+ s.particles.push({ x: px, y: py, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.4, life: 0.35 + Math.random() * 0.4, max: 0.75, color, size: 1.5 + Math.random() * 2.2 });
630+ }
631+ }, []);
632+
633+ const animate = useCallback(
634+ async (outcome: DropOutcome) => {
635+ const s = getScene();
636+ const canvas = canvasRef.current;
637+ const alive = () => aliveRef.current;
638+ const rm = s.reduceMotion;
639+ const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 600 };
640+ const L = () => layoutFor(rect.width, rect.height, cfg);
641+ const rowMs = rm ? 45 : 150;
642+
643+ // reset
644+ s.phase = "dropping";
645+ s.gates = [];
646+ s.portals = [];
647+ s.landed = null;
648+ s.deepLanded = null;
649+ s.deepReveal = 0;
650+ s.flash = null;
651+ s.particles = [];
652+ s.trail = [];
653+ s.values = outcome.summary.table;
654+ if (s.camY > 0) await tween(rm ? 60 : 320, (t) => (s.camY = lerp(s.camY, 0, easeOutCubic(t))), alive);
655+ s.camY = 0;
656+ const cap = s.capsule;
657+ cap.deep = false;
658+ cap.x = outcome.summary.startLane * 2 + 1;
659+ cap.y = -0.75;
660+ cap.alpha = 1;
661+ cap.squash = 1;
662+ cap.visible = true;
663+ cap.glow = 1;
664+ await tween(rm ? 60 : 220, (t) => (cap.y = lerp(-0.75, 0, easeInQuad(t))), alive);
665+
666+ let gateMult = 1;
667+ const runSteps = async (steps: DropStep[], deep: boolean) => {
668+ for (let i = 0; i < steps.length; i++) {
669+ if (!alive()) return;
670+ const step = steps[i];
671+ const fromX = cap.x;
672+ const toX = step.x;
673+ const r = step.row;
674+ await tween(rowMs, (t) => {
675+ if (t < 0.5) {
676+ cap.y = lerp(r, r + 0.5, easeInQuad(t / 0.5));
677+ cap.squash = 1;
678+ } else {
679+ const k = (t - 0.5) / 0.5;
680+ cap.x = lerp(fromX, toX, easeOutCubic(k));
681+ cap.y = lerp(r + 0.5, r + 1, k);
682+ cap.squash = 1 + 0.45 * Math.sin(k * Math.PI) * (k < 0.5 ? 1 : 0.4);
683+ }
684+ }, alive);
685+ spawnBurst(s, L(), fromX, r + 0.5, deep, 3, "#ffffff", 90);
686+ if (i % 2 === 0) sound("tick");
687+ if (!deep && step.event?.type === "gate") {
688+ gateMult *= step.event.value;
689+ s.gates.push({ x: toX, y: r + 1, value: step.event.value, at: s.t });
690+ cap.glow = 1;
691+ spawnBurst(s, L(), toX, r + 1, false, 14, palette.secondary, 160);
692+ sound("bonus");
693+ setLive(`Gate ×${step.event.value} · total ×${gateMult}`);
694+ await wait(rm ? 80 : 260);
695+ } else if (!deep && step.event?.type === "portal") {
696+ const to = step.event.to;
697+ s.portals.push({ x: toX, to, y: r + 1, at: s.t });
698+ sound("bonus");
699+ setLive(`Portal → lane ${Math.floor(to / 2) + 1}`);
700+ await tween(rm ? 40 : 160, (t) => (cap.alpha = 1 - t), alive);
701+ spawnBurst(s, L(), toX, r + 1, false, 10, "#a78bfa", 120);
702+ cap.x = to;
703+ spawnBurst(s, L(), to, r + 1, false, 10, "#a78bfa", 120);
704+ await tween(rm ? 40 : 160, (t) => (cap.alpha = t), alive);
705+ cap.alpha = 1;
706+ }
707+ }
708+ };
709+
710+ await runSteps(outcome.steps, false);
711+ if (!alive()) return;
712+
713+ // Landing in the main bucket
714+ const bucket = outcome.summary.bucket;
715+ const fromX = cap.x;
716+ await tween(rm ? 60 : 260, (t) => {
717+ cap.x = lerp(fromX, bucket * 2 + 1, easeOutCubic(t));
718+ cap.y = lerp(cfg.rows, cfg.rows + 0.85, easeOutBack(t));
719+ cap.squash = 1 + 0.3 * Math.sin(t * Math.PI);
720+ }, alive);
721+ s.landed = bucket;
722+ s.landedAt = s.t;
723+ cap.glow = 1;
724+ spawnBurst(s, L(), bucket * 2 + 1, cfg.rows + 0.85, false, 18, palette.glow, 180);
725+ const bucketValue = outcome.summary.bucketValue;
726+ setLive(gateMult > 1 ? `${formatMultiplier(bucketValue)} bucket × gates ×${gateMult}` : `${formatMultiplier(bucketValue)} bucket`);
727+ sound(bucketValue * gateMult >= 1 ? "win" : "tick");
728+
729+ // Deep Drop
730+ const deep = outcome.summary.deep;
731+ if (deep) {
732+ await wait(rm ? 80 : 420);
733+ sound("bonus");
734+ s.flash = { text: "DEEP DROP", at: s.t, color: palette.secondary };
735+ const Ld = L();
736+ const visibleUnits = (Ld.h - Ld.pad * 2) / Ld.rowH - MARKER_UNITS;
737+ const targetCam = Math.max(0, Ld.totalDeepUnits + 0.4 - visibleUnits);
738+ const deepStartX = clamp(bucket * 2 + 1, 0, cfg.deepBuckets.length * 2 - 1);
739+ await tween(rm ? 120 : 800, (t) => {
740+ const k = easeOutCubic(t);
741+ s.deepReveal = Math.min(1, t * 1.6);
742+ s.camY = targetCam * k;
743+ }, alive);
744+ // capsule falls through the bucket floor into the deep release line
745+ await tween(rm ? 60 : 360, (t) => {
746+ cap.alpha = t < 0.5 ? 1 - t * 2 : (t - 0.5) * 2;
747+ if (t >= 0.5 && !cap.deep) {
748+ cap.deep = true;
749+ cap.x = deepStartX;
750+ cap.y = -0.6;
751+ }
752+ if (t >= 0.5) cap.y = lerp(-0.6, 0, (t - 0.5) * 2);
753+ }, alive);
754+ cap.alpha = 1;
755+ cap.deep = true;
756+ cap.x = deepStartX;
757+ cap.y = 0;
758+ await runSteps(deep.steps, true);
759+ if (!alive()) return;
760+ const fx = cap.x;
761+ await tween(rm ? 60 : 260, (t) => {
762+ cap.x = lerp(fx, deep.bucket * 2 + 1, easeOutCubic(t));
763+ cap.y = lerp(cfg.deepRows, cfg.deepRows + 0.85, easeOutBack(t));
764+ cap.squash = 1 + 0.3 * Math.sin(t * Math.PI);
765+ }, alive);
766+ s.deepLanded = deep.bucket;
767+ s.deepLandedAt = s.t;
768+ const col = deep.value === 0 ? "#ff5c7a" : deep.value >= 10 ? "#ffd66b" : palette.secondary;
769+ s.flash = { text: `×${deep.value}`, at: s.t, color: col };
770+ spawnBurst(s, L(), deep.bucket * 2 + 1, cfg.deepRows + 0.85, true, 24, col, 220);
771+ setLive(deep.value === 0 ? "Deep bucket ×0 — vanished" : `Deep bucket ×${deep.value}`);
772+ await wait(rm ? 100 : 500);
773+ }
774+
775+ if (!alive()) return;
776+ const parts = gateMult > 1 || deep ? [`${formatMultiplier(bucketValue)} bucket`, gateMult > 1 ? `×${gateMult} gates` : null, deep ? `×${deep.value} deep` : null].filter(Boolean).join(" · ") : `Bucket ${bucket + 1}`;
777+ setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, parts });
778+ if (outcome.totalWin <= 0) sound("lose");
779+ else if (outcome.multiplier >= 15) sound("bigWin");
780+ else sound("win");
781+ onResult({ win: outcome.totalWin, multiplier: outcome.multiplier });
782+ s.phase = "idle";
783+ },
784+ [cfg, palette, getScene, sound, onResult, spawnBurst],
785+ );
786+
787+ const drop = useCallback(async () => {
788+ if (phaseRef.current !== "idle") return;
789+ phaseRef.current = "dropping";
790+ setPhase("dropping");
791+ setResult(null);
792+ setLive(null);
793+ onBusy(true);
794+ sound("click");
795+ const res = await play(bet, { risk, lane });
796+ if (res && aliveRef.current) {
797+ await animate(res.outcome);
798+ }
799+ phaseRef.current = "idle";
800+ if (aliveRef.current) {
801+ setPhase("idle");
802+ const s = sceneRef.current;
803+ if (s) s.phase = "idle";
804+ }
805+ onBusy(false);
806+ }, [play, bet, risk, lane, animate, onBusy, sound]);
807+
808+ const dropping = phase === "dropping";
809+ const maxValue = Math.max(...values);
810+
811+ return (
812+ <div className="absolute inset-0 flex flex-col">
813+ {/* Risk selector */}
814+ <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2">
815+ <div role="radiogroup" aria-label="Risk profile" className="flex h-11 flex-1 max-w-[330px] rounded-md p-1 surface-2">
816+ {RISKS.map((r) => {
817+ const active = risk === r.id;
818+ return (
819+ <button
820+ key={r.id}
821+ role="radio"
822+ aria-checked={active}
823+ disabled={dropping}
824+ onClick={() => {
825+ if (risk !== r.id) sound("click");
826+ setRisk(r.id);
827+ }}
828+ className={cn("flex-1 rounded-[8px] text-[13px] font-semibold transition-all focus-ring disabled:opacity-60", active ? "text-[#061018]" : "text-fg-2 hover:text-fg")}
829+ style={active ? { background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary})`, boxShadow: `0 6px 20px -8px ${palette.primary}` } : undefined}
830+ >
831+ {r.label}
832+ </button>
833+ );
834+ })}
835+ </div>
836+ <div className="text-right leading-tight">
837+ <div className="eyebrow">Top bucket</div>
838+ <div className="text-sm font-bold tabular" style={{ color: palette.glow }}>
839+ {formatMultiplier(maxValue)}
840+ </div>
841+ </div>
842+ </div>
843+
844+ {/* Tower */}
845+ <div className="relative min-h-0 flex-1">
846+ <canvas
847+ ref={canvasRef}
848+ className={cn("absolute inset-0 h-full w-full touch-none", dropping ? "cursor-default" : "cursor-pointer")}
849+ onPointerDown={onPointer}
850+ onPointerMove={onPointer}
851+ aria-label="Drop tower. Tap or drag to choose the release lane."
852+ role="img"
853+ />
854+ <AnimatePresence>
855+ {live && dropping ? (
856+ <motion.div key={live} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: rgba(palette.secondary, 0.18), color: palette.secondary }}>
857+ {live}
858+ </motion.div>
859+ ) : null}
860+ {result && !dropping ? (
861+ <motion.div key="result" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-2 flex -translate-x-1/2 flex-col items-center glass rounded-lg px-4 py-1.5 text-center leading-tight">
862+ <span className={cn("whitespace-nowrap text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No win this drop"}</span>
863+ <span className="whitespace-nowrap text-[11px] text-fg-3">{result.parts}</span>
864+ </motion.div>
865+ ) : null}
866+ {error ? (
867+ <motion.div key="err" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 mx-auto max-w-sm glass rounded-md p-3 text-center text-sm">
868+ <div className="text-fg-2">{error}</div>
869+ <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring">
870+ Dismiss
871+ </button>
872+ </motion.div>
873+ ) : null}
874+ </AnimatePresence>
875+ </div>
876+
877+ {/* Lane + action */}
878+ <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2">
879+ <div className="flex items-center gap-1">
880+ <button disabled={dropping || lane <= 0} onClick={() => { sound("tick"); setLane((l) => Math.max(0, l - 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane left">
881+ <ChevronLeft className="h-4 w-4" />
882+ </button>
883+ <div className="flex h-11 min-w-[72px] flex-col items-center justify-center rounded-md surface-2 px-2">
884+ <span className="text-[10px] uppercase tracking-wider text-fg-3">Lane</span>
885+ <span className="text-sm font-bold tabular">{lane + 1} / {cfg.lanes}</span>
886+ </div>
887+ <button disabled={dropping || lane >= cfg.lanes - 1} onClick={() => { sound("tick"); setLane((l) => Math.min(cfg.lanes - 1, l + 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane right">
888+ <ChevronRight className="h-4 w-4" />
889+ </button>
890+ </div>
891+ <button
892+ onClick={() => void drop()}
893+ disabled={dropping}
894+ className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#061018] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring"
895+ style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 55%, ${mixHex(palette.primary, "#000000", 0.25)})`, boxShadow: `0 0 0 4px ${rgba(palette.primary, 0.18)}, 0 18px 50px -14px ${palette.primary}` }}
896+ aria-label={definition.presentation.verb}
897+ >
898+ {dropping ? "DROPPING…" : `${definition.presentation.verb} · ${formatSC(bet)}`}
899+ </button>
900+ </div>
901+ </div>
902+ );
903+}
added apps/web/src/components/arcade/escape.tsx +538 −0
@@ -0,0 +1,538 @@
1+"use client";
2+
3+/**
4+ * Escape 99 — Spinza Original (ladder). A fast roguelike tower: 99 floors,
5+ * room by room, cash out on any of them. The tower strip scrolls as the player
6+ * climbs; every CLIMB animates the room resolution returned by the server.
7+ */
8+import { useCallback, useEffect, useRef, useState } from "react";
9+import { AnimatePresence, motion } from "framer-motion";
10+import { ArrowUp, Crown, DoorOpen, Flag, Flame, Gem, Orbit, RotateCcw, Shield, ShieldCheck, Skull, Sparkles, Sword, TriangleAlert, Zap, type LucideIcon } from "lucide-react";
11+import { formatMultiplier, formatSC } from "@spinza/shared";
12+import type { LadderEvent, LadderOffer } from "@spinza/game-core/client";
13+import { cn } from "@/lib/utils";
14+import { Button } from "@/components/ui";
15+import { useLadder, type ArcadeGameProps, type LadderView } from "./contract";
16+
17+interface EscapeConfig {
18+ floors: number;
19+ bands: [number, number][];
20+ checkpoints: number[];
21+ rooms: { kind: string; label: string; weight: number; detail: string }[];
22+ forkChance: number;
23+ portalChance: number;
24+}
25+
26+/** Room resolution being animated from the returned log tail. */
27+interface Resolution {
28+ ev: LadderEvent;
29+ from: number;
30+ step: "resolve" | "result";
31+}
32+
33+type Phase = "idle" | "resolve" | "running" | "cashed" | "busted" | "completed";
34+
35+const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
36+const ROW = 22;
37+const DANGER = "#ff5c7a";
38+
39+const ICONS: Record<string, LucideIcon> = { chest: Gem, enemy: Sword, trap: TriangleAlert, multiplier: Sparkles, room: DoorOpen, portal: Orbit, "fork-safe": Shield, "fork-risky": Flame, start: DoorOpen, summit: Crown };
40+const SUCCESS: Record<string, string> = { chest: "Chest opened", enemy: "Guardian defeated", trap: "Trap disarmed", multiplier: "Rune charged", room: "Stairs climbed", portal: "Warped three floors", "fork-safe": "Corridor cleared", "fork-risky": "Corridor cleared", start: "You are in" };
41+
42+function lastStep(log: LadderEvent[]): LadderEvent | undefined {
43+ for (let i = log.length - 1; i >= 0; i--) if (log[i].outcome === "ok" || log[i].outcome === "bust") return log[i];
44+ return undefined;
45+}
46+
47+function pickSafe(offers: LadderOffer[]): LadderOffer | undefined {
48+ return offers.find((o) => o.kind === "fork-safe") ?? offers[0];
49+}
50+
51+export function EscapeGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {
52+ const cfg = definition.config as unknown as EscapeConfig;
53+ const palette = definition.presentation.palette;
54+ const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug);
55+ const [anim, setAnim] = useState<Resolution | null>(null);
56+ const [selected, setSelected] = useState<string | null>(null);
57+ const [flourish, setFlourish] = useState<number | null>(null);
58+ const [summit, setSummit] = useState(false);
59+ const [auto, setAuto] = useState(false);
60+ const autoRef = useRef(false);
61+ const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]);
62+ const sec = (ms: number) => dur(ms) / 1000;
63+
64+ /* ------------------------------------------------------------ derived */
65+ const phase: Phase = anim ? "resolve" : !session ? "idle" : session.status;
66+ const running = session?.status === "running";
67+ const locked = busy || anim !== null;
68+ const floor = anim ? (anim.step === "resolve" || anim.ev.outcome === "bust" ? anim.from : anim.ev.stage) : (session?.stage ?? 0);
69+ const runBet = session?.bet ?? bet;
70+ const current = session?.current ?? 0;
71+ const offers = session?.offers ?? [];
72+ const selectedId = offers.some((o) => o.id === selected) ? selected : offers[0]?.id;
73+ const checkpointsHit: number[] = Array.isArray(session?.extra.checkpointsHit) ? (session.extra.checkpointsHit as unknown[]).filter((x): x is number => typeof x === "number") : [];
74+ const bustEvent = phase === "busted" && session ? lastStep(session.log) : undefined;
75+ const ended = phase === "cashed" || phase === "busted" || phase === "completed";
76+
77+ useEffect(() => {
78+ if (running) onBusy(true);
79+ }, [running, onBusy]);
80+
81+ /* ------------------------------------------------------------ sequence */
82+ const stopAuto = useCallback(() => {
83+ autoRef.current = false;
84+ setAuto(false);
85+ }, []);
86+
87+ const finish = useCallback(
88+ (s: LadderView) => {
89+ stopAuto();
90+ onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 });
91+ onBusy(false);
92+ },
93+ [stopAuto, onResult, onBusy],
94+ );
95+
96+ /** Animate the last step of `s` (climbed from floor `from`); settle end states. Returns true when auto-climb should continue. */
97+ const animateStep = useCallback(
98+ async (s: LadderView, from: number): Promise<boolean> => {
99+ const ev = lastStep(s.log);
100+ if (ev) {
101+ setAnim({ ev, from, step: "resolve" });
102+ sound("tick");
103+ await wait(dur(ev.outcome === "bust" ? 520 : 420));
104+ const checkpoint = ev.data?.checkpoint === true;
105+ if (ev.outcome === "bust") {
106+ sound("lose");
107+ setAnim({ ev, from, step: "result" });
108+ await wait(dur(1100));
109+ } else {
110+ setAnim({ ev, from, step: "result" });
111+ if (checkpoint) {
112+ sound("bonus");
113+ setFlourish(ev.stage);
114+ setTimeout(() => setFlourish(null), dur(1500));
115+ } else sound("tick");
116+ await wait(dur(checkpoint ? 700 : 380));
117+ }
118+ setAnim(null);
119+ if (checkpoint && s.status === "running") stopAuto();
120+ }
121+ if (s.status === "completed") {
122+ await wait(dur(300));
123+ setSummit(true);
124+ sound("bigWin");
125+ }
126+ if (s.status !== "running") {
127+ finish(s);
128+ return false;
129+ }
130+ if (!autoRef.current) return false;
131+ await wait(dur(650));
132+ return autoRef.current;
133+ },
134+ [dur, sound, stopAuto, finish],
135+ );
136+
137+ /** Animate `first`, then keep climbing the safe path while auto-climb is on (stops at checkpoints / end states). */
138+ const runFrom = useCallback(
139+ async (first: LadderView, from: number) => {
140+ let s = first;
141+ let prev = from;
142+ for (;;) {
143+ const cont = await animateStep(s, prev);
144+ if (!cont) return;
145+ prev = s.stage;
146+ const res = await act({ type: "continue", offerId: pickSafe(s.offers)?.id });
147+ if (!res) {
148+ stopAuto();
149+ return;
150+ }
151+ s = res.session;
152+ }
153+ },
154+ [animateStep, act, stopAuto],
155+ );
156+
157+ const climb = async (offerId: string | undefined) => {
158+ if (!session) return;
159+ const res = await act({ type: "continue", offerId });
160+ if (!res) {
161+ stopAuto();
162+ return;
163+ }
164+ await runFrom(res.session, session.stage);
165+ };
166+
167+ const onStart = async () => {
168+ if (locked) return;
169+ sound("click");
170+ setSummit(false);
171+ if (session && session.status !== "running") reset();
172+ onBusy(true);
173+ const res = await start(bet);
174+ if (!res) {
175+ onBusy(false);
176+ return;
177+ }
178+ await runFrom(res.session, 0);
179+ };
180+
181+ const onClimb = async () => {
182+ if (!session || locked) return;
183+ sound("click");
184+ await climb(selectedId ?? undefined);
185+ };
186+
187+ const onCashout = async () => {
188+ if (!session || locked) return;
189+ sound("click");
190+ stopAuto();
191+ const res = await act({ type: "cashout" });
192+ if (!res) return;
193+ sound("win");
194+ finish(res.session);
195+ };
196+
197+ const onToggleAuto = () => {
198+ sound("click");
199+ const next = !auto;
200+ autoRef.current = next;
201+ setAuto(next);
202+ if (next && session && !locked) void climb(pickSafe(offers)?.id);
203+ };
204+
205+ const onNewRun = () => {
206+ sound("click");
207+ setSummit(false);
208+ reset();
209+ };
210+
211+ /* -------------------------------------------------------------- render */
212+ const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff";
213+ const isFork = offers.length > 1;
214+
215+ return (
216+ <div className="absolute inset-0 flex flex-col overflow-hidden" data-scene="escape">
217+ <div className="pointer-events-none absolute inset-0" style={{ background: `radial-gradient(70% 50% at 50% 100%, ${palette.surface} 0%, transparent 70%), radial-gradient(60% 40% at 50% 0%, ${palette.primary}14 0%, transparent 70%)` }} />
218+ {/* Bust flash */}
219+ <AnimatePresence>
220+ {phase === "busted" || (anim && anim.ev.outcome === "bust" && anim.step === "result") ? (
221+ <motion.div key="flash" className="pointer-events-none absolute inset-0 z-[3]" style={{ background: `radial-gradient(70% 60% at 50% 50%, ${DANGER}44 0%, transparent 100%)` }} initial={{ opacity: 0 }} animate={{ opacity: reduceMotion ? [0, 0.7, 0] : [0, 1, 0.2, 0.7, 0] }} exit={{ opacity: 0 }} transition={{ duration: sec(1100) }} />
222+ ) : null}
223+ </AnimatePresence>
224+ {/* Summit: the tower opens to the sky */}
225+ <AnimatePresence>
226+ {summit ? (
227+ <motion.div key="sky" className="pointer-events-none absolute inset-0 z-[3]" initial={{ opacity: 0, y: "60%" }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: sec(1600), ease: [0.16, 1, 0.3, 1] }} style={{ background: `linear-gradient(180deg, #e9fff5 0%, ${palette.glow}aa 22%, ${palette.primary}44 55%, transparent 100%)`, mixBlendMode: "screen" }} />
228+ ) : null}
229+ </AnimatePresence>
230+
231+ <div className="relative z-[2] mx-auto flex h-full w-full max-w-3xl flex-col px-3 pb-2 pt-1">
232+ {/* Stats */}
233+ <div className="flex items-end justify-between gap-3">
234+ <div>
235+ <div className="eyebrow">{phase === "busted" ? "Run over" : phase === "cashed" ? "Exited" : phase === "completed" ? "Summit" : "Multiplier"}</div>
236+ <div className="flex items-baseline gap-2">
237+ <motion.div key={`${phase}-${current}`} initial={{ opacity: 0.4, y: 4 }} animate={{ opacity: 1, y: 0 }} className="text-[clamp(34px,9vw,52px)] font-extrabold leading-none tabular tracking-tight" style={{ color: multiplierColor, textShadow: phase === "running" || phase === "resolve" ? `0 0 28px ${palette.glow}88` : undefined }}>
238+ {phase === "idle" ? "—" : formatMultiplier(current)}
239+ </motion.div>
240+ </div>
241+ </div>
242+ <div className="text-center">
243+ <div className="eyebrow">Floor</div>
244+ <div className="text-2xl font-extrabold leading-none tabular">
245+ <motion.span key={floor} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="inline-block" style={{ color: phase === "busted" ? DANGER : palette.glow }}>
246+ {Math.max(1, floor)}
247+ </motion.span>
248+ <span className="text-sm text-fg-3">/{cfg.floors}</span>
249+ </div>
250+ </div>
251+ <div className="text-right">
252+ <div className="eyebrow">{phase === "busted" ? "Lost" : ended ? "Win" : "Potential win"}</div>
253+ <div className={cn("text-lg font-bold tabular sm:text-xl", phase === "busted" ? "text-danger" : "text-credit")}>{phase === "idle" ? formatSC(bet) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}</div>
254+ <div className="text-[11px] text-fg-3 tabular">Bet {formatSC(runBet)}</div>
255+ </div>
256+ </div>
257+
258+ {/* Tower + room */}
259+ <div className="mt-2 grid min-h-0 flex-1 grid-cols-[64px_1fr] gap-3 sm:grid-cols-[88px_1fr]">
260+ <Tower cfg={cfg} floor={Math.max(1, floor)} busted={phase === "busted"} checkpointsHit={checkpointsHit} palette={palette} sec={sec} />
261+
262+ <div className="relative flex min-h-0 flex-col justify-center">
263+ <AnimatePresence mode="wait">
264+ {phase === "idle" ? (
265+ <motion.div key="idle" initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.94 }} className="flex flex-col items-center gap-3 text-center">
266+ <button
267+ onClick={() => void onStart()}
268+ disabled={locked}
269+ className="grid h-[112px] w-[112px] place-items-center rounded-full text-[17px] font-extrabold tracking-[0.14em] transition-transform active:scale-95 focus-ring disabled:opacity-60"
270+ style={{ background: `radial-gradient(circle at 50% 30%, #e9fff5 0%, ${palette.glow} 18%, ${palette.primary} 60%, #0f6b4a 100%)`, boxShadow: `0 0 0 8px ${palette.primary}2a, 0 0 0 9px ${palette.primary}55, 0 18px 60px -12px ${palette.primary}`, color: "#04140d" }}
271+ aria-label="Start run"
272+ >
273+ START
274+ </button>
275+ <span className="rounded-full bg-black/50 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-fg-2 tabular">Bet {formatSC(bet)}</span>
276+ <p className="max-w-[260px] text-[12px] text-fg-3">{definition.tagline} You enter floor 1 automatically.</p>
277+ </motion.div>
278+ ) : anim ? (
279+ <ResolveCard key={`res-${anim.ev.stage}-${anim.ev.kind}`} res={anim} palette={palette} reduceMotion={reduceMotion} />
280+ ) : phase === "running" ? (
281+ <motion.div key={`offers-${session?.stage}`} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: sec(260) }} className="flex flex-col gap-2">
282+ <div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-fg-3">
283+ <span>{isFork ? "Fork — choose a corridor" : offers[0]?.kind === "portal" ? "Portal detected" : `Floor ${floor + 1}`}</span>
284+ <span className="tabular">Next</span>
285+ </div>
286+ <div className={cn("grid gap-2", isFork ? "grid-cols-2" : "grid-cols-1")}>
287+ {offers.map((o) => (
288+ <OfferCard key={o.id} offer={o} bet={runBet} selected={selectedId === o.id} fork={isFork} onSelect={() => setSelected(o.id)} palette={palette} />
289+ ))}
290+ </div>
291+ </motion.div>
292+ ) : phase === "busted" && bustEvent ? (
293+ <motion.div key="bust" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="surface rounded-lg p-4 text-center" style={{ borderColor: `${DANGER}66` }}>
294+ <Skull className="mx-auto h-10 w-10" style={{ color: DANGER }} />
295+ <div className="mt-2 text-xl font-extrabold uppercase tracking-tight" style={{ color: DANGER }}>
296+ {bustEvent.label}
297+ </div>
298+ {bustEvent.detail ? <div className="text-[12px] text-fg-3">{bustEvent.detail}</div> : null}
299+ <div className="mt-2 text-[12px] text-fg-2">
300+ The run ended on floor {session?.stage ?? 0}. Bet lost: <span className="tabular text-danger">{formatSC(runBet)}</span>
301+ </div>
302+ </motion.div>
303+ ) : phase === "cashed" ? (
304+ <motion.div key="cashed" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="surface rounded-lg p-4 text-center">
305+ <ShieldCheck className="mx-auto h-10 w-10" style={{ color: palette.glow }} />
306+ <div className="mt-2 text-[12px] font-extrabold uppercase tracking-[0.22em]" style={{ color: palette.glow }}>
307+ Exited at floor {session?.stage}
308+ </div>
309+ <div className="text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>
310+ </motion.div>
311+ ) : phase === "completed" ? (
312+ <motion.div key="summit" initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} transition={{ type: "spring", stiffness: 200, damping: 16 }} className="text-center">
313+ <Crown className="mx-auto h-12 w-12" style={{ color: palette.glow, filter: `drop-shadow(0 0 18px ${palette.glow})` }} />
314+ <div className="mt-2 text-[clamp(20px,6vw,30px)] font-extrabold uppercase tracking-tight shimmer-text">Floor 99</div>
315+ <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-2">The tower opens to the sky</div>
316+ <div className="mt-2 text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>
317+ </motion.div>
318+ ) : null}
319+ </AnimatePresence>
320+
321+ {/* Checkpoint flourish */}
322+ <AnimatePresence>
323+ {flourish !== null ? (
324+ <motion.div key={`cp-${flourish}`} className="pointer-events-none absolute inset-x-0 top-0 flex justify-center" initial={{ opacity: 0, y: 14, scale: 0.8 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -10 }} transition={{ type: "spring", stiffness: 260, damping: 18 }}>
325+ <div className="flex items-center gap-2 rounded-full px-4 py-1.5 text-[12px] font-extrabold uppercase tracking-[0.2em]" style={{ background: `${palette.primary}22`, border: `1px solid ${palette.primary}88`, color: palette.glow, boxShadow: `0 0 30px -6px ${palette.glow}` }}>
326+ <Flag className="h-4 w-4" /> Checkpoint {flourish}
327+ </div>
328+ </motion.div>
329+ ) : null}
330+ </AnimatePresence>
331+ </div>
332+ </div>
333+
334+ {/* Controls */}
335+ <div className="mt-2 min-h-[72px]">
336+ <AnimatePresence mode="wait">
337+ {phase === "running" || phase === "resolve" ? (
338+ <motion.div key="run" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="flex flex-col gap-2">
339+ <div className="grid grid-cols-2 gap-2">
340+ <button onClick={() => void onCashout()} disabled={locked || !session?.canCashout} className="tap flex h-14 flex-col items-center justify-center rounded-md border text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ borderColor: `${palette.primary}88`, color: palette.glow, background: `${palette.primary}14` }}>
341+ {definition.presentation.secondaryVerb ?? "CASH OUT"}
342+ <span className="text-[11px] font-semibold normal-case tracking-normal text-fg-2 tabular">+{formatSC(Math.round(runBet * current))}</span>
343+ </button>
344+ <button onClick={() => void onClimb()} disabled={locked || offers.length === 0} className="tap flex h-14 flex-col items-center justify-center rounded-md text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #0f6b4a)`, color: "#04140d", boxShadow: `0 10px 40px -12px ${palette.primary}` }}>
345+ <span className="inline-flex items-center gap-1">
346+ <ArrowUp className="h-4 w-4" /> {definition.presentation.verb}
347+ </span>
348+ {(() => {
349+ const o = offers.find((x) => x.id === selectedId);
350+ return o ? (
351+ <span className="text-[11px] font-semibold normal-case tracking-normal tabular" style={{ color: "#0a3d2a" }}>
352+ {Math.round(o.survival * 100)}% safe · {formatMultiplier(o.next)}
353+ </span>
354+ ) : null;
355+ })()}
356+ </button>
357+ </div>
358+ <button role="switch" aria-checked={auto} onClick={onToggleAuto} className="flex h-9 items-center justify-center gap-2 rounded-md text-[12px] font-semibold text-fg-2 focus-ring">
359+ <span className={cn("relative h-5 w-9 rounded-full border transition-colors", auto ? "border-transparent" : "bg-surface-3 border-line-2")} style={auto ? { background: palette.primary } : undefined}>
360+ <span className={cn("absolute top-0.5 h-[14px] w-[14px] rounded-full bg-white transition-transform", auto ? "translate-x-[18px]" : "translate-x-0.5")} />
361+ </span>
362+ <Zap className="h-3.5 w-3.5" style={{ color: auto ? palette.glow : undefined }} />
363+ Auto-climb to next checkpoint (safe path)
364+ </button>
365+ </motion.div>
366+ ) : ended ? (
367+ <motion.div key="end" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-[1fr_auto] gap-2">
368+ <button onClick={() => void onStart()} disabled={locked} className="tap flex h-14 items-center justify-center gap-2 rounded-md text-[14px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: phase === "busted" ? `linear-gradient(180deg, #ff8aa0, ${DANGER})` : `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #0f6b4a)`, color: "#04140d", boxShadow: `0 10px 40px -12px ${phase === "busted" ? DANGER : palette.primary}` }}>
369+ <RotateCcw className="h-4 w-4" /> {phase === "busted" ? "Try again" : "Climb again"}
370+ <span className="text-[11px] font-semibold normal-case tracking-normal tabular opacity-70">{formatSC(bet)}</span>
371+ </button>
372+ <Button variant="secondary" size="lg" className="h-14" onClick={onNewRun}>
373+ New run
374+ </Button>
375+ </motion.div>
376+ ) : (
377+ <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex h-14 items-center justify-center gap-3 text-[11px] uppercase tracking-wider text-fg-3">
378+ {cfg.checkpoints.map((c) => (
379+ <span key={c} className="inline-flex items-center gap-1">
380+ <Flag className="h-3 w-3" style={{ color: palette.primary }} /> {c}
381+ </span>
382+ ))}
383+ </motion.div>
384+ )}
385+ </AnimatePresence>
386+ </div>
387+ </div>
388+
389+ <AnimatePresence>
390+ {error ? (
391+ <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 z-[50] mx-auto max-w-sm glass rounded-md p-3 text-center text-sm">
392+ <div className="text-fg-2">{error}</div>
393+ <div className="mt-2 flex justify-center gap-2">
394+ <Button size="sm" variant="secondary" onClick={clearError}>
395+ Dismiss
396+ </Button>
397+ <Button size="sm" variant="accent" href="/rewards">
398+ Get rewards
399+ </Button>
400+ </div>
401+ </motion.div>
402+ ) : null}
403+ </AnimatePresence>
404+ </div>
405+ );
406+}
407+
408+/* ------------------------------------------------------------------ parts */
409+
410+type Palette = ArcadeGameProps["definition"]["presentation"]["palette"];
411+
412+function Tower({ cfg, floor, busted, checkpointsHit, palette, sec }: { cfg: EscapeConfig; floor: number; busted: boolean; checkpointsHit: number[]; palette: Palette; sec: (ms: number) => number }) {
413+ const floors = Array.from({ length: cfg.floors }, (_, i) => cfg.floors - i); // 99 … 1
414+ const index = cfg.floors - floor;
415+ return (
416+ <div className="relative h-full min-h-[180px] overflow-hidden rounded-lg" style={{ background: `linear-gradient(180deg, ${palette.surface} 0%, #050807 100%)`, border: `1px solid ${palette.primary}33`, boxShadow: "inset 0 0 40px -10px #000" }} aria-label={`Tower, floor ${floor}`}>
417+ {/* rails */}
418+ <div className="pointer-events-none absolute inset-y-0 left-2 w-px" style={{ background: `${palette.primary}33` }} />
419+ <div className="pointer-events-none absolute inset-y-0 right-2 w-px" style={{ background: `${palette.primary}33` }} />
420+ <motion.ol className="absolute inset-x-0 top-1/2" initial={false} animate={{ y: -(index * ROW + ROW / 2) }} transition={{ type: "spring", stiffness: 140, damping: 22, duration: sec(500) }}>
421+ {floors.map((f) => {
422+ const cp = cfg.checkpoints.includes(f);
423+ const cur = f === floor;
424+ const done = f < floor;
425+ const hit = checkpointsHit.includes(f);
426+ return (
427+ <li key={f} className="relative flex items-center justify-center" style={{ height: ROW }}>
428+ <span className="absolute inset-x-3 top-1/2 h-px" style={{ background: cur ? (busted ? DANGER : palette.glow) : done ? `${palette.primary}55` : "rgba(255,255,255,0.07)" }} />
429+ <span className={cn("relative z-[1] rounded px-1.5 text-[11px] font-bold tabular leading-none", cur ? "text-black" : done ? "" : "text-fg-4")} style={cur ? { background: busted ? DANGER : palette.glow, boxShadow: `0 0 16px ${busted ? DANGER : palette.glow}` } : done ? { color: palette.primary, background: "#050807" } : { background: "#050807" }}>
430+ {f}
431+ </span>
432+ {cp ? <Flag className="absolute right-3 top-1/2 z-[1] h-3 w-3 -translate-y-1/2" style={{ color: hit ? palette.glow : cur ? palette.glow : palette.secondary, filter: hit ? `drop-shadow(0 0 6px ${palette.glow})` : undefined }} /> : null}
433+ </li>
434+ );
435+ })}
436+ </motion.ol>
437+ <div className="pointer-events-none absolute inset-x-0 top-0 h-10" style={{ background: `linear-gradient(180deg, ${palette.surface}, transparent)` }} />
438+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10" style={{ background: "linear-gradient(0deg, #050807, transparent)" }} />
439+ {/* current-floor marker */}
440+ <span className="pointer-events-none absolute left-0 top-1/2 h-[2px] w-2 -translate-y-1/2" style={{ background: busted ? DANGER : palette.glow }} />
441+ <span className="pointer-events-none absolute right-0 top-1/2 h-[2px] w-2 -translate-y-1/2" style={{ background: busted ? DANGER : palette.glow }} />
442+ </div>
443+ );
444+}
445+
446+function OfferCard({ offer, bet, selected, fork, onSelect, palette }: { offer: LadderOffer; bet: number; selected: boolean; fork: boolean; onSelect: () => void; palette: Palette }) {
447+ const Icon = ICONS[offer.kind] ?? DoorOpen;
448+ const risky = offer.kind === "fork-risky";
449+ const accent = risky ? palette.secondary : palette.primary;
450+ const [title, sub] = offer.label.includes(" — ") ? offer.label.split(" — ") : [offer.label, ""];
451+ return (
452+ <button
453+ type="button"
454+ onClick={onSelect}
455+ aria-pressed={selected}
456+ className={cn("tap relative flex w-full flex-col rounded-lg border p-3 text-left transition-all active:scale-[0.99] focus-ring", fork ? "min-h-[132px]" : "min-h-[96px]")}
457+ style={{ borderColor: selected ? accent : "rgba(255,255,255,0.1)", background: selected ? `linear-gradient(180deg, ${accent}22, ${accent}0a)` : "linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02))", boxShadow: selected ? `0 0 28px -8px ${accent}` : undefined }}
458+ >
459+ <div className="flex w-full items-start justify-between gap-2">
460+ <div className="flex min-w-0 items-center gap-2">
461+ <span className="grid h-9 w-9 shrink-0 place-items-center rounded-md" style={{ background: `${accent}22`, color: accent }}>
462+ <Icon className="h-5 w-5" />
463+ </span>
464+ <div className="min-w-0">
465+ <div className="truncate text-[13px] font-bold text-fg">{sub || title}</div>
466+ {sub ? <div className="truncate text-[11px] text-fg-3">{title}</div> : null}
467+ </div>
468+ </div>
469+ <div className="shrink-0 text-right">
470+ <div className="text-lg font-extrabold leading-none tabular" style={{ color: accent }}>
471+ {formatMultiplier(offer.next)}
472+ </div>
473+ <div className="text-[10px] text-fg-3 tabular">{formatSC(Math.round(bet * offer.next))}</div>
474+ </div>
475+ </div>
476+ <div className="mt-auto w-full pt-2">
477+ <div className="flex items-center justify-between text-[11px]">
478+ <span className="text-fg-3">{offer.description}</span>
479+ <span className="font-bold tabular" style={{ color: accent }}>
480+ {Math.round(offer.survival * 100)}%
481+ </span>
482+ </div>
483+ <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-white/10">
484+ <div className="h-full rounded-full" style={{ width: `${Math.round(offer.survival * 100)}%`, background: accent }} />
485+ </div>
486+ </div>
487+ </button>
488+ );
489+}
490+
491+function ResolveCard({ res, palette, reduceMotion }: { res: Resolution; palette: Palette; reduceMotion: boolean }) {
492+ const { ev, step } = res;
493+ const bust = ev.outcome === "bust";
494+ const Icon = bust && step === "result" ? Skull : (ICONS[ev.kind] ?? DoorOpen);
495+ const accent = bust && step === "result" ? DANGER : ev.kind === "fork-risky" ? palette.secondary : palette.glow;
496+ const motionFor = (): { animate: Record<string, number | number[]>; transition: Record<string, number | string> } => {
497+ if (reduceMotion || step === "result") return { animate: { scale: bust ? 1.15 : 1, rotate: 0 }, transition: { duration: 0.2 } };
498+ switch (ev.kind) {
499+ case "chest":
500+ return { animate: { scale: [1, 1.35, 1.1], rotate: [0, -12, 8, 0], y: [0, -8, 0] }, transition: { duration: 0.42 } };
501+ case "enemy":
502+ return { animate: { rotate: [-50, 40, -10, 0], x: [-14, 14, 0], scale: [1, 1.2, 1] }, transition: { duration: 0.42 } };
503+ case "trap":
504+ return { animate: { x: [0, -6, 6, -5, 5, 0], scale: [1, 1.15, 1] }, transition: { duration: 0.42 } };
505+ case "multiplier":
506+ return { animate: { scale: [1, 1.5, 1.2], rotate: [0, 180, 360] }, transition: { duration: 0.42 } };
507+ case "portal":
508+ return { animate: { rotate: [0, 540], scale: [1, 1.6, 0.6, 1.1] }, transition: { duration: 0.5 } };
509+ case "fork-safe":
510+ case "fork-risky":
511+ return { animate: { x: ev.kind === "fork-safe" ? [30, -6, 0] : [-30, 6, 0], scale: [0.9, 1.15, 1] }, transition: { duration: 0.4 } };
512+ default:
513+ return { animate: { y: [0, -14, 0], scale: [1, 1.1, 1] }, transition: { duration: 0.4 } };
514+ }
515+ };
516+ const m = motionFor();
517+ const headline = step === "resolve" ? ev.detail && bust ? ev.detail : ev.label : bust ? ev.label : SUCCESS[ev.kind] ?? "Cleared";
518+ return (
519+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.18 }} className="surface relative overflow-hidden rounded-lg p-4 text-center" style={{ borderColor: `${accent}66` }}>
520+ <motion.div key={step} className="mx-auto grid h-16 w-16 place-items-center rounded-full" style={{ background: `${accent}22`, color: accent, boxShadow: `0 0 34px -8px ${accent}` }} animate={m.animate} transition={m.transition}>
521+ <Icon className="h-8 w-8" />
522+ </motion.div>
523+ {!bust && step === "result" && !reduceMotion ? (
524+ <>
525+ {[0, 1, 2, 3, 4, 5].map((i) => (
526+ <motion.span key={i} className="pointer-events-none absolute left-1/2 top-[38px] h-1.5 w-1.5 rounded-full" style={{ background: accent }} initial={{ x: 0, y: 0, opacity: 1 }} animate={{ x: Math.cos((i / 6) * Math.PI * 2) * 70, y: Math.sin((i / 6) * Math.PI * 2) * 70, opacity: 0 }} transition={{ duration: 0.5, ease: "easeOut" }} />
527+ ))}
528+ </>
529+ ) : null}
530+ <motion.div key={`${step}-h`} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="mt-3 text-[15px] font-extrabold uppercase tracking-tight" style={{ color: bust && step === "result" ? DANGER : "var(--color-fg)" }}>
531+ {headline}
532+ </motion.div>
533+ <div className="mt-0.5 h-4 text-[12px] text-fg-3">
534+ {step === "resolve" ? "Resolving…" : bust ? ev.detail ?? "" : ev.data?.checkpoint === true ? "Checkpoint reached" : `Floor ${ev.stage} · ${formatMultiplier(ev.multiplierAfter)}`}
535+ </div>
536+ </motion.div>
537+ );
538+}
added apps/web/src/components/arcade/gridbreak.tsx +796 −0
@@ -0,0 +1,796 @@
1+"use client";
2+
3+/**
4+ * GRID//BREAK — browser side. The server resolves every chain step; this file
5+ * replays `outcome.steps` (grid before the step, destroyed blocks, specials,
6+ * chain index, step win) on a 2D canvas: wave beam → detonation → gravity/refill.
7+ */
8+import { useCallback, useEffect, 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 { ArcadeOutcome, GridCell, GridStep, GridbreakConfig } from "@spinza/game-core/client";
13+import { cn } from "@/lib/utils";
14+import { useInstantPlay, type ArcadeGameProps } from "./contract";
15+
16+interface GridOutcome extends ArcadeOutcome {
17+ steps: GridStep[];
18+ summary: { column: number; chains: number; blocksDestroyed: number };
19+}
20+
21+/* --------------------------------------------------------------- helpers */
22+
23+const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
24+const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
25+const easeInCubic = (t: number) => t * t * t;
26+const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);
27+const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
28+const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
29+
30+function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> {
31+ return new Promise((resolve) => {
32+ const start = performance.now();
33+ const frame = (now: number) => {
34+ if (!alive()) return resolve();
35+ const t = Math.min(1, (now - start) / Math.max(1, ms));
36+ fn(t);
37+ if (t < 1) requestAnimationFrame(frame);
38+ else resolve();
39+ };
40+ requestAnimationFrame(frame);
41+ });
42+}
43+
44+function rgba(hex: string, a: number): string {
45+ const h = hex.replace("#", "");
46+ const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
47+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
48+}
49+
50+function mixHex(a: string, b: string, t: number): string {
51+ const pa = parseInt(a.replace("#", ""), 16);
52+ const pb = parseInt(b.replace("#", ""), 16);
53+ const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t));
54+ return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
55+}
56+
57+/* ----------------------------------------------------------------- scene */
58+
59+interface Block {
60+ c: number;
61+ s?: GridCell["s"];
62+ x: number; // column
63+ y: number; // display row (float during falls)
64+ alpha: number;
65+ scale: number;
66+ flash: number; // 0..1 highlight before detonation
67+}
68+
69+interface Particle {
70+ x: number;
71+ y: number;
72+ vx: number;
73+ vy: number;
74+ life: number;
75+ max: number;
76+ color: string;
77+ size: number;
78+ rot: number;
79+ vr: number;
80+}
81+
82+interface Pop {
83+ x: number;
84+ y: number;
85+ text: string;
86+ color: string;
87+ at: number;
88+}
89+
90+interface Scene {
91+ t: number;
92+ cols: Block[][];
93+ column: number;
94+ phase: "idle" | "firing";
95+ placeholder: boolean;
96+ beam: { p: number; alpha: number } | null;
97+ shocks: { x: number; y: number; at: number; kind: "bomb" | "line" | "x2" | "miss" }[];
98+ sweeps: { row: number; at: number }[];
99+ particles: Particle[];
100+ pops: Pop[];
101+ reduceMotion: boolean;
102+}
103+
104+interface Layout {
105+ w: number;
106+ h: number;
107+ ox: number;
108+ oy: number;
109+ cell: number;
110+ headH: number;
111+}
112+
113+function layoutFor(w: number, h: number, size: number): Layout {
114+ const headH = 26;
115+ const side = Math.max(120, Math.min(w - 16, h - headH - 30, 560));
116+ const cell = side / size;
117+ const ox = (w - side) / 2;
118+ const oy = headH + (h - headH - side) / 2;
119+ return { w, h, ox, oy, cell, headH };
120+}
121+
122+function placeholderGrid(size: number, colors: number): GridCell[][] {
123+ const g: GridCell[][] = [];
124+ for (let x = 0; x < size; x++) {
125+ const col: GridCell[] = [];
126+ for (let y = 0; y < size; y++) col.push({ c: (x * 2 + y * 3 + ((x * y) % 5)) % colors });
127+ g.push(col);
128+ }
129+ return g;
130+}
131+
132+function blocksFrom(grid: GridCell[][]): Block[][] {
133+ return grid.map((col, x) => col.map((cell, y) => ({ c: cell.c, s: cell.s, x, y, alpha: 1, scale: 1, flash: 0 })));
134+}
135+
136+/* --------------------------------------------------------------- drawing */
137+
138+interface Palette {
139+ primary: string;
140+ secondary: string;
141+ glow: string;
142+ bg: string;
143+ surface: string;
144+}
145+
146+function blockColors(palette: Palette): string[] {
147+ return [palette.secondary, palette.primary, "#fbbf24", "#f472b6", "#a78bfa", "#f8fafc"];
148+}
149+
150+function drawBlock(ctx: CanvasRenderingContext2D, b: Block, L: Layout, color: string, dim: boolean) {
151+ const size = L.cell;
152+ const cx = L.ox + (b.x + 0.5) * size;
153+ const cy = L.oy + (b.y + 0.5) * size;
154+ const s = size * 0.86 * b.scale;
155+ ctx.save();
156+ ctx.globalAlpha = b.alpha * (dim ? 0.5 : 1);
157+ ctx.translate(cx, cy);
158+ const r = Math.min(7, size * 0.18);
159+ const grad = ctx.createLinearGradient(0, -s / 2, 0, s / 2);
160+ grad.addColorStop(0, rgba(color, 0.95));
161+ grad.addColorStop(1, mixHex(color, "#000000", 0.42));
162+ ctx.fillStyle = grad;
163+ ctx.shadowColor = color;
164+ ctx.shadowBlur = b.flash > 0 ? 8 + 26 * b.flash : dim ? 0 : size * 0.16;
165+ ctx.beginPath();
166+ ctx.roundRect(-s / 2, -s / 2, s, s, r);
167+ ctx.fill();
168+ ctx.shadowBlur = 0;
169+ // gloss
170+ const gloss = ctx.createLinearGradient(0, -s / 2, 0, 0);
171+ gloss.addColorStop(0, "rgba(255,255,255,0.35)");
172+ gloss.addColorStop(1, "rgba(255,255,255,0)");
173+ ctx.fillStyle = gloss;
174+ ctx.beginPath();
175+ ctx.roundRect(-s / 2 + 2, -s / 2 + 2, s - 4, s / 2, r);
176+ ctx.fill();
177+ ctx.strokeStyle = b.flash > 0 ? `rgba(255,255,255,${0.4 + 0.6 * b.flash})` : "rgba(255,255,255,0.18)";
178+ ctx.lineWidth = b.flash > 0 ? 2 : 1;
179+ ctx.beginPath();
180+ ctx.roundRect(-s / 2, -s / 2, s, s, r);
181+ ctx.stroke();
182+ // Special icons
183+ if (b.s === "bomb") {
184+ ctx.fillStyle = "#0b0f14";
185+ ctx.beginPath();
186+ ctx.arc(0, s * 0.06, s * 0.24, 0, Math.PI * 2);
187+ ctx.fill();
188+ ctx.strokeStyle = "#f8fafc";
189+ ctx.lineWidth = 1.5;
190+ ctx.beginPath();
191+ ctx.moveTo(s * 0.08, -s * 0.14);
192+ ctx.quadraticCurveTo(s * 0.2, -s * 0.34, s * 0.3, -s * 0.28);
193+ ctx.stroke();
194+ ctx.fillStyle = "#fde68a";
195+ ctx.beginPath();
196+ ctx.arc(s * 0.31, -s * 0.29, s * 0.06, 0, Math.PI * 2);
197+ ctx.fill();
198+ } else if (b.s === "line") {
199+ ctx.strokeStyle = "#0b0f14";
200+ ctx.lineWidth = Math.max(2, s * 0.09);
201+ ctx.lineCap = "round";
202+ ctx.beginPath();
203+ ctx.moveTo(-s * 0.3, 0);
204+ ctx.lineTo(s * 0.3, 0);
205+ ctx.moveTo(-s * 0.3, 0);
206+ ctx.lineTo(-s * 0.16, -s * 0.13);
207+ ctx.moveTo(-s * 0.3, 0);
208+ ctx.lineTo(-s * 0.16, s * 0.13);
209+ ctx.moveTo(s * 0.3, 0);
210+ ctx.lineTo(s * 0.16, -s * 0.13);
211+ ctx.moveTo(s * 0.3, 0);
212+ ctx.lineTo(s * 0.16, s * 0.13);
213+ ctx.stroke();
214+ } else if (b.s === "x2") {
215+ ctx.fillStyle = "#0b0f14";
216+ ctx.font = `900 ${Math.max(10, s * 0.46)}px Geist, "Geist Fallback", system-ui, sans-serif`;
217+ ctx.textAlign = "center";
218+ ctx.textBaseline = "middle";
219+ ctx.fillText("×2", 0, 1);
220+ }
221+ ctx.restore();
222+}
223+
224+function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: GridbreakConfig, palette: Palette) {
225+ const { w, h, ox, oy, cell } = L;
226+ const side = cell * cfg.size;
227+ ctx.clearRect(0, 0, w, h);
228+ const colors = blockColors(palette);
229+ const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`;
230+
231+ // Board frame
232+ ctx.save();
233+ ctx.fillStyle = "rgba(255,255,255,0.03)";
234+ ctx.strokeStyle = rgba(palette.primary, 0.22);
235+ ctx.lineWidth = 1;
236+ ctx.beginPath();
237+ ctx.roundRect(ox - 6, oy - 6, side + 12, side + 12, 14);
238+ ctx.fill();
239+ ctx.stroke();
240+ // grid lines
241+ ctx.strokeStyle = "rgba(255,255,255,0.05)";
242+ for (let i = 1; i < cfg.size; i++) {
243+ ctx.beginPath();
244+ ctx.moveTo(ox + i * cell, oy);
245+ ctx.lineTo(ox + i * cell, oy + side);
246+ ctx.moveTo(ox, oy + i * cell);
247+ ctx.lineTo(ox + side, oy + i * cell);
248+ ctx.stroke();
249+ }
250+ ctx.restore();
251+
252+ // Selected column glow + headers
253+ for (let x = 0; x < cfg.size; x++) {
254+ const cx = ox + (x + 0.5) * cell;
255+ const sel = x === s.column;
256+ if (sel) {
257+ const g = ctx.createLinearGradient(0, oy, 0, oy + side);
258+ g.addColorStop(0, rgba(palette.primary, s.phase === "idle" ? 0.16 : 0.08));
259+ g.addColorStop(1, rgba(palette.primary, 0));
260+ ctx.fillStyle = g;
261+ ctx.fillRect(ox + x * cell + 1, oy, cell - 2, side);
262+ }
263+ // chevron header
264+ ctx.save();
265+ ctx.translate(cx, oy - 13 + (sel ? Math.sin(s.t * 3) * 1.5 : 0));
266+ ctx.strokeStyle = sel ? palette.glow : "rgba(255,255,255,0.28)";
267+ ctx.lineWidth = sel ? 2.5 : 1.5;
268+ ctx.lineCap = "round";
269+ ctx.beginPath();
270+ ctx.moveTo(-5, -3);
271+ ctx.lineTo(0, 3);
272+ ctx.lineTo(5, -3);
273+ ctx.stroke();
274+ if (sel) {
275+ ctx.shadowColor = palette.glow;
276+ ctx.shadowBlur = 12;
277+ ctx.stroke();
278+ }
279+ ctx.restore();
280+ }
281+
282+ // Clip to board for falling blocks
283+ ctx.save();
284+ ctx.beginPath();
285+ ctx.rect(ox - 2, oy - 2, side + 4, side + 4);
286+ ctx.clip();
287+ for (const col of s.cols) for (const b of col) if (b.alpha > 0) drawBlock(ctx, b, L, colors[b.c % colors.length], s.placeholder);
288+ ctx.restore();
289+
290+ // Beam
291+ if (s.beam && s.beam.alpha > 0) {
292+ const x = ox + (s.column + 0.5) * cell;
293+ const yEnd = oy + side * s.beam.p;
294+ ctx.save();
295+ ctx.globalAlpha = s.beam.alpha;
296+ ctx.strokeStyle = palette.glow;
297+ ctx.lineWidth = Math.max(3, cell * 0.14);
298+ ctx.lineCap = "round";
299+ ctx.shadowColor = palette.primary;
300+ ctx.shadowBlur = 24;
301+ ctx.beginPath();
302+ ctx.moveTo(x, oy - 8);
303+ ctx.lineTo(x, yEnd);
304+ ctx.stroke();
305+ ctx.strokeStyle = "#ffffff";
306+ ctx.lineWidth = 1.5;
307+ ctx.stroke();
308+ ctx.fillStyle = "#ffffff";
309+ ctx.beginPath();
310+ ctx.arc(x, yEnd, cell * 0.18, 0, Math.PI * 2);
311+ ctx.fill();
312+ ctx.restore();
313+ }
314+
315+ // Shocks (bomb rings, x2 rings)
316+ for (const sh of s.shocks) {
317+ const age = s.t - sh.at;
318+ if (age > 0.7) continue;
319+ const k = age / 0.7;
320+ const cx = ox + (sh.x + 0.5) * cell;
321+ const cy = oy + (sh.y + 0.5) * cell;
322+ ctx.save();
323+ ctx.globalAlpha = 1 - k;
324+ if (sh.kind === "bomb") {
325+ ctx.strokeStyle = "#fde68a";
326+ ctx.lineWidth = 4 * (1 - k) + 1;
327+ ctx.beginPath();
328+ ctx.roundRect(cx - cell * 1.5 * easeOutCubic(k), cy - cell * 1.5 * easeOutCubic(k), cell * 3 * easeOutCubic(k), cell * 3 * easeOutCubic(k), 8);
329+ ctx.stroke();
330+ ctx.fillStyle = rgba("#fde68a", 0.25 * (1 - k));
331+ ctx.fill();
332+ } else if (sh.kind === "x2") {
333+ ctx.strokeStyle = palette.glow;
334+ ctx.lineWidth = 3;
335+ ctx.beginPath();
336+ ctx.arc(cx, cy, cell * (0.3 + 1.6 * easeOutCubic(k)), 0, Math.PI * 2);
337+ ctx.stroke();
338+ } else if (sh.kind === "miss") {
339+ ctx.strokeStyle = "rgba(255,255,255,0.5)";
340+ ctx.lineWidth = 2;
341+ ctx.beginPath();
342+ ctx.arc(cx, cy, cell * (0.2 + 0.6 * easeOutCubic(k)), 0, Math.PI * 2);
343+ ctx.stroke();
344+ }
345+ ctx.restore();
346+ }
347+ // Line sweeps
348+ for (const sw of s.sweeps) {
349+ const age = s.t - sw.at;
350+ if (age > 0.6) continue;
351+ const k = easeOutCubic(age / 0.6);
352+ const y = oy + (sw.row + 0.5) * cell;
353+ ctx.save();
354+ ctx.globalAlpha = 1 - age / 0.6;
355+ const g = ctx.createLinearGradient(ox, 0, ox + side * k, 0);
356+ g.addColorStop(0, rgba(palette.secondary, 0));
357+ g.addColorStop(0.8, rgba(palette.secondary, 0.6));
358+ g.addColorStop(1, "#ffffff");
359+ ctx.fillStyle = g;
360+ ctx.fillRect(ox, y - cell * 0.42, side * k, cell * 0.84);
361+ ctx.restore();
362+ }
363+
364+ // Particles
365+ for (const p of s.particles) {
366+ const k = p.life / p.max;
367+ ctx.save();
368+ ctx.globalAlpha = k;
369+ ctx.translate(p.x, p.y);
370+ ctx.rotate(p.rot);
371+ ctx.fillStyle = p.color;
372+ ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
373+ ctx.restore();
374+ }
375+
376+ // Pops
377+ for (const pop of s.pops) {
378+ const age = s.t - pop.at;
379+ if (age > 1.1) continue;
380+ const k = age < 0.2 ? easeOutBack(age / 0.2) : 1;
381+ const fade = age > 0.7 ? 1 - (age - 0.7) / 0.4 : 1;
382+ ctx.save();
383+ ctx.globalAlpha = Math.max(0, fade);
384+ ctx.translate(pop.x, pop.y - age * 26);
385+ ctx.scale(k, k);
386+ ctx.font = font(Math.max(12, cell * 0.5), 900);
387+ ctx.textAlign = "center";
388+ ctx.textBaseline = "middle";
389+ ctx.shadowColor = pop.color;
390+ ctx.shadowBlur = 14;
391+ ctx.fillStyle = pop.color;
392+ ctx.fillText(pop.text, 0, 0);
393+ ctx.restore();
394+ }
395+
396+ // Placeholder watermark
397+ if (s.placeholder) {
398+ const label = "PREVIEW GRID · FIRE TO REVEAL THE REAL ONE";
399+ ctx.save();
400+ ctx.font = font(11, 700);
401+ ctx.textAlign = "center";
402+ ctx.textBaseline = "middle";
403+ const tw = Math.min(side - 16, ctx.measureText(label).width + 28);
404+ const cx = ox + side / 2;
405+ const cy = oy + side / 2;
406+ ctx.fillStyle = "rgba(5,8,12,0.78)";
407+ ctx.strokeStyle = rgba(palette.primary, 0.35);
408+ ctx.lineWidth = 1;
409+ ctx.beginPath();
410+ ctx.roundRect(cx - tw / 2, cy - 15, tw, 30, 15);
411+ ctx.fill();
412+ ctx.stroke();
413+ ctx.fillStyle = "rgba(255,255,255,0.8)";
414+ ctx.fillText(label, cx, cy + 0.5, tw - 20);
415+ ctx.restore();
416+ }
417+}
418+
419+/* ------------------------------------------------------------- component */
420+
421+export function GridbreakGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {
422+ const cfg = definition.config as unknown as GridbreakConfig;
423+ const palette = definition.presentation.palette;
424+ const { play, error, clearError } = useInstantPlay<GridOutcome>(definition.slug);
425+ const [column, setColumn] = useState(Math.floor(cfg.size / 2));
426+ const [phase, setPhase] = useState<"idle" | "firing">("idle");
427+ const [chain, setChain] = useState<number | null>(null);
428+ const [running, setRunning] = useState(0);
429+ const [live, setLive] = useState<string | null>(null);
430+ const [result, setResult] = useState<{ win: number; multiplier: number; chains: number; blocks: number } | null>(null);
431+ const canvasRef = useRef<HTMLCanvasElement>(null);
432+ const sceneRef = useRef<Scene | null>(null);
433+ const aliveRef = useRef(true);
434+ const phaseRef = useRef<"idle" | "firing">("idle");
435+
436+ const getScene = useCallback((): Scene => {
437+ if (!sceneRef.current) {
438+ sceneRef.current = {
439+ t: 0,
440+ cols: blocksFrom(placeholderGrid(cfg.size, cfg.colors)),
441+ column: Math.floor(cfg.size / 2),
442+ phase: "idle",
443+ placeholder: true,
444+ beam: null,
445+ shocks: [],
446+ sweeps: [],
447+ particles: [],
448+ pops: [],
449+ reduceMotion: false,
450+ };
451+ }
452+ return sceneRef.current;
453+ }, [cfg]);
454+
455+ useEffect(() => {
456+ aliveRef.current = true;
457+ const canvas = canvasRef.current;
458+ if (!canvas) return;
459+ const ctx = canvas.getContext("2d");
460+ if (!ctx) return;
461+ const s = getScene();
462+ let raf = 0;
463+ let last = performance.now();
464+ const loop = (now: number) => {
465+ const dt = Math.min(0.05, (now - last) / 1000);
466+ last = now;
467+ s.t += dt;
468+ const dpr = Math.min(2, window.devicePixelRatio || 1);
469+ const rect = canvas.getBoundingClientRect();
470+ const W = Math.max(1, Math.round(rect.width * dpr));
471+ const H = Math.max(1, Math.round(rect.height * dpr));
472+ if (canvas.width !== W || canvas.height !== H) {
473+ canvas.width = W;
474+ canvas.height = H;
475+ }
476+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
477+ const L = layoutFor(rect.width, rect.height, cfg.size);
478+ for (let i = s.particles.length - 1; i >= 0; i--) {
479+ const p = s.particles[i];
480+ p.life -= dt;
481+ p.x += p.vx * dt;
482+ p.y += p.vy * dt;
483+ p.vy += 900 * dt;
484+ p.rot += p.vr * dt;
485+ if (p.life <= 0) s.particles.splice(i, 1);
486+ }
487+ s.shocks = s.shocks.filter((x) => s.t - x.at < 0.8);
488+ s.sweeps = s.sweeps.filter((x) => s.t - x.at < 0.7);
489+ s.pops = s.pops.filter((x) => s.t - x.at < 1.2);
490+ drawScene(ctx, L, s, cfg, palette);
491+ raf = requestAnimationFrame(loop);
492+ };
493+ raf = requestAnimationFrame(loop);
494+ return () => {
495+ aliveRef.current = false;
496+ cancelAnimationFrame(raf);
497+ };
498+ }, [cfg, palette, getScene]);
499+
500+ useEffect(() => {
501+ const s = getScene();
502+ s.column = column;
503+ s.reduceMotion = reduceMotion;
504+ }, [column, reduceMotion, getScene]);
505+
506+ const onPointer = useCallback(
507+ (e: React.PointerEvent<HTMLCanvasElement>) => {
508+ if (phaseRef.current !== "idle") return;
509+ if (e.type === "pointermove" && e.buttons === 0) return;
510+ const rect = e.currentTarget.getBoundingClientRect();
511+ const L = layoutFor(rect.width, rect.height, cfg.size);
512+ const col = clamp(Math.floor((e.clientX - rect.left - L.ox) / L.cell), 0, cfg.size - 1);
513+ setColumn((prev) => {
514+ if (prev !== col) sound("tick");
515+ return col;
516+ });
517+ },
518+ [cfg.size, sound],
519+ );
520+
521+ const explode = useCallback((s: Scene, L: Layout, b: Block, color: string) => {
522+ if (s.reduceMotion) return;
523+ const cx = L.ox + (b.x + 0.5) * L.cell;
524+ const cy = L.oy + (b.y + 0.5) * L.cell;
525+ const n = 7;
526+ for (let i = 0; i < n; i++) {
527+ const a = Math.random() * Math.PI * 2;
528+ const v = 120 + Math.random() * 220;
529+ s.particles.push({ x: cx, y: cy, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 160, life: 0.45 + Math.random() * 0.35, max: 0.8, color, size: 2 + Math.random() * L.cell * 0.18, rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 12 });
530+ }
531+ }, []);
532+
533+ const animate = useCallback(
534+ async (outcome: GridOutcome) => {
535+ const s = getScene();
536+ const alive = () => aliveRef.current;
537+ const rm = s.reduceMotion;
538+ const canvas = canvasRef.current;
539+ const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 500 };
540+ const L = () => layoutFor(rect.width, rect.height, cfg.size);
541+ const colors = blockColors(palette);
542+ const steps = outcome.steps;
543+ const size = cfg.size;
544+ s.phase = "firing";
545+ s.placeholder = false;
546+ s.particles = [];
547+ s.shocks = [];
548+ s.sweeps = [];
549+ s.pops = [];
550+ s.column = outcome.summary.column;
551+
552+ // 1) The real grid drops in.
553+ s.cols = blocksFrom(steps[0].grid);
554+ for (const col of s.cols) for (const b of col) b.y = b.y - size - 0.5 - Math.random() * 0.8;
555+ await tween(rm ? 80 : 380, (t) => {
556+ for (const col of s.cols)
557+ for (let y = 0; y < col.length; y++) {
558+ const b = col[y];
559+ const start = -size - 0.5 - (b.x % 3) * 0.25;
560+ b.y = lerp(start, y, easeOutCubic(clamp(t * 1.15 - b.x * 0.02, 0, 1)));
561+ }
562+ }, alive);
563+ for (const col of s.cols) for (let y = 0; y < col.length; y++) col[y].y = y;
564+ sound("tick");
565+
566+ // 2) The wave beam down the chosen column.
567+ s.beam = { p: 0, alpha: 1 };
568+ await tween(rm ? 80 : 300, (t) => {
569+ if (s.beam) s.beam.p = easeInCubic(t);
570+ }, alive);
571+ const first = steps[0];
572+ if (first.destroyed.length === 0) {
573+ s.shocks.push({ x: s.column, y: size - 1, at: s.t, kind: "miss" });
574+ setLive("No cluster on that column");
575+ }
576+ await tween(rm ? 60 : 220, (t) => {
577+ if (s.beam) s.beam.alpha = 1 - t;
578+ }, alive);
579+ s.beam = null;
580+
581+ let cum = 0;
582+ for (let i = 0; i < steps.length - 1; i++) {
583+ if (!alive()) return;
584+ const step = steps[i];
585+ const next = steps[i + 1];
586+ const destroyed = new Set(step.destroyed.map(([x, y]) => `${x}:${y}`));
587+ if (destroyed.size === 0) break;
588+ setChain(step.chain);
589+ const chainMult = cfg.chainLadder[Math.min(step.chain, cfg.chainLadder.length - 1)];
590+ const x2s = step.specials.filter((sp) => sp.type === "x2").length;
591+ setLive(step.chain > 0 ? `Chain ${step.chain + 1} · ×${chainMult}${x2s ? ` · ×${2 ** x2s} block` : ""}` : x2s ? `×${2 ** x2s} block` : `Wave hit ${destroyed.size} blocks`);
592+
593+ // Highlight + specials
594+ const hitBlocks: Block[] = [];
595+ for (const col of s.cols) for (const b of col) if (destroyed.has(`${b.x}:${Math.round(b.y)}`)) hitBlocks.push(b);
596+ await tween(rm ? 50 : 200, (t) => {
597+ for (const b of hitBlocks) {
598+ b.flash = Math.sin(t * Math.PI);
599+ b.scale = 1 + 0.12 * Math.sin(t * Math.PI);
600+ }
601+ }, alive);
602+ for (const sp of step.specials) {
603+ if (sp.type === "bomb") {
604+ s.shocks.push({ x: sp.at[0], y: sp.at[1], at: s.t, kind: "bomb" });
605+ sound("bonus");
606+ } else if (sp.type === "line") {
607+ s.sweeps.push({ row: sp.at[1], at: s.t });
608+ sound("bonus");
609+ } else if (sp.type === "x2") {
610+ s.shocks.push({ x: sp.at[0], y: sp.at[1], at: s.t, kind: "x2" });
611+ s.pops.push({ x: L().ox + (sp.at[0] + 0.5) * L().cell, y: L().oy + (sp.at[1] + 0.5) * L().cell, text: "×2", color: palette.glow, at: s.t });
612+ sound("bonus");
613+ }
614+ }
615+ if (step.specials.length) await wait(rm ? 60 : 260);
616+
617+ // Detonate
618+ for (const b of hitBlocks) explode(s, L(), b, colors[b.c % colors.length]);
619+ sound(step.chain >= 2 ? "bonus" : "tick");
620+ await tween(rm ? 50 : 170, (t) => {
621+ for (const b of hitBlocks) {
622+ b.alpha = 1 - t;
623+ b.scale = 1 + 0.45 * t;
624+ b.flash = 1 - t;
625+ }
626+ }, alive);
627+ // Step win counts up
628+ const from = cum;
629+ cum += step.win;
630+ if (step.win > 0) {
631+ const Lc = L();
632+ const cxs = step.destroyed.reduce((a, [x]) => a + x, 0) / step.destroyed.length;
633+ const cys = step.destroyed.reduce((a, [, y]) => a + y, 0) / step.destroyed.length;
634+ s.pops.push({ x: Lc.ox + (cxs + 0.5) * Lc.cell, y: Lc.oy + (cys + 0.5) * Lc.cell, text: `+${formatSC(step.win, { unit: false })}`, color: step.chain >= 2 ? "#ffd66b" : "#ffffff", at: s.t });
635+ }
636+ void tween(rm ? 80 : 360, (t) => setRunning(Math.round(lerp(from, cum, easeOutCubic(t)))), alive);
637+
638+ // Gravity + refill toward the next grid
639+ type Fall = { b: Block; from: number; to: number };
640+ const falls: Fall[] = [];
641+ const newCols: Block[][] = [];
642+ for (let x = 0; x < size; x++) {
643+ const kept = s.cols[x].filter((b) => !destroyed.has(`${b.x}:${Math.round(b.y)}`)).sort((a, b) => a.y - b.y);
644+ const freshCount = size - kept.length;
645+ const col: Block[] = [];
646+ for (let j = 0; j < freshCount; j++) {
647+ const cell = next.grid[x][j];
648+ const b: Block = { c: cell.c, s: cell.s, x, y: j - freshCount - 0.3, alpha: 1, scale: 1, flash: 0 };
649+ col.push(b);
650+ falls.push({ b, from: b.y, to: j });
651+ }
652+ kept.forEach((b, j) => {
653+ const target = freshCount + j;
654+ const cell = next.grid[x][target];
655+ // trust the server grid for colour/special (they should match)
656+ b.c = cell.c;
657+ b.s = cell.s;
658+ b.alpha = 1;
659+ b.scale = 1;
660+ b.flash = 0;
661+ if (b.y !== target) falls.push({ b, from: b.y, to: target });
662+ col.push(b);
663+ });
664+ newCols.push(col);
665+ }
666+ s.cols = newCols;
667+ if (falls.length) {
668+ await tween(rm ? 70 : 340, (t) => {
669+ for (const f of falls) {
670+ const k = easeOutBack(clamp(t * 1.05, 0, 1));
671+ f.b.y = lerp(f.from, f.to, Math.min(1, Math.max(0, k)));
672+ }
673+ }, alive);
674+ for (const f of falls) f.b.y = f.to;
675+ sound("tick");
676+ }
677+ await wait(rm ? 40 : 140);
678+ }
679+
680+ if (!alive()) return;
681+ // Final grid = last step's grid (already what we show; enforce exactly).
682+ s.cols = blocksFrom(steps[steps.length - 1].grid);
683+ setRunning(outcome.totalWin);
684+ setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, chains: outcome.summary.chains, blocks: outcome.summary.blocksDestroyed });
685+ if (outcome.totalWin <= 0) sound("lose");
686+ else if (outcome.multiplier >= 15) sound("bigWin");
687+ else sound("win");
688+ onResult({ win: outcome.totalWin, multiplier: outcome.multiplier });
689+ s.phase = "idle";
690+ },
691+ [cfg, palette, getScene, sound, onResult, explode],
692+ );
693+
694+ const fire = useCallback(async () => {
695+ if (phaseRef.current !== "idle") return;
696+ phaseRef.current = "firing";
697+ setPhase("firing");
698+ setResult(null);
699+ setLive(null);
700+ setChain(null);
701+ setRunning(0);
702+ onBusy(true);
703+ sound("click");
704+ const res = await play(bet, { column });
705+ if (res && aliveRef.current) await animate(res.outcome);
706+ phaseRef.current = "idle";
707+ if (aliveRef.current) {
708+ setPhase("idle");
709+ const s = sceneRef.current;
710+ if (s) s.phase = "idle";
711+ }
712+ onBusy(false);
713+ }, [play, bet, column, animate, onBusy, sound]);
714+
715+ const firing = phase === "firing";
716+
717+ return (
718+ <div className="absolute inset-0 flex flex-col">
719+ {/* Chain ladder + running win */}
720+ <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2">
721+ <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto scrollbar-none" aria-label="Chain multiplier ladder">
722+ {cfg.chainLadder.map((m, i) => {
723+ const lit = chain !== null && i <= chain;
724+ const current = chain === i;
725+ return (
726+ <span
727+ key={i}
728+ className={cn("shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold tabular transition-all", lit ? "text-[#061018]" : "bg-white/5 text-fg-4")}
729+ style={lit ? { background: current ? palette.glow : rgba(palette.primary, 0.75), boxShadow: current ? `0 0 18px ${rgba(palette.primary, 0.7)}` : undefined, transform: current ? "scale(1.12)" : undefined } : undefined}
730+ >
731+ ×{m}
732+ </span>
733+ );
734+ })}
735+ </div>
736+ <div className="text-right leading-tight">
737+ <div className="eyebrow">{firing ? "Running" : "Round win"}</div>
738+ <div className={cn("text-sm font-bold tabular", running > 0 ? "text-credit" : "text-fg-3")}>{running > 0 ? formatSC(running) : "—"}</div>
739+ </div>
740+ </div>
741+
742+ {/* Grid */}
743+ <div className="relative min-h-0 flex-1">
744+ <canvas ref={canvasRef} className={cn("absolute inset-0 h-full w-full touch-none", firing ? "cursor-default" : "cursor-pointer")} onPointerDown={onPointer} onPointerMove={onPointer} role="img" aria-label="Energy grid. Tap a column to aim the wave." />
745+ <AnimatePresence>
746+ {live && firing ? (
747+ <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-1 -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 }}>
748+ {live}
749+ </motion.div>
750+ ) : null}
751+ {result && !firing ? (
752+ <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-1 -translate-x-1/2 glass whitespace-nowrap rounded-full px-4 py-1.5 text-center">
753+ <span className={cn("text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No cluster detonated"}</span>
754+ <span className="ml-2 text-[11px] text-fg-3">
755+ {result.chains} chain{result.chains === 1 ? "" : "s"} · {result.blocks} blocks
756+ </span>
757+ </motion.div>
758+ ) : null}
759+ {error ? (
760+ <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">
761+ <div className="text-fg-2">{error}</div>
762+ <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring">
763+ Dismiss
764+ </button>
765+ </motion.div>
766+ ) : null}
767+ </AnimatePresence>
768+ </div>
769+
770+ {/* Column + action */}
771+ <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2">
772+ <div className="flex items-center gap-1">
773+ <button disabled={firing || column <= 0} onClick={() => { sound("tick"); setColumn((c) => Math.max(0, c - 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Column left">
774+ <ChevronLeft className="h-4 w-4" />
775+ </button>
776+ <div className="flex h-11 min-w-[72px] flex-col items-center justify-center rounded-md surface-2 px-2">
777+ <span className="text-[10px] uppercase tracking-wider text-fg-3">Column</span>
778+ <span className="text-sm font-bold tabular">{column + 1} / {cfg.size}</span>
779+ </div>
780+ <button disabled={firing || column >= cfg.size - 1} onClick={() => { sound("tick"); setColumn((c) => Math.min(cfg.size - 1, c + 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Column right">
781+ <ChevronRight className="h-4 w-4" />
782+ </button>
783+ </div>
784+ <button
785+ onClick={() => void fire()}
786+ disabled={firing}
787+ 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"
788+ 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}` }}
789+ aria-label={definition.presentation.verb}
790+ >
791+ {firing ? "WAVE ACTIVE…" : `${definition.presentation.verb} · ${formatSC(bet)}`}
792+ </button>
793+ </div>
794+ </div>
795+ );
796+}
added apps/web/src/components/arcade/orbit.tsx +917 −0
@@ -0,0 +1,917 @@
1+"use client";
2+
3+/**
4+ * ORBIT — browser side. The server places the objects and resolves the impulse;
5+ * this file draws `summary.orbits` (angles in degrees, 0 = up, clockwise) and
6+ * replays `outcome.steps` (miss / hit / deflection / spawned orbit / supernova)
7+ * on a 2D canvas. Object rotation is purely cosmetic and frozen while a round plays.
8+ */
9+import { useCallback, useEffect, useRef, useState } from "react";
10+import { AnimatePresence, motion } from "framer-motion";
11+import { RotateCcw, RotateCw } from "lucide-react";
12+import { formatMultiplier, formatSC } from "@spinza/shared";
13+import type { ArcadeOutcome, OrbitConfig, OrbitObject, OrbitStep } from "@spinza/game-core/client";
14+import { cn } from "@/lib/utils";
15+import { useInstantPlay, type ArcadeGameProps } from "./contract";
16+
17+interface OrbitOutcome extends ArcadeOutcome {
18+ steps: OrbitStep[];
19+ summary: { angle: number | null; orbits: OrbitObject[][]; supernova: { remaining: number; multiplier: number } | null; hits: number };
20+}
21+
22+/* --------------------------------------------------------------- helpers */
23+
24+const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
25+const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
26+const easeInCubic = (t: number) => t * t * t;
27+const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);
28+const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
29+const rad = (deg: number) => ((deg - 90) * Math.PI) / 180;
30+const norm = (deg: number) => ((deg % 360) + 360) % 360;
31+
32+function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> {
33+ return new Promise((resolve) => {
34+ const start = performance.now();
35+ const frame = (now: number) => {
36+ if (!alive()) return resolve();
37+ const t = Math.min(1, (now - start) / Math.max(1, ms));
38+ fn(t);
39+ if (t < 1) requestAnimationFrame(frame);
40+ else resolve();
41+ };
42+ requestAnimationFrame(frame);
43+ });
44+}
45+
46+function rgba(hex: string, a: number): string {
47+ const h = hex.replace("#", "");
48+ const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
49+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
50+}
51+
52+function mixHex(a: string, b: string, t: number): string {
53+ const pa = parseInt(a.replace("#", ""), 16);
54+ const pb = parseInt(b.replace("#", ""), 16);
55+ const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t));
56+ return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
57+}
58+
59+/* ----------------------------------------------------------------- scene */
60+
61+interface Obj {
62+ id: string;
63+ type: string;
64+ angle: number;
65+ value: number; // already payScale-scaled (from summary.orbits)
66+ alpha: number;
67+ scale: number;
68+ flash: number;
69+ collapse: number; // 0 = on orbit, 1 = in the core
70+ hit: boolean;
71+}
72+
73+interface Ring {
74+ radius: number; // px, animated
75+ target: number;
76+ alpha: number;
77+ rot: number;
78+ speed: number; // deg/s (cosmetic)
79+ objs: Obj[];
80+ flashAt: number;
81+}
82+
83+interface Particle {
84+ x: number;
85+ y: number;
86+ vx: number;
87+ vy: number;
88+ life: number;
89+ max: number;
90+ color: string;
91+ size: number;
92+}
93+
94+interface Pop {
95+ x: number;
96+ y: number;
97+ text: string;
98+ color: string;
99+ at: number;
100+ big?: boolean;
101+}
102+
103+interface Scene {
104+ t: number;
105+ rings: Ring[];
106+ aim: number;
107+ phase: "idle" | "firing";
108+ frozen: boolean;
109+ preview: boolean;
110+ path: { x: number; y: number }[]; // screen-space polyline (normalised units of Rmax)
111+ head: { x: number; y: number } | null;
112+ pathAlpha: number;
113+ ripples: { angle: number; radius: number; at: number }[];
114+ particles: Particle[];
115+ pops: Pop[];
116+ coreFlare: number;
117+ supernova: number; // 0..1 visual intensity
118+ reduceMotion: boolean;
119+}
120+
121+interface Layout {
122+ w: number;
123+ h: number;
124+ cx: number;
125+ cy: number;
126+ rmax: number;
127+ rc: number;
128+}
129+
130+function layoutFor(w: number, h: number): Layout {
131+ const rmax = Math.max(60, Math.min(w, h) / 2 - 26);
132+ return { w, h, cx: w / 2, cy: h / 2, rmax, rc: rmax * 0.13 };
133+}
134+
135+function ringTargets(n: number, L: Layout): number[] {
136+ const out: number[] = [];
137+ for (let i = 0; i < n; i++) out.push(L.rc + (L.rmax - L.rc) * ((i + 1) / n));
138+ return out;
139+}
140+
141+function toObj(o: OrbitObject): Obj {
142+ return { id: o.id, type: o.type, angle: o.angle, value: o.value, alpha: 1, scale: 1, flash: 0, collapse: 0, hit: false };
143+}
144+
145+const TYPE_COLORS: Record<string, string> = {
146+ debris: "#9ca3af",
147+ satellite: "#93c5fd",
148+ planet: "#34d399",
149+ comet: "#f0abfc",
150+ gasgiant: "#fb923c",
151+ quasar: "#fde68a",
152+};
153+
154+const TYPE_SIZE: Record<string, number> = { debris: 0.45, satellite: 0.55, planet: 0.85, comet: 0.7, gasgiant: 1.25, quasar: 1.1 };
155+
156+/* Deterministic cosmetic preview before the first round. */
157+function previewOrbits(cfg: OrbitConfig): OrbitObject[][] {
158+ const types = cfg.objectTypes;
159+ const out: OrbitObject[][] = [];
160+ let seq = 0;
161+ for (let i = 0; i < cfg.orbits; i++) {
162+ const n = 3 + ((i * 2) % 3);
163+ const objs: OrbitObject[] = [];
164+ for (let j = 0; j < n; j++) {
165+ const t = types[(i * 3 + j * 2) % Math.min(types.length, 5)];
166+ objs.push({ id: `p${seq++}`, type: t.id, angle: norm((360 / n) * j + i * 37 + j * 11), value: t.value });
167+ }
168+ out.push(objs);
169+ }
170+ return out;
171+}
172+
173+/* --------------------------------------------------------------- drawing */
174+
175+interface Palette {
176+ primary: string;
177+ secondary: string;
178+ glow: string;
179+ bg: string;
180+ surface: string;
181+}
182+
183+function drawObject(ctx: CanvasRenderingContext2D, o: Obj, x: number, y: number, unit: number, t: number, palette: Palette) {
184+ const color = TYPE_COLORS[o.type] ?? palette.primary;
185+ const r = unit * (TYPE_SIZE[o.type] ?? 0.6) * o.scale;
186+ ctx.save();
187+ ctx.globalAlpha = o.alpha;
188+ ctx.translate(x, y);
189+ if (o.flash > 0) {
190+ ctx.shadowColor = "#ffffff";
191+ ctx.shadowBlur = 30 * o.flash;
192+ } else {
193+ ctx.shadowColor = color;
194+ ctx.shadowBlur = o.type === "quasar" || o.type === "comet" ? 18 : 8;
195+ }
196+ switch (o.type) {
197+ case "debris": {
198+ ctx.rotate(t * 0.8 + o.angle);
199+ ctx.fillStyle = mixHex(color, "#000000", 0.25);
200+ ctx.beginPath();
201+ ctx.moveTo(-r, -r * 0.4);
202+ ctx.lineTo(-r * 0.2, -r);
203+ ctx.lineTo(r * 0.9, -r * 0.5);
204+ ctx.lineTo(r, r * 0.5);
205+ ctx.lineTo(r * 0.1, r);
206+ ctx.lineTo(-r * 0.8, r * 0.6);
207+ ctx.closePath();
208+ ctx.fill();
209+ ctx.strokeStyle = "rgba(255,255,255,0.35)";
210+ ctx.lineWidth = 1;
211+ ctx.stroke();
212+ break;
213+ }
214+ case "satellite": {
215+ ctx.rotate(rad(o.angle) + Math.PI / 2);
216+ ctx.fillStyle = "#1e3a8a";
217+ ctx.fillRect(-r * 2.1, -r * 0.35, r * 1.3, r * 0.7);
218+ ctx.fillRect(r * 0.8, -r * 0.35, r * 1.3, r * 0.7);
219+ ctx.strokeStyle = color;
220+ ctx.lineWidth = 1;
221+ ctx.strokeRect(-r * 2.1, -r * 0.35, r * 1.3, r * 0.7);
222+ ctx.strokeRect(r * 0.8, -r * 0.35, r * 1.3, r * 0.7);
223+ ctx.fillStyle = "#e5e7eb";
224+ ctx.beginPath();
225+ ctx.roundRect(-r * 0.7, -r * 0.6, r * 1.4, r * 1.2, 3);
226+ ctx.fill();
227+ ctx.fillStyle = color;
228+ ctx.beginPath();
229+ ctx.arc(0, 0, r * 0.28, 0, Math.PI * 2);
230+ ctx.fill();
231+ break;
232+ }
233+ case "planet": {
234+ const g = ctx.createRadialGradient(-r * 0.35, -r * 0.35, r * 0.1, 0, 0, r);
235+ g.addColorStop(0, "#d1fae5");
236+ g.addColorStop(0.4, color);
237+ g.addColorStop(1, mixHex(color, "#000000", 0.6));
238+ ctx.fillStyle = g;
239+ ctx.beginPath();
240+ ctx.arc(0, 0, r, 0, Math.PI * 2);
241+ ctx.fill();
242+ ctx.strokeStyle = "rgba(255,255,255,0.35)";
243+ ctx.lineWidth = 1.2;
244+ ctx.beginPath();
245+ ctx.ellipse(0, r * 0.15, r * 0.9, r * 0.28, -0.3, 0, Math.PI * 2);
246+ ctx.stroke();
247+ break;
248+ }
249+ case "comet": {
250+ const dir = rad(o.angle) + Math.PI / 2; // tangent
251+ ctx.rotate(dir);
252+ const tail = ctx.createLinearGradient(0, 0, r * 4.2, 0);
253+ tail.addColorStop(0, rgba(color, 0.9));
254+ tail.addColorStop(1, rgba(color, 0));
255+ ctx.fillStyle = tail;
256+ ctx.beginPath();
257+ ctx.moveTo(0, -r * 0.7);
258+ ctx.lineTo(r * 4.2, 0);
259+ ctx.lineTo(0, r * 0.7);
260+ ctx.closePath();
261+ ctx.fill();
262+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
263+ g.addColorStop(0, "#ffffff");
264+ g.addColorStop(1, color);
265+ ctx.fillStyle = g;
266+ ctx.beginPath();
267+ ctx.arc(0, 0, r * 0.75, 0, Math.PI * 2);
268+ ctx.fill();
269+ break;
270+ }
271+ case "gasgiant": {
272+ const g = ctx.createRadialGradient(-r * 0.4, -r * 0.4, r * 0.1, 0, 0, r);
273+ g.addColorStop(0, "#fed7aa");
274+ g.addColorStop(0.5, color);
275+ g.addColorStop(1, mixHex(color, "#000000", 0.6));
276+ ctx.fillStyle = g;
277+ ctx.beginPath();
278+ ctx.arc(0, 0, r, 0, Math.PI * 2);
279+ ctx.fill();
280+ ctx.strokeStyle = "rgba(0,0,0,0.25)";
281+ ctx.lineWidth = r * 0.16;
282+ for (const k of [-0.45, 0, 0.45]) {
283+ ctx.beginPath();
284+ ctx.ellipse(0, r * k, Math.sqrt(1 - k * k) * r, r * 0.08, 0, 0, Math.PI * 2);
285+ ctx.stroke();
286+ }
287+ ctx.strokeStyle = rgba("#fde68a", 0.8);
288+ ctx.lineWidth = 2;
289+ ctx.beginPath();
290+ ctx.ellipse(0, 0, r * 1.75, r * 0.45, -0.45, 0, Math.PI * 2);
291+ ctx.stroke();
292+ break;
293+ }
294+ case "quasar": {
295+ ctx.rotate(t * 1.6);
296+ ctx.strokeStyle = rgba(color, 0.9);
297+ ctx.lineWidth = 1.5;
298+ for (let i = 0; i < 6; i++) {
299+ ctx.rotate(Math.PI / 3);
300+ ctx.beginPath();
301+ ctx.moveTo(0, -r * 0.4);
302+ ctx.lineTo(0, -r * 1.9);
303+ ctx.stroke();
304+ }
305+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
306+ g.addColorStop(0, "#ffffff");
307+ g.addColorStop(0.5, color);
308+ g.addColorStop(1, rgba(color, 0));
309+ ctx.fillStyle = g;
310+ ctx.beginPath();
311+ ctx.arc(0, 0, r, 0, Math.PI * 2);
312+ ctx.fill();
313+ break;
314+ }
315+ default: {
316+ ctx.fillStyle = color;
317+ ctx.beginPath();
318+ ctx.arc(0, 0, r, 0, Math.PI * 2);
319+ ctx.fill();
320+ }
321+ }
322+ ctx.restore();
323+}
324+
325+function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: OrbitConfig, palette: Palette) {
326+ const { w, h, cx, cy, rmax, rc } = L;
327+ ctx.clearRect(0, 0, w, h);
328+ const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`;
329+ const unit = rmax * 0.06;
330+
331+ // Star dust
332+ ctx.save();
333+ ctx.fillStyle = "rgba(255,255,255,0.25)";
334+ for (let i = 0; i < 40; i++) {
335+ const a = i * 2.399 + 0.3;
336+ const r = ((i * 97) % 100) / 100;
337+ const x = cx + Math.cos(a) * r * Math.max(w, h) * 0.7;
338+ const y = cy + Math.sin(a) * r * Math.max(w, h) * 0.7;
339+ const tw = 0.5 + 0.5 * Math.sin(s.t * (0.6 + (i % 5) * 0.2) + i);
340+ ctx.globalAlpha = 0.15 + 0.35 * tw;
341+ ctx.fillRect(x, y, 1.5, 1.5);
342+ }
343+ ctx.restore();
344+
345+ // Aim cone (idle)
346+ if (s.phase === "idle" && !s.reduceMotion) {
347+ ctx.save();
348+ ctx.translate(cx, cy);
349+ const a = rad(s.aim);
350+ const half = (cfg.beamHalfWidth * Math.PI) / 180;
351+ const g = ctx.createLinearGradient(0, 0, Math.cos(a) * rmax, Math.sin(a) * rmax);
352+ g.addColorStop(0, rgba(palette.primary, 0.22));
353+ g.addColorStop(1, rgba(palette.primary, 0));
354+ ctx.fillStyle = g;
355+ ctx.beginPath();
356+ ctx.moveTo(0, 0);
357+ ctx.arc(0, 0, rmax + 8, a - half, a + half);
358+ ctx.closePath();
359+ ctx.fill();
360+ ctx.setLineDash([4, 6]);
361+ ctx.strokeStyle = rgba(palette.glow, 0.7);
362+ ctx.lineWidth = 1.5;
363+ ctx.beginPath();
364+ ctx.moveTo(Math.cos(a) * rc, Math.sin(a) * rc);
365+ ctx.lineTo(Math.cos(a) * (rmax + 8), Math.sin(a) * (rmax + 8));
366+ ctx.stroke();
367+ ctx.setLineDash([]);
368+ // handle
369+ ctx.fillStyle = palette.glow;
370+ ctx.shadowColor = palette.glow;
371+ ctx.shadowBlur = 16;
372+ ctx.beginPath();
373+ ctx.arc(Math.cos(a) * (rmax + 14), Math.sin(a) * (rmax + 14), 7, 0, Math.PI * 2);
374+ ctx.fill();
375+ ctx.restore();
376+ } else if (s.phase === "idle") {
377+ ctx.save();
378+ ctx.translate(cx, cy);
379+ const a = rad(s.aim);
380+ ctx.strokeStyle = rgba(palette.glow, 0.7);
381+ ctx.lineWidth = 1.5;
382+ ctx.beginPath();
383+ ctx.moveTo(Math.cos(a) * rc, Math.sin(a) * rc);
384+ ctx.lineTo(Math.cos(a) * (rmax + 8), Math.sin(a) * (rmax + 8));
385+ ctx.stroke();
386+ ctx.fillStyle = palette.glow;
387+ ctx.beginPath();
388+ ctx.arc(Math.cos(a) * (rmax + 14), Math.sin(a) * (rmax + 14), 7, 0, Math.PI * 2);
389+ ctx.fill();
390+ ctx.restore();
391+ }
392+
393+ // Orbit rings
394+ for (const ring of s.rings) {
395+ if (ring.alpha <= 0) continue;
396+ ctx.save();
397+ ctx.globalAlpha = ring.alpha;
398+ const flash = Math.max(0, 1 - (s.t - ring.flashAt) / 0.9);
399+ ctx.strokeStyle = rgba(palette.primary, 0.18 + 0.6 * flash);
400+ ctx.lineWidth = 1 + 2 * flash;
401+ ctx.shadowColor = palette.primary;
402+ ctx.shadowBlur = 18 * flash;
403+ ctx.beginPath();
404+ ctx.arc(cx, cy, ring.radius, 0, Math.PI * 2);
405+ ctx.stroke();
406+ ctx.restore();
407+ }
408+
409+ // Core
410+ const flare = s.coreFlare;
411+ const coreR = rc * (1 + 0.06 * Math.sin(s.t * 3)) * (1 + 0.6 * flare) + s.supernova * rmax * 1.3;
412+ ctx.save();
413+ const halo = ctx.createRadialGradient(cx, cy, coreR * 0.5, cx, cy, coreR * 2.6);
414+ halo.addColorStop(0, rgba(palette.secondary, 0.45 + 0.4 * flare));
415+ halo.addColorStop(1, rgba(palette.secondary, 0));
416+ ctx.fillStyle = halo;
417+ ctx.beginPath();
418+ ctx.arc(cx, cy, coreR * 2.6, 0, Math.PI * 2);
419+ ctx.fill();
420+ const core = ctx.createRadialGradient(cx - coreR * 0.2, cy - coreR * 0.2, coreR * 0.1, cx, cy, coreR);
421+ core.addColorStop(0, "#fff7ed");
422+ core.addColorStop(0.45, palette.secondary);
423+ core.addColorStop(1, mixHex(palette.secondary, "#7c2d12", 0.7));
424+ ctx.fillStyle = core;
425+ ctx.shadowColor = palette.secondary;
426+ ctx.shadowBlur = 30 + 60 * flare;
427+ ctx.beginPath();
428+ ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
429+ ctx.fill();
430+ ctx.restore();
431+
432+ // Objects
433+ for (const ring of s.rings) {
434+ for (const o of ring.objs) {
435+ if (o.alpha <= 0) continue;
436+ const a = rad(o.angle + ring.rot);
437+ const r = lerp(ring.radius, 0, o.collapse);
438+ drawObject(ctx, o, cx + Math.cos(a) * r, cy + Math.sin(a) * r, unit, s.t, palette);
439+ }
440+ }
441+
442+ // Impulse path
443+ if (s.path.length > 1 && s.pathAlpha > 0) {
444+ ctx.save();
445+ ctx.globalAlpha = s.pathAlpha;
446+ ctx.strokeStyle = palette.glow;
447+ ctx.lineWidth = 3;
448+ ctx.lineCap = "round";
449+ ctx.lineJoin = "round";
450+ ctx.shadowColor = palette.primary;
451+ ctx.shadowBlur = 18;
452+ ctx.beginPath();
453+ ctx.moveTo(cx + s.path[0].x * rmax, cy + s.path[0].y * rmax);
454+ for (let i = 1; i < s.path.length; i++) ctx.lineTo(cx + s.path[i].x * rmax, cy + s.path[i].y * rmax);
455+ if (s.head) ctx.lineTo(cx + s.head.x * rmax, cy + s.head.y * rmax);
456+ ctx.stroke();
457+ ctx.strokeStyle = "#ffffff";
458+ ctx.lineWidth = 1;
459+ ctx.stroke();
460+ if (s.head && s.phase === "firing") {
461+ ctx.fillStyle = "#ffffff";
462+ ctx.shadowBlur = 24;
463+ ctx.beginPath();
464+ ctx.arc(cx + s.head.x * rmax, cy + s.head.y * rmax, 5, 0, Math.PI * 2);
465+ ctx.fill();
466+ }
467+ ctx.restore();
468+ }
469+
470+ // Ripples (misses)
471+ for (const rp of s.ripples) {
472+ const age = s.t - rp.at;
473+ if (age > 0.6) continue;
474+ const a = rad(rp.angle);
475+ ctx.save();
476+ ctx.globalAlpha = 1 - age / 0.6;
477+ ctx.strokeStyle = "rgba(255,255,255,0.6)";
478+ ctx.lineWidth = 1.5;
479+ ctx.beginPath();
480+ ctx.arc(cx + Math.cos(a) * rp.radius, cy + Math.sin(a) * rp.radius, 4 + 22 * easeOutCubic(age / 0.6), 0, Math.PI * 2);
481+ ctx.stroke();
482+ ctx.restore();
483+ }
484+
485+ // Particles
486+ for (const p of s.particles) {
487+ const k = p.life / p.max;
488+ ctx.globalAlpha = k;
489+ ctx.fillStyle = p.color;
490+ ctx.beginPath();
491+ ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2);
492+ ctx.fill();
493+ }
494+ ctx.globalAlpha = 1;
495+
496+ // Pops
497+ for (const pop of s.pops) {
498+ const age = s.t - pop.at;
499+ const life = pop.big ? 1.8 : 1.2;
500+ if (age > life) continue;
501+ const k = age < 0.22 ? easeOutBack(age / 0.22) : 1;
502+ const fade = age > life - 0.4 ? (life - age) / 0.4 : 1;
503+ ctx.save();
504+ ctx.globalAlpha = Math.max(0, fade);
505+ ctx.translate(pop.x, pop.y - (pop.big ? 0 : age * 22));
506+ ctx.scale(k, k);
507+ ctx.font = font(pop.big ? Math.min(64, w * 0.15) : 15, 900);
508+ ctx.textAlign = "center";
509+ ctx.textBaseline = "middle";
510+ ctx.shadowColor = pop.color;
511+ ctx.shadowBlur = pop.big ? 40 : 12;
512+ ctx.fillStyle = pop.color;
513+ ctx.fillText(pop.text, 0, 0);
514+ ctx.restore();
515+ }
516+
517+ // Angle ticks
518+ ctx.save();
519+ ctx.strokeStyle = "rgba(255,255,255,0.18)";
520+ ctx.lineWidth = 1;
521+ for (let d = 0; d < 360; d += 30) {
522+ const a = rad(d);
523+ ctx.beginPath();
524+ ctx.moveTo(cx + Math.cos(a) * (rmax + 18), cy + Math.sin(a) * (rmax + 18));
525+ ctx.lineTo(cx + Math.cos(a) * (rmax + 24), cy + Math.sin(a) * (rmax + 24));
526+ ctx.stroke();
527+ }
528+ ctx.restore();
529+
530+ if (s.preview) {
531+ ctx.save();
532+ ctx.font = font(11, 700);
533+ ctx.textAlign = "center";
534+ ctx.textBaseline = "bottom";
535+ ctx.fillStyle = "rgba(255,255,255,0.45)";
536+ ctx.fillText("PREVIEW SYSTEM · FIRE TO GENERATE THE REAL ORBITS", w / 2, h - 6);
537+ ctx.restore();
538+ }
539+}
540+
541+/* ------------------------------------------------------------- component */
542+
543+export function OrbitGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {
544+ const cfg = definition.config as unknown as OrbitConfig;
545+ const palette = definition.presentation.palette;
546+ const payScale = definition.payScale;
547+ const { play, error, clearError } = useInstantPlay<OrbitOutcome>(definition.slug);
548+ const [aim, setAim] = useState(0);
549+ const [phase, setPhase] = useState<"idle" | "firing">("idle");
550+ const [collected, setCollected] = useState(0);
551+ const [live, setLive] = useState<string | null>(null);
552+ const [result, setResult] = useState<{ win: number; multiplier: number; hits: number; supernova: number | null } | null>(null);
553+ const canvasRef = useRef<HTMLCanvasElement>(null);
554+ const sceneRef = useRef<Scene | null>(null);
555+ const aliveRef = useRef(true);
556+ const phaseRef = useRef<"idle" | "firing">("idle");
557+
558+ const getScene = useCallback((): Scene => {
559+ if (!sceneRef.current) {
560+ const orbits = previewOrbits(cfg);
561+ sceneRef.current = {
562+ t: 0,
563+ rings: orbits.map((objs, i) => ({ radius: 0, target: 0, alpha: 1, rot: 0, speed: (i % 2 === 0 ? 1 : -1) * (5 - i), objs: objs.map(toObj), flashAt: -10 })),
564+ aim: 0,
565+ phase: "idle",
566+ frozen: false,
567+ preview: true,
568+ path: [],
569+ head: null,
570+ pathAlpha: 0,
571+ ripples: [],
572+ particles: [],
573+ pops: [],
574+ coreFlare: 0,
575+ supernova: 0,
576+ reduceMotion: false,
577+ };
578+ }
579+ return sceneRef.current;
580+ }, [cfg]);
581+
582+ useEffect(() => {
583+ aliveRef.current = true;
584+ const canvas = canvasRef.current;
585+ if (!canvas) return;
586+ const ctx = canvas.getContext("2d");
587+ if (!ctx) return;
588+ const s = getScene();
589+ let raf = 0;
590+ let last = performance.now();
591+ const loop = (now: number) => {
592+ const dt = Math.min(0.05, (now - last) / 1000);
593+ last = now;
594+ s.t += dt;
595+ const dpr = Math.min(2, window.devicePixelRatio || 1);
596+ const rect = canvas.getBoundingClientRect();
597+ const W = Math.max(1, Math.round(rect.width * dpr));
598+ const H = Math.max(1, Math.round(rect.height * dpr));
599+ if (canvas.width !== W || canvas.height !== H) {
600+ canvas.width = W;
601+ canvas.height = H;
602+ }
603+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
604+ const L = layoutFor(rect.width, rect.height);
605+ const targets = ringTargets(s.rings.length, L);
606+ s.rings.forEach((ring, i) => {
607+ ring.target = targets[i];
608+ ring.radius = ring.radius === 0 ? ring.target : lerp(ring.radius, ring.target, Math.min(1, dt * 6));
609+ if (!s.frozen && !s.reduceMotion) ring.rot += ring.speed * dt;
610+ });
611+ for (let i = s.particles.length - 1; i >= 0; i--) {
612+ const p = s.particles[i];
613+ p.life -= dt;
614+ p.x += p.vx * dt;
615+ p.y += p.vy * dt;
616+ p.vx *= 0.985;
617+ p.vy *= 0.985;
618+ if (p.life <= 0) s.particles.splice(i, 1);
619+ }
620+ s.ripples = s.ripples.filter((r) => s.t - r.at < 0.7);
621+ s.pops = s.pops.filter((p) => s.t - p.at < 2);
622+ s.coreFlare = Math.max(0, s.coreFlare - dt * 1.4);
623+ drawScene(ctx, L, s, cfg, palette);
624+ raf = requestAnimationFrame(loop);
625+ };
626+ raf = requestAnimationFrame(loop);
627+ return () => {
628+ aliveRef.current = false;
629+ cancelAnimationFrame(raf);
630+ };
631+ }, [cfg, palette, getScene]);
632+
633+ useEffect(() => {
634+ const s = getScene();
635+ s.aim = aim;
636+ s.reduceMotion = reduceMotion;
637+ }, [aim, reduceMotion, getScene]);
638+
639+ const onPointer = useCallback(
640+ (e: React.PointerEvent<HTMLCanvasElement>) => {
641+ if (phaseRef.current !== "idle") return;
642+ if (e.type === "pointermove" && e.buttons === 0) return;
643+ const rect = e.currentTarget.getBoundingClientRect();
644+ const L = layoutFor(rect.width, rect.height);
645+ const dx = e.clientX - rect.left - L.cx;
646+ const dy = e.clientY - rect.top - L.cy;
647+ if (Math.hypot(dx, dy) < L.rc * 0.6) return;
648+ const deg = Math.round(norm((Math.atan2(dy, dx) * 180) / Math.PI + 90));
649+ setAim((prev) => {
650+ if (Math.abs(prev - deg) >= 3) sound("tick");
651+ return deg;
652+ });
653+ },
654+ [sound],
655+ );
656+
657+ const burst = useCallback((s: Scene, x: number, y: number, n: number, color: string, speed: number) => {
658+ if (s.reduceMotion) return;
659+ for (let i = 0; i < n; i++) {
660+ const a = Math.random() * Math.PI * 2;
661+ const v = speed * (0.3 + Math.random() * 0.9);
662+ s.particles.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.4 + Math.random() * 0.5, max: 0.9, color, size: 1.5 + Math.random() * 2.5 });
663+ }
664+ }, []);
665+
666+ const animate = useCallback(
667+ async (outcome: OrbitOutcome) => {
668+ const s = getScene();
669+ const alive = () => aliveRef.current;
670+ const rm = s.reduceMotion;
671+ const canvas = canvasRef.current;
672+ const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 500 };
673+ const L = layoutFor(rect.width, rect.height);
674+ const orbits = outcome.summary.orbits;
675+ s.phase = "firing";
676+ s.frozen = true;
677+ s.preview = false;
678+ s.particles = [];
679+ s.pops = [];
680+ s.ripples = [];
681+ s.path = [];
682+ s.head = null;
683+ s.pathAlpha = 0;
684+ s.supernova = 0;
685+
686+ // Swap in the real system: old objects fade, new ones appear at the server's angles.
687+ const old = s.rings;
688+ await tween(rm ? 60 : 260, (t) => {
689+ for (const r of old) for (const o of r.objs) o.alpha = 1 - t;
690+ }, alive);
691+ const initial = Math.min(cfg.orbits, orbits.length);
692+ s.rings = orbits.slice(0, initial).map((objs, i) => ({ radius: old[i]?.radius ?? 0, target: 0, alpha: 1, rot: 0, speed: (i % 2 === 0 ? 1 : -1) * (5 - i), objs: objs.map(toObj), flashAt: -10 }));
693+ for (const r of s.rings) for (const o of r.objs) o.alpha = 0;
694+ await tween(rm ? 60 : 260, (t) => {
695+ for (const r of s.rings) for (const o of r.objs) o.alpha = t;
696+ }, alive);
697+
698+ // Impulse leaves the core.
699+ s.coreFlare = 1;
700+ sound("click");
701+ const startA = rad(outcome.steps[0]?.angle ?? outcome.summary.angle ?? 0);
702+ const unitPt = (angleDeg: number, radiusPx: number) => ({ x: (Math.cos(rad(angleDeg)) * radiusPx) / L.rmax, y: (Math.sin(rad(angleDeg)) * radiusPx) / L.rmax });
703+ s.path = [{ x: (Math.cos(startA) * L.rc) / L.rmax, y: (Math.sin(startA) * L.rc) / L.rmax }];
704+ s.pathAlpha = 1;
705+ let spawnIndex = initial;
706+ let running = 0;
707+ for (let i = 0; i < outcome.steps.length; i++) {
708+ if (!alive()) return;
709+ const step = outcome.steps[i];
710+ const ring = s.rings[step.orbit];
711+ if (!ring) break;
712+ const from = s.path[s.path.length - 1];
713+ const to = unitPt(step.angle, ring.target || ringTargets(s.rings.length, L)[step.orbit]);
714+ await tween(rm ? 50 : 240, (t) => {
715+ const k = easeInCubic(t) * 0.4 + t * 0.6;
716+ s.head = { x: lerp(from.x, to.x, k), y: lerp(from.y, to.y, k) };
717+ }, alive);
718+ s.path.push(to);
719+ s.head = null;
720+ const hx = L.cx + to.x * L.rmax;
721+ const hy = L.cy + to.y * L.rmax;
722+ if (!step.hit) {
723+ s.ripples.push({ angle: step.angle, radius: ring.radius, at: s.t });
724+ continue;
725+ }
726+ const obj = ring.objs.find((o) => o.id === step.hit?.id);
727+ const type = cfg.objectTypes.find((t) => t.id === step.hit?.type);
728+ const shownValue = obj?.value ?? Math.round(step.hit.value * payScale * 100) / 100;
729+ running += step.hit.value;
730+ const color = TYPE_COLORS[step.hit.type] ?? palette.primary;
731+ if (obj) {
732+ obj.hit = true;
733+ await tween(rm ? 40 : 180, (t) => {
734+ obj.flash = Math.sin(t * Math.PI);
735+ obj.scale = 1 + 0.5 * Math.sin(t * Math.PI);
736+ }, alive);
737+ burst(s, hx, hy, 16, color, 200);
738+ void tween(rm ? 40 : 240, (t) => {
739+ obj.alpha = 1 - t;
740+ obj.scale = 1 + t;
741+ }, alive);
742+ }
743+ s.pops.push({ x: hx, y: hy - 18, text: `+${formatMultiplier(shownValue)}`, color, at: s.t });
744+ const runningScaled = Math.round(running * payScale * 100) / 100;
745+ setCollected(runningScaled);
746+ setLive(`${type?.label ?? step.hit.type} +${formatMultiplier(shownValue)}`);
747+ sound(step.hit.type === "gasgiant" || step.hit.type === "quasar" ? "bonus" : "tick");
748+ if (step.deflectedTo !== undefined) {
749+ setLive(`${type?.label ?? "Object"} deflects the impulse → ${Math.round(step.deflectedTo)}°`);
750+ await wait(rm ? 40 : 160);
751+ }
752+ if (step.spawned && spawnIndex < orbits.length) {
753+ const objs = orbits[spawnIndex];
754+ const newRing: Ring = { radius: L.rmax + 40, target: 0, alpha: 0, rot: 0, speed: (spawnIndex % 2 === 0 ? 1 : -1) * Math.max(1.5, 5 - spawnIndex), objs: objs.map(toObj), flashAt: s.t };
755+ for (const o of newRing.objs) o.alpha = 0;
756+ s.rings.push(newRing);
757+ spawnIndex++;
758+ sound("bonus");
759+ setLive(`New orbit spawned · ${s.rings.length} orbits`);
760+ await tween(rm ? 60 : 420, (t) => {
761+ newRing.alpha = t;
762+ for (const o of newRing.objs) o.alpha = t;
763+ }, alive);
764+ } else {
765+ await wait(rm ? 30 : 120);
766+ }
767+ }
768+
769+ if (!alive()) return;
770+ // Impulse leaves the system.
771+ const last = s.path[s.path.length - 1];
772+ const dirLen = Math.hypot(last.x, last.y) || 1;
773+ const out = { x: (last.x / dirLen) * 1.25, y: (last.y / dirLen) * 1.25 };
774+ await tween(rm ? 40 : 200, (t) => {
775+ s.head = { x: lerp(last.x, out.x, t), y: lerp(last.y, out.y, t) };
776+ }, alive);
777+ s.path.push(out);
778+ s.head = null;
779+ void tween(rm ? 200 : 1400, (t) => (s.pathAlpha = 1 - t), alive);
780+
781+ // Supernova
782+ const sn = outcome.summary.supernova;
783+ if (sn) {
784+ sound("bonus");
785+ setLive(`SUPERNOVA · ${sn.remaining} survivors`);
786+ s.pops.push({ x: L.cx, y: L.cy - L.rmax * 0.55, text: "SUPERNOVA", color: palette.secondary, at: s.t, big: true });
787+ await wait(rm ? 80 : 500);
788+ const survivors = s.rings.flatMap((r) => r.objs.filter((o) => !o.hit));
789+ await tween(rm ? 120 : 900, (t) => {
790+ const k = easeInCubic(t);
791+ for (const o of survivors) {
792+ o.collapse = k;
793+ o.scale = 1 - 0.6 * k;
794+ }
795+ s.coreFlare = 1;
796+ }, alive);
797+ for (const o of survivors) o.alpha = 0;
798+ burst(s, L.cx, L.cy, 60, "#ffffff", 420);
799+ sound("bigWin");
800+ await tween(rm ? 100 : 700, (t) => {
801+ s.supernova = Math.sin(t * Math.PI) * 0.9;
802+ s.coreFlare = 1;
803+ }, alive);
804+ s.supernova = 0;
805+ s.pops.push({ x: L.cx, y: L.cy, text: `×${sn.multiplier.toFixed(1).replace(/\.0$/, "")}`, color: "#ffd66b", at: s.t, big: true });
806+ setCollected(Math.round(running * sn.multiplier * payScale * 100) / 100);
807+ await wait(rm ? 100 : 900);
808+ }
809+
810+ if (!alive()) return;
811+ setCollected(outcome.multiplier);
812+ setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, hits: outcome.summary.hits, supernova: sn ? sn.multiplier : null });
813+ if (outcome.totalWin <= 0) sound("lose");
814+ else if (outcome.multiplier >= 15) sound("bigWin");
815+ else sound("win");
816+ onResult({ win: outcome.totalWin, multiplier: outcome.multiplier });
817+ s.phase = "idle";
818+ s.frozen = false;
819+ },
820+ [cfg, palette, payScale, getScene, sound, onResult, burst],
821+ );
822+
823+ const fire = useCallback(async () => {
824+ if (phaseRef.current !== "idle") return;
825+ phaseRef.current = "firing";
826+ setPhase("firing");
827+ setResult(null);
828+ setLive(null);
829+ setCollected(0);
830+ onBusy(true);
831+ sound("click");
832+ const res = await play(bet, { angle: aim });
833+ if (res && aliveRef.current) await animate(res.outcome);
834+ phaseRef.current = "idle";
835+ if (aliveRef.current) {
836+ setPhase("idle");
837+ const s = sceneRef.current;
838+ if (s) {
839+ s.phase = "idle";
840+ s.frozen = false;
841+ }
842+ }
843+ onBusy(false);
844+ }, [play, bet, aim, animate, onBusy, sound]);
845+
846+ const firing = phase === "firing";
847+ const nudge = (d: number) => {
848+ sound("tick");
849+ setAim((a) => norm(a + d));
850+ };
851+
852+ return (
853+ <div className="absolute inset-0 flex flex-col">
854+ {/* Aim + collected */}
855+ <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2">
856+ <div className="flex items-center gap-1">
857+ <button disabled={firing} onClick={() => nudge(-5)} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Rotate aim counter-clockwise">
858+ <RotateCcw className="h-4 w-4" />
859+ </button>
860+ <div className="flex h-11 min-w-[78px] flex-col items-center justify-center rounded-md surface-2 px-2">
861+ <span className="text-[10px] uppercase tracking-wider text-fg-3">Aim</span>
862+ <span className="text-sm font-bold tabular">{aim}°</span>
863+ </div>
864+ <button disabled={firing} onClick={() => nudge(5)} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Rotate aim clockwise">
865+ <RotateCw className="h-4 w-4" />
866+ </button>
867+ </div>
868+ <div className="text-right leading-tight">
869+ <div className="eyebrow">{firing ? "Collected" : "Round"}</div>
870+ <div className={cn("text-sm font-bold tabular", collected > 0 ? "text-credit" : "text-fg-3")}>{collected > 0 ? formatMultiplier(collected) : "—"}</div>
871+ </div>
872+ </div>
873+
874+ {/* System */}
875+ <div className="relative min-h-0 flex-1">
876+ <canvas ref={canvasRef} className={cn("absolute inset-0 h-full w-full touch-none", firing ? "cursor-default" : "cursor-crosshair")} onPointerDown={onPointer} onPointerMove={onPointer} role="img" aria-label="Orbital system. Drag around the core to aim the impulse." />
877+ <AnimatePresence>
878+ {live && firing ? (
879+ <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-1 -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 }}>
880+ {live}
881+ </motion.div>
882+ ) : null}
883+ {result && !firing ? (
884+ <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-1 -translate-x-1/2 glass whitespace-nowrap rounded-full px-4 py-1.5 text-center">
885+ <span className={cn("text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "The impulse missed everything"}</span>
886+ <span className="ml-2 text-[11px] text-fg-3">
887+ {result.hits} hit{result.hits === 1 ? "" : "s"}
888+ {result.supernova ? ` · supernova ×${result.supernova.toFixed(1).replace(/\.0$/, "")}` : ""}
889+ </span>
890+ </motion.div>
891+ ) : null}
892+ {error ? (
893+ <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">
894+ <div className="text-fg-2">{error}</div>
895+ <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring">
896+ Dismiss
897+ </button>
898+ </motion.div>
899+ ) : null}
900+ </AnimatePresence>
901+ </div>
902+
903+ {/* Action */}
904+ <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2">
905+ <button
906+ onClick={() => void fire()}
907+ disabled={firing}
908+ className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#1a0b26] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring"
909+ 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}` }}
910+ aria-label={definition.presentation.verb}
911+ >
912+ {firing ? "IMPULSE IN FLIGHT…" : `${definition.presentation.verb} · ${formatSC(bet)}`}
913+ </button>
914+ </div>
915+ </div>
916+ );
917+}
added apps/web/src/components/arcade/vault.tsx +485 −0
@@ -0,0 +1,485 @@
1+"use client";
2+
3+/**
4+ * The Vault — Spinza Original (ladder). A colossal five-layer vault door:
5+ * every layer reveals three digits one by one; after each opened layer the
6+ * player SECUREs the current multiplier or OPENs the next lock. Outcomes come
7+ * from the server; this component only animates the returned `log` tail.
8+ */
9+import { useCallback, useEffect, useMemo, useState } from "react";
10+import { AnimatePresence, motion } from "framer-motion";
11+import { Atom, Clock, Fingerprint, Gauge, Lock, LockOpen, RotateCcw, ShieldCheck, Siren } from "lucide-react";
12+import { formatMultiplier, formatSC } from "@spinza/shared";
13+import type { LadderEvent, LadderOffer } from "@spinza/game-core/client";
14+import { cn } from "@/lib/utils";
15+import { Button } from "@/components/ui";
16+import { useLadder, type ArcadeGameProps, type LadderView } from "./contract";
17+
18+interface VaultConfig {
19+ layers: number[];
20+ digitsPerLayer: number;
21+ layerNames: string[];
22+}
23+
24+/** Reveal animation in progress for one layer (drives the digit wheels). */
25+interface Reveal {
26+ layer: number;
27+ digits: number[];
28+ shown: number;
29+ outcome: "ok" | "bust" | null;
30+}
31+
32+type Phase = "idle" | "reveal" | "running" | "cashed" | "busted" | "completed";
33+type WheelState = { kind: "idle" } | { kind: "spin" } | { kind: "fail" } | { kind: "locked"; digit: number };
34+
35+const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
36+const EASE = [0.16, 1, 0.3, 1] as const;
37+const LAYER_SIZES = [100, 82, 65, 49, 34];
38+const LAYER_ICONS = [Lock, Clock, Fingerprint, Gauge, Atom];
39+const DANGER = "#ff5c7a";
40+const WHEEL_H = 56;
41+
42+/** Same arithmetic as the server: m₀ = rtp, m_k = floor₂(m_{k−1} / p_k). */
43+export function vaultLadder(rtp: number, layers: number[]): number[] {
44+ const out: number[] = [];
45+ let m = rtp;
46+ for (const p of layers) {
47+ m = m / p; // full precision like the server; displayed values are floored to 2 decimals
48+ out.push(Math.floor(m * 100) / 100);
49+ }
50+ return out;
51+}
52+
53+function lastReveal(log: LadderEvent[]): LadderEvent | undefined {
54+ for (let i = log.length - 1; i >= 0; i--) {
55+ const k = log[i].kind;
56+ if (k === "layer" || k === "alarm") return log[i];
57+ }
58+ return undefined;
59+}
60+
61+function digitsOf(ev: LadderEvent | undefined): number[] {
62+ const d = ev?.data?.digits;
63+ return Array.isArray(d) ? d.filter((x): x is number => typeof x === "number") : [];
64+}
65+
66+export function VaultGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {
67+ const cfg = definition.config as unknown as VaultConfig;
68+ const palette = definition.presentation.palette;
69+ const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug);
70+ const [anim, setAnim] = useState<Reveal | null>(null);
71+ const [celebrate, setCelebrate] = useState(false);
72+ const ladder = useMemo(() => vaultLadder(definition.rtp, cfg.layers), [definition.rtp, cfg.layers]);
73+ const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]);
74+ const sec = (ms: number) => dur(ms) / 1000;
75+
76+ /* ------------------------------------------------------------ derived */
77+ const phase: Phase = anim ? "reveal" : !session ? "idle" : session.status;
78+ const layersOpen = anim ? anim.layer : session ? Number(session.extra.layersOpen ?? 0) : 0;
79+ const runBet = session?.bet ?? bet;
80+ // While a layer is being revealed, keep showing the multiplier secured before it.
81+ const current = anim ? (session?.log.find((e) => e.kind === "layer" && e.stage === anim.layer)?.multiplierAfter ?? (anim.layer > 0 ? ladder[anim.layer - 1] : definition.rtp)) : (session?.current ?? 0);
82+ const offer: LadderOffer | undefined = session?.offers[0];
83+ const running = session?.status === "running";
84+ const locked = busy || anim !== null;
85+ const revealedDigits: number[] = useMemo(() => {
86+ const d = session?.extra.digits;
87+ return Array.isArray(d) ? d.filter((x): x is number => typeof x === "number") : [];
88+ }, [session]);
89+ const failedEvent = phase === "busted" && session ? lastReveal(session.log) : undefined;
90+ const failedDigits = digitsOf(failedEvent);
91+ const activeLayer = Math.min(layersOpen, cfg.layers.length - 1);
92+
93+ const wheels: WheelState[] = Array.from({ length: cfg.digitsPerLayer }, (_, i) => {
94+ if (anim) {
95+ if (i < anim.shown) return { kind: "locked", digit: anim.digits[i] ?? 0 };
96+ if (anim.outcome === "bust") return i === anim.digits.length ? { kind: "fail" } : { kind: "idle" };
97+ return { kind: "spin" };
98+ }
99+ if (failedEvent) {
100+ if (i < failedDigits.length) return { kind: "locked", digit: failedDigits[i] };
101+ if (i === failedDigits.length) return { kind: "fail" };
102+ }
103+ return { kind: "idle" };
104+ });
105+
106+ // Resume: a running session locks the shell's bet controls.
107+ useEffect(() => {
108+ if (running) onBusy(true);
109+ }, [running, onBusy]);
110+
111+ /* ------------------------------------------------------------ sequence */
112+ const playTail = useCallback(
113+ async (s: LadderView) => {
114+ const ev = lastReveal(s.log);
115+ if (ev) {
116+ const digits = digitsOf(ev);
117+ setAnim({ layer: Math.max(0, ev.stage - 1), digits, shown: 0, outcome: null });
118+ await wait(dur(320));
119+ for (let i = 0; i < digits.length; i++) {
120+ await wait(dur(350));
121+ sound("tick");
122+ setAnim((a) => (a ? { ...a, shown: i + 1 } : a));
123+ }
124+ if (ev.outcome === "bust") {
125+ await wait(dur(420));
126+ sound("lose");
127+ setAnim((a) => (a ? { ...a, outcome: "bust" } : a));
128+ await wait(dur(1300));
129+ } else {
130+ await wait(dur(260));
131+ sound("bonus");
132+ setAnim((a) => (a ? { ...a, outcome: "ok" } : a));
133+ await wait(dur(760));
134+ }
135+ setAnim(null);
136+ }
137+ if (s.status === "completed") {
138+ await wait(dur(600));
139+ setCelebrate(true);
140+ sound("bigWin");
141+ }
142+ if (s.status !== "running") {
143+ onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 });
144+ onBusy(false);
145+ }
146+ },
147+ [dur, sound, onResult, onBusy],
148+ );
149+
150+ const onStart = async () => {
151+ if (locked) return;
152+ sound("click");
153+ setCelebrate(false);
154+ if (session && session.status !== "running") reset();
155+ onBusy(true);
156+ const res = await start(bet);
157+ if (!res) {
158+ onBusy(false);
159+ return;
160+ }
161+ await playTail(res.session);
162+ };
163+
164+ const onOpen = async () => {
165+ if (!session || locked) return;
166+ sound("click");
167+ const res = await act({ type: "continue" });
168+ if (res) await playTail(res.session);
169+ };
170+
171+ const onSecure = async () => {
172+ if (!session || locked) return;
173+ sound("click");
174+ const res = await act({ type: "cashout" });
175+ if (!res) return;
176+ sound("win");
177+ onResult({ win: res.session.win, multiplier: res.session.bet ? res.session.win / res.session.bet : 0 });
178+ onBusy(false);
179+ };
180+
181+ const onNewRun = () => {
182+ sound("click");
183+ setCelebrate(false);
184+ reset();
185+ };
186+
187+ /* -------------------------------------------------------------- render */
188+ const ended = phase === "cashed" || phase === "busted" || phase === "completed";
189+ const strobe = phase === "busted" || anim?.outcome === "bust";
190+ const ActiveIcon = LAYER_ICONS[activeLayer] ?? Lock;
191+ const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff";
192+
193+ return (
194+ <div className="absolute inset-0 flex flex-col overflow-hidden" data-scene="vault">
195+ {/* Ambient light */}
196+ <div
197+ className="pointer-events-none absolute inset-0"
198+ style={{
199+ background: `radial-gradient(60% 45% at 50% 42%, ${palette.primary}${phase === "completed" ? "55" : "22"} 0%, transparent 70%), radial-gradient(80% 40% at 50% 100%, ${palette.surface} 0%, transparent 70%)`,
200+ transition: "background 900ms",
201+ }}
202+ />
203+ {/* Alarm strobe */}
204+ <AnimatePresence>
205+ {strobe ? (
206+ <motion.div key="strobe" className="pointer-events-none absolute inset-0 z-[3]" style={{ background: `radial-gradient(70% 60% at 50% 50%, ${DANGER}55 0%, ${DANGER}22 60%, transparent 100%)` }} initial={{ opacity: 0 }} animate={{ opacity: reduceMotion ? [0, 0.7, 0] : [0, 1, 0, 0.8, 0, 0.5, 0, 0.3, 0] }} exit={{ opacity: 0 }} transition={{ duration: sec(1800), ease: "linear" }} />
207+ ) : null}
208+ </AnimatePresence>
209+
210+ <div className="relative z-[2] mx-auto flex h-full w-full max-w-3xl flex-col px-3 pb-2 pt-1">
211+ {/* Stats */}
212+ <div className="flex items-end justify-between gap-3">
213+ <div>
214+ <div className="eyebrow">{phase === "busted" ? "Alarm" : phase === "cashed" ? "Secured" : phase === "completed" ? "Jackpot" : "Secured multiplier"}</div>
215+ <motion.div key={`${phase}-${current}`} initial={{ opacity: 0.4, y: 4 }} animate={{ opacity: 1, y: 0 }} className="text-[clamp(34px,9vw,52px)] font-extrabold leading-none tabular tracking-tight" style={{ color: multiplierColor, textShadow: phase === "running" || phase === "reveal" ? `0 0 28px ${palette.glow}88` : undefined }}>
216+ {phase === "idle" ? "—" : formatMultiplier(current)}
217+ </motion.div>
218+ </div>
219+ <div className="text-right">
220+ <div className="eyebrow">{phase === "busted" ? "Lost" : ended ? "Win" : phase === "idle" ? "After layer 1" : "Potential win"}</div>
221+ <div className={cn("text-lg font-bold tabular sm:text-xl", phase === "busted" ? "text-danger" : "text-credit")}>{phase === "idle" ? formatSC(Math.round(bet * (ladder[0] ?? 1))) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}</div>
222+ <div className="text-[11px] text-fg-3 tabular">Bet {formatSC(runBet)}</div>
223+ </div>
224+ </div>
225+
226+ {/* Door */}
227+ <div className="relative grid min-h-0 flex-1 place-items-center py-2">
228+ <div className="relative aspect-square select-none" style={{ width: "min(86vw, 44dvh, 460px)" }}>
229+ <div className="pointer-events-none absolute -inset-[6%] rounded-full" style={{ background: `radial-gradient(circle, ${palette.glow}33 0%, transparent 62%)`, filter: "blur(10px)" }} />
230+ {LAYER_SIZES.map((size, i) => (
231+ <Frame key={`f${i}`} size={size} open={i < layersOpen} primary={palette.primary} glow={palette.glow} sec={sec} />
232+ ))}
233+ {/* Core light (visible once the last disc opens) */}
234+ <div className="absolute rounded-full" style={{ width: `${LAYER_SIZES[4]}%`, height: `${LAYER_SIZES[4]}%`, left: `${(100 - LAYER_SIZES[4]) / 2}%`, top: `${(100 - LAYER_SIZES[4]) / 2}%`, background: `radial-gradient(circle, #fff7dc 0%, ${palette.glow} 30%, ${palette.primary} 65%, transparent 100%)`, zIndex: 5, boxShadow: `0 0 80px 20px ${palette.glow}88` }} />
235+ {[...LAYER_SIZES].map((_, k) => LAYER_SIZES.length - 1 - k).map((i) => (
236+ <Disc key={`d${i}`} index={i} size={LAYER_SIZES[i]} open={i < layersOpen} active={i === layersOpen && phase !== "idle"} outcome={anim && anim.layer === i ? anim.outcome : null} sealed={phase === "busted" && i === layersOpen} palette={palette} sec={sec} />
237+ ))}
238+
239+ {/* Jackpot burst */}
240+ <AnimatePresence>
241+ {celebrate ? (
242+ <motion.div key="burst" className="pointer-events-none absolute inset-0 grid place-items-center" style={{ zIndex: 30 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
243+ {[0, 1, 2].map((r) => (
244+ <motion.div key={r} className="absolute rounded-full" style={{ width: "34%", height: "34%", border: `2px solid ${palette.glow}` }} initial={{ scale: 0.6, opacity: 0.9 }} animate={{ scale: 3.2, opacity: 0 }} transition={{ duration: sec(1600), delay: r * sec(260), ease: "easeOut", repeat: Infinity, repeatDelay: sec(400) }} />
245+ ))}
246+ <motion.div className="absolute inset-0 rounded-full" style={{ background: `conic-gradient(from 0deg, transparent 0deg, ${palette.glow}44 8deg, transparent 16deg, transparent 60deg, ${palette.glow}33 68deg, transparent 76deg, transparent 120deg, ${palette.glow}44 128deg, transparent 136deg, transparent 180deg, ${palette.glow}33 188deg, transparent 196deg, transparent 240deg, ${palette.glow}44 248deg, transparent 256deg, transparent 300deg, ${palette.glow}33 308deg, transparent 316deg)` }} animate={{ rotate: 360 }} transition={{ duration: reduceMotion ? 40 : 14, ease: "linear", repeat: Infinity }} />
247+ </motion.div>
248+ ) : null}
249+ </AnimatePresence>
250+
251+ {/* Center overlay */}
252+ <div className="absolute inset-0 grid place-items-center" style={{ zIndex: 40 }}>
253+ <AnimatePresence mode="wait">
254+ {phase === "idle" ? (
255+ <motion.div key="idle" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className="flex flex-col items-center gap-2">
256+ <button
257+ onClick={() => void onStart()}
258+ disabled={locked}
259+ className="grid h-[112px] w-[112px] place-items-center rounded-full text-[17px] font-extrabold tracking-[0.14em] transition-transform active:scale-95 focus-ring disabled:opacity-60"
260+ style={{ background: `radial-gradient(circle at 50% 30%, #fff3cf 0%, ${palette.glow} 18%, ${palette.primary} 60%, #8a6d2f 100%)`, boxShadow: `0 0 0 8px ${palette.primary}2a, 0 0 0 9px ${palette.primary}55, 0 18px 60px -12px ${palette.primary}`, color: "#1a1406" }}
261+ aria-label="Start run"
262+ >
263+ START
264+ </button>
265+ <span className="rounded-full bg-black/50 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-fg-2 tabular">Bet {formatSC(bet)}</span>
266+ </motion.div>
267+ ) : phase === "cashed" ? (
268+ <motion.div key="cashed" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center text-center">
269+ <ShieldCheck className="h-9 w-9" style={{ color: palette.glow }} />
270+ <div className="mt-1 text-[13px] font-extrabold uppercase tracking-[0.22em]" style={{ color: palette.glow }}>
271+ Secured
272+ </div>
273+ <div className="text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>
274+ </motion.div>
275+ ) : phase === "completed" ? (
276+ <motion.div key="jackpot" initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} transition={{ type: "spring", stiffness: 220, damping: 16 }} className="flex flex-col items-center text-center">
277+ <div className="text-[clamp(22px,6vw,34px)] font-extrabold uppercase tracking-tight shimmer-text">Core open</div>
278+ <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-2">Fictional jackpot</div>
279+ <div className="mt-1 text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>
280+ </motion.div>
281+ ) : (
282+ <motion.div key="wheels" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center gap-2">
283+ <div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-[0.2em]" style={{ color: phase === "busted" ? DANGER : palette.glow }}>
284+ <ActiveIcon className="h-3.5 w-3.5" />
285+ {cfg.layerNames[activeLayer]}
286+ </div>
287+ <div className="flex gap-2">
288+ {wheels.map((w, i) => (
289+ <DigitWheel key={i} w={w} primary={palette.primary} glow={palette.glow} reduceMotion={reduceMotion} />
290+ ))}
291+ </div>
292+ <div className="h-5 text-[11px] font-semibold uppercase tracking-wider">
293+ {phase === "busted" ? (
294+ <motion.span initial={{ scale: 1.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="inline-flex items-center gap-1 rounded-sm border-2 px-2 py-0.5 text-[12px] font-extrabold tracking-[0.3em]" style={{ borderColor: DANGER, color: DANGER, transform: "rotate(-6deg)" }}>
295+ <Siren className="h-3.5 w-3.5" /> Sealed
296+ </motion.span>
297+ ) : anim ? (
298+ <span className="text-fg-2">{anim.outcome === "ok" ? "Lock yields" : anim.outcome === "bust" ? "Alarm" : "Decoding…"}</span>
299+ ) : offer ? (
300+ <span className="text-fg-3 tabular">
301+ {Math.round(offer.survival * 100)}% · <span style={{ color: palette.glow }}>{formatMultiplier(offer.next)}</span>
302+ </span>
303+ ) : null}
304+ </div>
305+ </motion.div>
306+ )}
307+ </AnimatePresence>
308+ </div>
309+ </div>
310+ </div>
311+
312+ {/* Layer map */}
313+ <LayerMap cfg={cfg} ladder={ladder} layersOpen={layersOpen} phase={phase} digits={anim ? [...revealedDigits.slice(0, anim.layer * cfg.digitsPerLayer), ...anim.digits.slice(0, anim.shown)] : revealedDigits} failed={failedDigits} palette={palette} />
314+
315+ {/* Controls */}
316+ <div className="mt-2 min-h-[72px]">
317+ <AnimatePresence mode="wait">
318+ {phase === "running" || phase === "reveal" ? (
319+ <motion.div key="run" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-2 gap-2">
320+ <button onClick={() => void onSecure()} disabled={locked || !session?.canCashout} className="tap flex h-14 flex-col items-center justify-center rounded-md border text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ borderColor: `${palette.primary}88`, color: palette.glow, background: `${palette.primary}14` }}>
321+ {definition.presentation.secondaryVerb ?? "SECURE"}
322+ <span className="text-[11px] font-semibold normal-case tracking-normal text-fg-2 tabular">+{formatSC(Math.round(runBet * current))}</span>
323+ </button>
324+ <button onClick={() => void onOpen()} disabled={locked || !offer} className="tap flex h-14 flex-col items-center justify-center rounded-md text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #9a7b3a)`, color: "#1a1406", boxShadow: `0 10px 40px -12px ${palette.primary}` }}>
325+ {definition.presentation.verb}
326+ <span className="text-[11px] font-semibold normal-case tracking-normal tabular" style={{ color: "#3a2d0c" }}>
327+ {anim ? "Decoding…" : offer ? `${Math.round(offer.survival * 100)}% yields · ${formatMultiplier(offer.next)}` : ""}
328+ </span>
329+ </button>
330+ </motion.div>
331+ ) : ended ? (
332+ <motion.div key="end" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-[1fr_auto] gap-2">
333+ <button onClick={() => void onStart()} disabled={locked} className="tap flex h-14 items-center justify-center gap-2 rounded-md text-[14px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: phase === "busted" ? `linear-gradient(180deg, #ff8aa0, ${DANGER})` : `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #9a7b3a)`, color: "#1a1406", boxShadow: `0 10px 40px -12px ${phase === "busted" ? DANGER : palette.primary}` }}>
334+ <RotateCcw className="h-4 w-4" /> {phase === "busted" ? "Try again" : "Play again"}
335+ <span className="text-[11px] font-semibold normal-case tracking-normal tabular opacity-70">{formatSC(bet)}</span>
336+ </button>
337+ <Button variant="secondary" size="lg" className="h-14" onClick={onNewRun}>
338+ New run
339+ </Button>
340+ </motion.div>
341+ ) : (
342+ <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex h-14 items-center justify-center gap-2 text-center text-[12px] text-fg-3">
343+ <LockOpen className="h-3.5 w-3.5" /> {definition.tagline} · First layer opens automatically.
344+ </motion.div>
345+ )}
346+ </AnimatePresence>
347+ </div>
348+ </div>
349+
350+ <AnimatePresence>
351+ {error ? (
352+ <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 z-[50] mx-auto max-w-sm glass rounded-md p-3 text-center text-sm">
353+ <div className="text-fg-2">{error}</div>
354+ <div className="mt-2 flex justify-center gap-2">
355+ <Button size="sm" variant="secondary" onClick={clearError}>
356+ Dismiss
357+ </Button>
358+ <Button size="sm" variant="accent" href="/rewards">
359+ Get rewards
360+ </Button>
361+ </div>
362+ </motion.div>
363+ ) : null}
364+ </AnimatePresence>
365+ </div>
366+ );
367+}
368+
369+/* ------------------------------------------------------------------ parts */
370+
371+function Frame({ size, open, primary, glow, sec }: { size: number; open: boolean; primary: string; glow: string; sec: (ms: number) => number }) {
372+ const inset = (100 - size) / 2;
373+ return (
374+ <motion.div className="absolute rounded-full" style={{ width: `${size}%`, height: `${size}%`, left: `${inset}%`, top: `${inset}%`, border: `2px solid ${primary}77`, boxShadow: `0 0 26px -6px ${glow}77, inset 0 0 22px -8px ${glow}55`, zIndex: 4 }} initial={false} animate={{ opacity: open ? 1 : 0 }} transition={{ duration: sec(700), delay: open ? sec(300) : 0 }} />
375+ );
376+}
377+
378+function Disc({ index, size, open, active, outcome, sealed, palette, sec }: { index: number; size: number; open: boolean; active: boolean; outcome: "ok" | "bust" | null; sealed: boolean; palette: ArcadeGameProps["definition"]["presentation"]["palette"]; sec: (ms: number) => number }) {
379+ const inset = (100 - size) / 2;
380+ const Icon = LAYER_ICONS[index] ?? Lock;
381+ const rim = sealed || outcome === "bust" ? DANGER : palette.primary;
382+ const bolts = 12 - index * 2;
383+ const glowShadow = outcome === "ok" ? `0 0 70px 10px ${palette.glow}aa` : active ? `0 0 44px -8px ${palette.glow}88` : "0 18px 50px -20px #000";
384+ return (
385+ <motion.div
386+ className="absolute rounded-full"
387+ style={{ width: `${size}%`, height: `${size}%`, left: `${inset}%`, top: `${inset}%`, zIndex: 10 + (LAYER_SIZES.length - index) }}
388+ initial={false}
389+ animate={open ? { opacity: 0, scale: 1.14, rotate: 32 } : { opacity: 1, scale: 1, rotate: 0 }}
390+ transition={{ duration: sec(950), ease: EASE }}
391+ >
392+ <motion.div
393+ className="absolute inset-0 rounded-full"
394+ style={{ background: `radial-gradient(circle at 35% 28%, #3b3441 0%, #221d27 40%, #110f14 100%)` }}
395+ animate={{ boxShadow: `inset 0 0 0 2px ${rim}88, inset 0 0 0 7px #0b0a0d, inset 0 0 0 8px ${rim}44, inset 0 -18px 40px -20px #000, ${glowShadow}` }}
396+ transition={{ duration: sec(500) }}
397+ />
398+ {/* brushed metal sheen */}
399+ <div className="absolute inset-0 rounded-full opacity-60" style={{ background: `conic-gradient(from 210deg, transparent 0deg, rgba(255,255,255,0.07) 40deg, transparent 90deg, rgba(255,255,255,0.05) 200deg, transparent 260deg)` }} />
400+ {/* tick ring */}
401+ <div className="absolute inset-[9%] rounded-full" style={{ border: `1.5px dashed ${rim}${active ? "aa" : "40"}` }} />
402+ {/* bolts */}
403+ {Array.from({ length: bolts }, (_, b) => {
404+ const a = (b / bolts) * Math.PI * 2 - Math.PI / 2;
405+ // Rounded so the SSR string matches the browser's normalised value (hydration).
406+ const bx = Math.round((50 + 45.5 * Math.cos(a) - 2.25) * 1000) / 1000;
407+ const by = Math.round((50 + 45.5 * Math.sin(a) - 2.25) * 1000) / 1000;
408+ return <span key={b} className="absolute h-[4.5%] w-[4.5%] rounded-full" style={{ left: `${bx}%`, top: `${by}%`, background: `radial-gradient(circle at 35% 35%, ${palette.glow}, ${palette.primary} 55%, #5b4a22)`, boxShadow: "0 1px 2px #000" }} />;
409+ })}
410+ {/* engraved layer mark (12 o'clock) */}
411+ <div className="absolute left-1/2 top-[16%] -translate-x-1/2 opacity-70" style={{ color: rim }}>
412+ <Icon style={{ width: `${Math.max(14, 30 - index * 3)}px`, height: `${Math.max(14, 30 - index * 3)}px` }} />
413+ </div>
414+ {sealed ? <div className="absolute inset-0 rounded-full" style={{ background: `radial-gradient(circle, ${DANGER}22 0%, ${DANGER}0f 70%, transparent 100%)` }} /> : null}
415+ </motion.div>
416+ );
417+}
418+
419+function DigitWheel({ w, primary, glow, reduceMotion }: { w: WheelState; primary: string; glow: string; reduceMotion: boolean }) {
420+ const column = Array.from({ length: 30 }, (_, i) => i % 10);
421+ const ring = w.kind === "fail" ? DANGER : w.kind === "locked" ? glow : `${primary}66`;
422+ return (
423+ <div className="relative overflow-hidden rounded-md" style={{ height: WHEEL_H, width: 42, background: "linear-gradient(180deg,#07060a 0%,#1b1720 50%,#07060a 100%)", boxShadow: `inset 0 0 0 1px ${ring}, inset 0 10px 12px -8px #000, inset 0 -10px 12px -8px #000` }}>
424+ {w.kind === "idle" ? <div className="grid h-full place-items-center text-lg text-fg-4">•</div> : null}
425+ {w.kind === "fail" ? (
426+ <motion.div initial={{ x: 0 }} animate={{ x: reduceMotion ? 0 : [0, -5, 5, -4, 4, 0] }} transition={{ duration: 0.5 }} className="grid h-full place-items-center text-2xl font-extrabold" style={{ color: DANGER, textShadow: `0 0 16px ${DANGER}` }}>
427+ ✕
428+ </motion.div>
429+ ) : null}
430+ {w.kind === "spin" ? (
431+ <motion.div key="spin" className="absolute inset-x-0 top-0" animate={{ y: [0, -10 * WHEEL_H] }} transition={{ duration: reduceMotion ? 1.2 : 0.42, ease: "linear", repeat: Infinity }} style={{ filter: reduceMotion ? undefined : "blur(0.6px)" }}>
432+ {column.map((d, i) => (
433+ <div key={i} className="grid place-items-center text-2xl font-extrabold tabular text-fg-3" style={{ height: WHEEL_H }}>
434+ {d}
435+ </div>
436+ ))}
437+ </motion.div>
438+ ) : null}
439+ {w.kind === "locked" ? (
440+ <motion.div key="locked" className="absolute inset-x-0 top-0" initial={{ y: -w.digit * WHEEL_H }} animate={{ y: -(10 + w.digit) * WHEEL_H }} transition={reduceMotion ? { duration: 0.01 } : { type: "spring", stiffness: 120, damping: 17, mass: 0.8 }}>
441+ {column.map((d, i) => (
442+ <div key={i} className="grid place-items-center text-2xl font-extrabold tabular" style={{ height: WHEEL_H, color: glow, textShadow: `0 0 14px ${glow}99` }}>
443+ {d}
444+ </div>
445+ ))}
446+ </motion.div>
447+ ) : null}
448+ </div>
449+ );
450+}
451+
452+function LayerMap({ cfg, ladder, layersOpen, phase, digits, failed, palette }: { cfg: VaultConfig; ladder: number[]; layersOpen: number; phase: Phase; digits: number[]; failed: number[]; palette: ArcadeGameProps["definition"]["presentation"]["palette"] }) {
453+ const n = cfg.layers.length;
454+ return (
455+ <div className="relative mt-1">
456+ <div className="absolute left-[10%] right-[10%] top-[15px] h-px" style={{ background: `linear-gradient(90deg, ${palette.primary}66, ${palette.primary}22)` }} />
457+ <div className="absolute left-[10%] top-[15px] h-px" style={{ width: `${(Math.max(0, Math.min(n - 1, layersOpen)) / (n - 1)) * 80}%`, background: palette.glow, boxShadow: `0 0 8px ${palette.glow}`, transition: "width 900ms cubic-bezier(0.16,1,0.3,1)" }} />
458+ <ol className="relative grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
459+ {cfg.layers.map((p, i) => {
460+ const open = i < layersOpen;
461+ const active = i === layersOpen && phase !== "idle";
462+ const sealed = active && phase === "busted";
463+ const Icon = LAYER_ICONS[i] ?? Lock;
464+ const code = open ? digits.slice(i * cfg.digitsPerLayer, (i + 1) * cfg.digitsPerLayer) : active && phase !== "busted" ? digits.slice(i * cfg.digitsPerLayer) : sealed ? failed : [];
465+ const colour = sealed ? DANGER : open ? palette.glow : active ? palette.primary : "rgba(255,255,255,0.28)";
466+ return (
467+ <li key={i} className="flex flex-col items-center text-center">
468+ <motion.div className="grid h-[30px] w-[30px] place-items-center rounded-full" animate={{ scale: active && phase !== "busted" ? [1, 1.08, 1] : 1 }} transition={{ duration: 1.6, repeat: active && phase !== "busted" ? Infinity : 0 }} style={{ background: open ? `radial-gradient(circle at 40% 35%, ${palette.glow}, ${palette.primary})` : "#0f0d12", border: `1.5px solid ${colour}`, color: open ? "#1a1406" : colour, boxShadow: open || active ? `0 0 14px -2px ${colour}` : undefined }}>
469+ <Icon className="h-3.5 w-3.5" />
470+ </motion.div>
471+ <div className="mt-1 text-[11px] font-bold tabular" style={{ color: open ? palette.glow : sealed ? DANGER : "var(--color-fg-3)" }}>
472+ {formatMultiplier(ladder[i])}
473+ </div>
474+ <div className="text-[9px] uppercase tracking-wider text-fg-4">{Math.round(p * 100)}%</div>
475+ <div className="h-[14px] font-mono text-[11px] tabular" style={{ color: sealed ? DANGER : palette.glow, letterSpacing: "0.15em" }}>
476+ {code.join("")}
477+ {sealed ? "✕" : ""}
478+ </div>
479+ </li>
480+ );
481+ })}
482+ </ol>
483+ </div>
484+ );
485+}
modified apps/web/src/components/lobby/game-art.tsx +1048 −21
@@ -1,15 +1,17 @@
1 import * as React from "react"; 1 import * as React from "react";
2−import { GAMES_BY_SLUG } from "@spinza/games/client"; 2+import { ARCADE_GAMES, CRASH_GAMES, GAMES_BY_SLUG } from "@spinza/games/client";
3−import type { GameDefinition, SymbolStyle, SymbolTier } from "@spinza/game-core/client"; 3+import type { GameDefinition, SymbolShape, SymbolStyle, SymbolTier } from "@spinza/game-core/client";
4 import { cn } from "@/lib/utils"; 4 import { cn } from "@/lib/utils";
5 import { SymbolSvg, svgArc, svgPolygon, svgStar } from "./symbol-svg"; 5 import { SymbolSvg, svgArc, svgPolygon, svgStar } from "./symbol-svg";
6 6
7 /* ------------------------------------------------------------------------ 7 /* ------------------------------------------------------------------------
8− Spinza key art — 20 hand-composed posters, one scene per game. 8+ Spinza key art — 35 hand-composed posters, one scene per game.
9− Every poster is built from the game's own reel symbols (SymbolSvg mirrors 9+ Slot posters are built from the game's own reel symbols (SymbolSvg mirrors
10− the PixiJS renderer) arranged in a composition unique to that title, on a 10+ the PixiJS renderer); the 10 Risk Games and 5 Beyond Slots originals have
11− theme-specific backdrop. Pure inline SVG, deterministic (seeded PRNG only 11+ no reels, so their scenes are composed from primitives around each game's
12− for star fields / particles), no images, filters used sparingly. 12+ mechanic (rising multiplier ladder + the big-button verb as a solid pill).
13+ Pure inline SVG, deterministic (seeded PRNG only for star fields /
14+ particles), no images, filters used sparingly.
13 ------------------------------------------------------------------------ */ 15 ------------------------------------------------------------------------ */
14 16
15 export type ArtVariant = "card" | "hero" | "banner" | "tile"; 17 export type ArtVariant = "card" | "hero" | "banner" | "tile";
@@ -35,8 +37,14 @@ export interface GameArtProps {
35 label?: string; 37 label?: string;
36 } 38 }
37 39
38−/** Presentation hints per game, derived from the game library. */ 40+/** Ambience → particle system for the non-slot games (their definitions carry no `particles`). */
39−export const GAME_PRESENTATION: Record<string, { backdrop: string; particles: string }> = Object.fromEntries([...GAMES_BY_SLUG.values()].map((g) => [g.slug, { backdrop: g.presentation.backdrop, particles: g.presentation.particles }])); 41+const AMBIENCE_PARTICLES: Record<string, string> = { lunar: "stars", ocean: "bubbles", space: "stars", heist: "coins", fire: "fire", void: "stars", reactor: "energy", obsidian: "dust", arcade: "confetti", quantum: "energy", jungle: "dust" };
42+
43+/** Presentation hints per game, derived from the game library (slots, risk games and originals). */
44+export const GAME_PRESENTATION: Record<string, { backdrop: string; particles: string }> = Object.fromEntries([
45+ ...[...GAMES_BY_SLUG.values()].map((g) => [g.slug, { backdrop: g.presentation.backdrop, particles: g.presentation.particles }] as const),
46+ ...[...CRASH_GAMES, ...ARCADE_GAMES].map((g) => [g.slug, { backdrop: g.presentation.scene, particles: AMBIENCE_PARTICLES[g.presentation.ambience] ?? "stars" }] as const),
47+]);
40 48
41 /* ------------------------------------------------------------ seeded PRNG */ 49 /* ------------------------------------------------------------ seeded PRNG */
42 50
@@ -63,12 +71,14 @@ function mulberry32(seed: number): () => number {
63 /* ------------------------------------------------------------------ stage */ 71 /* ------------------------------------------------------------------ stage */
64 72
65 const FONT = "var(--font-geist-sans), Geist, Inter, ui-sans-serif, system-ui, sans-serif"; 73 const FONT = "var(--font-geist-sans), Geist, Inter, ui-sans-serif, system-ui, sans-serif";
74+const MONO = "var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace";
66 const f = (n: number) => Math.round(n * 100) / 100; 75 const f = (n: number) => Math.round(n * 100) / 100;
67 76
68 interface Stage { 77 interface Stage {
69 W: number; 78 W: number;
70 H: number; 79 H: number;
71 v: ArtVariant; 80 v: ArtVariant;
81+ slug: string;
72 /** Focal centre and unit radius of the composition. */ 82 /** Focal centre and unit radius of the composition. */
73 cx: number; 83 cx: number;
74 cy: number; 84 cy: number;
@@ -1173,6 +1183,967 @@ function SpinzaOriginal(s: Stage) {
1173 ); 1183 );
1174 } 1184 }
1175 1185
1186+/* ======================================================================= */
1187+/* RISK GAMES + BEYOND SLOTS — no reel symbols. Shared grammar: a rising */
1188+/* multiplier ladder (`Rungs`) and the big-button verb as a solid pill */
1189+/* (`Verb`) so the cash-out / original category reads instantly. */
1190+/* ======================================================================= */
1191+
1192+/** Ad-hoc glyph from the shared symbol vocabulary (no reel plate, no halo). */
1193+function Shape({ shape, color, accent, x, y, size, rotate, opacity, label }: { shape: SymbolShape; color: string; accent?: string; x: number; y: number; size: number; rotate?: number; opacity?: number; label?: string }) {
1194+ return <SymbolSvg style={{ shape, color, accent, label, glow: 0 }} size={size} x={x} y={y} rotate={rotate} opacity={opacity} plate={false} halo={false} />;
1195+}
1196+
1197+/** The game's big-button verb as a solid pill with a "button" dot. */
1198+function Verb({ x, y, text, color, ink = "#0b0d14", size = 12, opacity = 1 }: { x: number; y: number; text: string; color: string; ink?: string; size?: number; opacity?: number }) {
1199+ const width = text.length * size * 0.7 + size * 2.6;
1200+ const h = size * 1.9;
1201+ const left = x - width / 2;
1202+ return (
1203+ <g opacity={opacity}>
1204+ <rect x={f(left)} y={f(y - h / 2)} width={f(width)} height={f(h)} rx={f(h / 2)} fill={color} stroke="#fff" strokeOpacity={0.35} strokeWidth={1} />
1205+ <circle cx={f(left + h * 0.58)} cy={f(y)} r={f(size * 0.24)} fill={ink} fillOpacity={0.75} />
1206+ <text x={f(x + size * 0.42)} y={f(y)} textAnchor="middle" dominantBaseline="central" fontSize={f(size)} fontWeight={800} fill={ink} style={{ fontFamily: FONT, letterSpacing: "0.1em" }}>
1207+ {text}
1208+ </text>
1209+ </g>
1210+ );
1211+}
1212+
1213+/** Multiplier ladder labels: opacity and size grow along the list, the last rung is hot. */
1214+function Rungs({ items, color, hot, size, anchor = "middle", weight = 700 }: { items: Array<[number, number, string]>; color: string; hot: string; size: number; anchor?: "start" | "middle" | "end"; weight?: 600 | 700 | 800 }) {
1215+ const n = Math.max(1, items.length - 1);
1216+ return (
1217+ <>
1218+ {items.map(([x, y, t], i) => (
1219+ <Small key={`${t}${i}`} x={x} y={y} text={t} color={i === n ? hot : color} size={i === n ? size * 1.3 : size} anchor={anchor} weight={i === n ? 800 : weight} tracking="0.06em" opacity={0.5 + (i / n) * 0.5} />
1220+ ))}
1221+ </>
1222+ );
1223+}
1224+
1225+/* 21 — SKYFALL: a jet climbs from the cloud deck through the storm into space; altitude rungs on the left */
1226+function Skyfall(s: Stage) {
1227+ const { W, H, cx, cy, R, id, p, compact } = s;
1228+ const stars = scatterDots(s, 55, [0, 0, W, Math.max(1, cy - R * 0.45)], 0.5, 1.4);
1229+ const cloudY = cy + R * 1.02;
1230+ const clouds: Array<[number, number, number]> = [];
1231+ for (let i = 0; i < 12; i++) clouds.push([cx - R * 1.4 + i * R * 0.26 + s.rnd() * R * 0.1, cloudY + s.rnd() * R * 0.2, R * (0.12 + s.rnd() * 0.15)]);
1232+ const jx = cx + R * 0.52;
1233+ const jy = cy - R * 0.48;
1234+ const trail = `M${f(cx - R * 1.15)} ${f(cy + R * 0.98)} C${f(cx - R * 0.1)} ${f(cy + R * 0.98)} ${f(cx + R * 0.05)} ${f(cy + R * 0.25)} ${f(jx - R * 0.1)} ${f(jy + R * 0.1)}`;
1235+ const jet = `M${f(R * 0.72)} 0 L${f(R * 0.12)} ${f(-R * 0.075)} L${f(-R * 0.56)} ${f(-R * 0.06)} L${f(-R * 0.62)} 0 L${f(-R * 0.56)} ${f(R * 0.06)} L${f(R * 0.12)} ${f(R * 0.075)} Z`;
1236+ const wings = `M${f(R * 0.06)} ${f(-R * 0.05)} L${f(-R * 0.48)} ${f(-R * 0.44)} L${f(-R * 0.62)} ${f(-R * 0.42)} L${f(-R * 0.44)} ${f(-R * 0.05)} Z M${f(R * 0.06)} ${f(R * 0.05)} L${f(-R * 0.48)} ${f(R * 0.44)} L${f(-R * 0.62)} ${f(R * 0.42)} L${f(-R * 0.44)} ${f(R * 0.05)} Z`;
1237+ const bolt = `M${f(cx - R * 0.62)} ${f(cy + R * 0.15)} l${f(R * 0.1)} ${f(R * 0.2)} l${f(-R * 0.09)} ${f(R * 0.02)} l${f(R * 0.14)} ${f(R * 0.26)}`;
1238+ const lx = cx - R * 1.14;
1239+ const rungs: Array<[number, number, string]> = [[lx, cy + R * 0.78, "1.5× CLOUDS"], [lx, cy + R * 0.38, "3× STORM FRONT"], [lx, cy - R * 0.02, "8× STRATOSPHERE"], [lx, cy - R * 0.42, "25× EDGE OF SPACE"], [lx, cy - R * 0.82, "100× ORBIT"]];
1240+ let ticks = "";
1241+ for (const [x, y] of rungs) ticks += `M${f(x - R * 0.06)} ${f(y)}h${f(R * 0.04)} M${f(x - R * 0.04)} ${f(y - R * 0.2)}v${f(R * 0.4)} `;
1242+ return (
1243+ <g>
1244+ <defs>
1245+ <Linear id={`${id}-sf1`} stops={[[0, "#020617"], [0.32, "#1e1b4b"], [0.58, "#334155"], [0.82, "#94a3b8"], [1, "#e2e8f0"]]} />
1246+ <Linear id={`${id}-sf2`} stops={[[0, p.secondary, 0], [1, p.secondary, 1]]} dir="h" />
1247+ <Radial id={`${id}-sf3`} color={p.secondary} o0={0.9} />
1248+ </defs>
1249+ <rect width={W} height={H} fill={`url(#${id}-sf1)`} />
1250+ <path d={stars} fill="#fff" fillOpacity={0.8} />
1251+ <rect x={0} y={f(cy + R * 0.05)} width={W} height={f(R * 0.7)} fill="#0f172a" fillOpacity={0.35} />
1252+ <path d={bolt} stroke={p.glow} strokeWidth={2} fill="none" strokeLinecap="round" strokeLinejoin="round" opacity={0.9} />
1253+ <path d={dots(clouds)} fill="#f8fafc" fillOpacity={0.9} />
1254+ <path d={dots(clouds.map(([x, y, r]) => [x + r * 0.2, y + r * 0.35, r * 0.8]))} fill="#cbd5e1" fillOpacity={0.6} />
1255+ <path d={trail} fill="none" stroke={`url(#${id}-sf2)`} strokeWidth={f(R * 0.05)} strokeLinecap="round" />
1256+ <path d={trail} fill="none" stroke="#fff" strokeOpacity={0.8} strokeWidth={1.5} strokeDasharray="3 8" />
1257+ {!compact ? <path d={ticks} stroke={p.primary} strokeOpacity={0.6} strokeWidth={1.5} fill="none" /> : null}
1258+ {!compact ? <Rungs items={rungs} color={p.primary} hot="#fff" size={R * 0.062} anchor="start" /> : null}
1259+ <circle cx={f(jx - R * 0.45 * Math.cos(0.61))} cy={f(jy + R * 0.45 * Math.sin(0.61))} r={f(R * 0.14)} fill={`url(#${id}-sf3)`} />
1260+ <g transform={`translate(${f(jx)} ${f(jy)}) rotate(-35)`}>
1261+ <path d={wings} fill="#94a3b8" stroke="#e2e8f0" strokeWidth={1} />
1262+ <path d={jet} fill="#f1f5f9" stroke="#64748b" strokeWidth={1} />
1263+ <ellipse cx={f(R * 0.28)} cy={f(-R * 0.02)} rx={f(R * 0.12)} ry={f(R * 0.045)} fill={p.secondary} />
1264+ <path d={`M${f(-R * 0.62)} 0 h${f(-R * 0.28)}`} stroke={p.secondary} strokeWidth={f(R * 0.05)} strokeLinecap="round" strokeOpacity={0.9} />
1265+ </g>
1266+ {!compact ? <Verb x={cx + R * 0.72} y={cy + R * 0.26} text="CASH OUT" color={p.secondary} size={R * 0.075} /> : null}
1267+ </g>
1268+ );
1269+}
1270+
1271+/* 22 — DEEP DIVE: research sub sinking past depth markers, a leviathan waiting in the abyss */
1272+function DeepDive(s: Stage) {
1273+ const { W, H, cx, cy, R, id, p, compact } = s;
1274+ const surface = cy - R * 1.05;
1275+ const bubbles: Array<[number, number, number]> = [];
1276+ for (let i = 0; i < 22; i++) bubbles.push([cx - R * 1.1 + s.rnd() * R * 2.2, surface + R * 0.2 + s.rnd() * R * 2.0, 1.5 + s.rnd() * R * 0.03]);
1277+ let waves = `M0 ${f(surface)}`;
1278+ for (let i = 0; i < 10; i++) waves += ` q${f(W / 20)} ${f(-R * 0.06)} ${f(W / 10)} 0`;
1279+ const sx = cx - R * 0.3;
1280+ const sy = cy - R * 0.28;
1281+ const rx = cx + R * 1.12;
1282+ const rungs: Array<[number, number, string]> = [[rx, cy + R * 0.02, "1,000 m ×2"], [rx, cy + R * 0.38, "3,000 m ×5"], [rx, cy + R * 0.74, "ABYSS ×20"]];
1283+ let markers = "";
1284+ for (const [, y] of rungs) markers += `M${f(cx - R * 1.2)} ${f(y + R * 0.11)}H${f(cx + R * 1.2)} `;
1285+ const body = `M${f(cx + R * 1.45)} ${f(cy + R * 1.45)} C${f(cx + R * 1.0)} ${f(cy + R * 0.75)} ${f(cx + R * 0.35)} ${f(cy + R * 1.7)} ${f(cx - R * 0.35)} ${f(cy + R * 1.12)}`;
1286+ const spines = `M${f(cx + R * 0.95)} ${f(cy + R * 1.0)}l${f(R * 0.05)} ${f(-R * 0.24)}l${f(R * 0.1)} ${f(R * 0.2)} M${f(cx + R * 0.72)} ${f(cy + R * 1.0)}l${f(R * 0.03)} ${f(-R * 0.22)}l${f(R * 0.1)} ${f(R * 0.18)} M${f(cx + R * 1.2)} ${f(cy + R * 1.12)}l${f(R * 0.07)} ${f(-R * 0.22)}l${f(R * 0.09)} ${f(R * 0.22)}`;
1287+ return (
1288+ <g>
1289+ <defs>
1290+ <Linear id={`${id}-dd1`} stops={[[0, "#0e7490", 0.55], [0.3, "#0c4a6e", 0.35], [1, "#000", 0]]} />
1291+ <Linear id={`${id}-dd2`} stops={[[0, p.glow, 0.5], [1, p.glow, 0]]} />
1292+ <Radial id={`${id}-dd3`} color={p.secondary} o0={0.9} />
1293+ </defs>
1294+ <rect x={0} y={f(surface)} width={W} height={f(H - surface)} fill={`url(#${id}-dd1)`} />
1295+ <polygon points={`${f(cx - R * 0.9)},${f(surface)} ${f(cx - R * 0.45)},${f(surface)} ${f(cx + R * 0.2)},${f(cy + R * 1.3)} ${f(cx - R * 0.75)},${f(cy + R * 1.3)}`} fill={`url(#${id}-dd2)`} opacity={0.45} />
1296+ <path d={waves} fill="none" stroke="#e0fbff" strokeOpacity={0.9} strokeWidth={2} />
1297+ {!compact ? <path d={markers} stroke={p.primary} strokeOpacity={0.4} strokeWidth={1} strokeDasharray="6 6" /> : null}
1298+ {!compact ? <Rungs items={rungs} color={p.primary} hot={p.secondary} size={R * 0.065} anchor="end" /> : null}
1299+ <path d={body} fill="none" stroke={p.secondary} strokeOpacity={0.5} strokeWidth={f(R * 0.33)} strokeLinecap="round" />
1300+ <path d={body} fill="none" stroke="#02161f" strokeWidth={f(R * 0.3)} strokeLinecap="round" />
1301+ <path d={body} fill="none" stroke={p.secondary} strokeOpacity={0.3} strokeWidth={f(R * 0.3)} strokeLinecap="round" strokeDasharray="2 14" />
1302+ <path d={spines} fill="#02161f" stroke={p.secondary} strokeOpacity={0.7} strokeWidth={1.2} />
1303+ <ellipse cx={f(cx - R * 0.42)} cy={f(cy + R * 1.12)} rx={f(R * 0.3)} ry={f(R * 0.2)} fill="#02161f" stroke={p.secondary} strokeOpacity={0.5} strokeWidth={1.2} />
1304+ <polygon points={`${f(cx - R * 0.68)},${f(cy + R * 1.09)} ${f(cx - R * 0.98)},${f(cy + R * 1.25)} ${f(cx - R * 0.6)},${f(cy + R * 1.27)}`} fill="#02161f" stroke={p.secondary} strokeOpacity={0.5} strokeWidth={1.2} />
1305+ <circle cx={f(cx - R * 0.55)} cy={f(cy + R * 1.05)} r={f(R * 0.035)} fill={p.secondary} />
1306+ <circle cx={f(cx - R * 0.55)} cy={f(cy + R * 1.05)} r={f(R * 0.1)} fill={`url(#${id}-dd3)`} />
1307+ <polygon points={`${f(sx + R * 0.55)},${f(sy + R * 0.05)} ${f(sx + R * 1.25)},${f(sy + R * 0.65)} ${f(sx + R * 0.7)},${f(sy + R * 0.95)}`} fill={p.primary} fillOpacity={0.16} />
1308+ <g transform={`translate(${f(sx)} ${f(sy)}) rotate(14)`}>
1309+ <rect x={f(-R * 0.22)} y={f(-R * 0.42)} width={f(R * 0.26)} height={f(R * 0.3)} rx={f(R * 0.04)} fill="#0b3444" stroke={p.primary} strokeWidth={1.5} />
1310+ <path d={`M${f(-R * 0.09)} ${f(-R * 0.42)}v${f(-R * 0.16)}h${f(R * 0.14)}`} stroke={p.primary} strokeWidth={2} fill="none" />
1311+ <rect x={f(-R * 0.7)} y={f(-R * 0.2)} width={f(R * 1.35)} height={f(R * 0.42)} rx={f(R * 0.21)} fill="#0b3444" stroke={p.primary} strokeWidth={2} />
1312+ <path d={dots([[-R * 0.3, 0, R * 0.055], [-R * 0.05, 0, R * 0.055], [R * 0.2, 0, R * 0.055]])} fill={p.secondary} stroke="#e0fbff" strokeWidth={1} />
1313+ <path d={`M${f(-R * 0.7)} ${f(-R * 0.16)}l${f(-R * 0.16)} ${f(-R * 0.1)}v${f(R * 0.52)}l${f(R * 0.16)} ${f(-R * 0.1)}`} fill="#0b3444" stroke={p.primary} strokeWidth={1.5} />
1314+ <path d={`M${f(-R * 0.86)} ${f(-R * 0.16)}v${f(R * 0.32)}`} stroke={p.primary} strokeWidth={3} strokeLinecap="round" />
1315+ <circle cx={f(R * 0.6)} cy={f(R * 0.02)} r={f(R * 0.06)} fill={p.glow} />
1316+ </g>
1317+ <path d={dots(bubbles)} fill="none" stroke="#dffaff" strokeOpacity={0.5} strokeWidth={1.2} />
1318+ {!compact ? <Verb x={cx + R * 0.62} y={surface + R * 0.32} text="SURFACE" color={p.secondary} size={R * 0.075} /> : null}
1319+ </g>
1320+ );
1321+}
1322+
1323+/* 23 — ROCKET RUN: launch pad, spent stage tumbling away, trajectory milestones toward a big Mars */
1324+function RocketRun(s: Stage) {
1325+ const { W, H, cx, cy, R, id, p, compact } = s;
1326+ const stars = scatterDots(s, 70, [0, 0, W, H], 0.5, 1.4);
1327+ const mx = cx + R * 0.86;
1328+ const my = cy - R * 0.84;
1329+ const mr = R * 0.5;
1330+ const ground = cy + R * 1.08;
1331+ const px = cx - R * 0.92;
1332+ const bez = (t: number): [number, number] => {
1333+ const x0 = px + R * 0.12;
1334+ const y0 = ground - R * 0.1;
1335+ const x1 = cx - R * 0.6;
1336+ const y1 = cy - R * 1.4;
1337+ const x2 = mx - mr * 0.55;
1338+ const y2 = my + mr * 0.75;
1339+ const u = 1 - t;
1340+ return [u * u * x0 + 2 * u * t * x1 + t * t * x2, u * u * y0 + 2 * u * t * y1 + t * t * y2];
1341+ };
1342+ const traj = `M${f(bez(0)[0])} ${f(bez(0)[1])} Q${f(cx - R * 0.6)} ${f(cy - R * 1.4)} ${f(bez(1)[0])} ${f(bez(1)[1])}`;
1343+ const ms: Array<{ t: number; l: string; side: 1 | 0 }> = [
1344+ { t: 0.14, l: "2× MAX-Q", side: 1 },
1345+ { t: 0.28, l: "5× SEPARATION", side: 1 },
1346+ { t: 0.74, l: "15× ESCAPE", side: 0 },
1347+ { t: 0.9, l: "60× TMI", side: 0 },
1348+ ];
1349+ const [rkx, rky] = bez(0.44);
1350+ const smoke = scatterDots(s, 14, [px - R * 0.3, ground - R * 0.35, R * 0.9, R * 0.35], R * 0.03, R * 0.1);
1351+ return (
1352+ <g>
1353+ <defs>
1354+ <Radial id={`${id}-rr1`} color="#f97316" o0={1} mid={[0.55, "#c2410c", 1]} o1={0.9} />
1355+ <Radial id={`${id}-rr2`} color={p.glow} o0={0.55} />
1356+ <Radial id={`${id}-rr3`} color={p.glow} o0={1} mid={[0.35, p.primary, 0.8]} />
1357+ </defs>
1358+ <path d={stars} fill="#fff" fillOpacity={0.75} />
1359+ <circle cx={f(mx)} cy={f(my)} r={f(mr * 1.7)} fill={`url(#${id}-rr2)`} />
1360+ <circle cx={f(mx)} cy={f(my)} r={f(mr)} fill={`url(#${id}-rr1)`} />
1361+ <ellipse cx={f(mx - mr * 0.25)} cy={f(my + mr * 0.2)} rx={f(mr * 0.42)} ry={f(mr * 0.18)} fill="#7c2d12" fillOpacity={0.7} />
1362+ <ellipse cx={f(mx + mr * 0.35)} cy={f(my - mr * 0.3)} rx={f(mr * 0.25)} ry={f(mr * 0.14)} fill="#7c2d12" fillOpacity={0.6} />
1363+ <ellipse cx={f(mx)} cy={f(my - mr * 0.88)} rx={f(mr * 0.3)} ry={f(mr * 0.08)} fill="#fff7ed" fillOpacity={0.8} />
1364+ <line x1={0} y1={f(ground)} x2={W} y2={f(ground)} stroke="#3b2a4a" strokeWidth={2} />
1365+ <rect x={f(px - R * 0.05)} y={f(ground - R * 0.95)} width={f(R * 0.1)} height={f(R * 0.95)} fill="#2a1d3a" stroke={p.primary} strokeOpacity={0.5} strokeWidth={1} />
1366+ <path d={`M${f(px)} ${f(ground - R * 0.85)}h${f(R * 0.3)} M${f(px)} ${f(ground - R * 0.6)}h${f(R * 0.3)} M${f(px)} ${f(ground - R * 0.35)}h${f(R * 0.3)}`} stroke={p.primary} strokeOpacity={0.55} strokeWidth={2} />
1367+ <path d={smoke} fill="#94a3b8" fillOpacity={0.35} />
1368+ <path d={traj} fill="none" stroke={p.primary} strokeOpacity={0.6} strokeWidth={2} strokeDasharray="5 9" />
1369+ {!compact ? <path d={dots(ms.map((m) => [bez(m.t)[0], bez(m.t)[1], R * 0.02]))} fill={p.glow} /> : null}
1370+ {!compact
1371+ ? ms.map((m, i) => {
1372+ const [x, y] = bez(m.t);
1373+ return <Small key={m.l} x={x + m.side * R * 0.09} y={m.side ? y - R * 0.06 : y + R * 0.15} text={m.l} color={i === 3 ? p.secondary : p.primary} size={R * (i === 3 ? 0.072 : 0.06)} anchor={m.side ? "start" : "middle"} weight={i === 3 ? 800 : 700} tracking="0.06em" opacity={0.6 + i * 0.13} />;
1374+ })
1375+ : null}
1376+ {!compact ? <Pill x={mx - mr * 0.1} y={my + mr * 0.35} text="300× MARS" color={p.secondary} ink="#fff" bg="#3b0a1a" size={R * 0.062} /> : null}
1377+ <g transform={`translate(${f(cx - R * 0.72)} ${f(cy + R * 0.5)}) rotate(70)`}>
1378+ <rect x={f(-R * 0.09)} y={f(-R * 0.22)} width={f(R * 0.18)} height={f(R * 0.44)} rx={f(R * 0.03)} fill="#c8ccd6" />
1379+ <polygon points={`${f(-R * 0.09)},${f(R * 0.1)} ${f(-R * 0.2)},${f(R * 0.25)} ${f(-R * 0.09)},${f(R * 0.22)} ${f(R * 0.09)},${f(R * 0.1)} ${f(R * 0.2)},${f(R * 0.25)} ${f(R * 0.09)},${f(R * 0.22)}`} fill={p.secondary} />
1380+ <path d={`M${f(-R * 0.05)} ${f(R * 0.22)}l${f(R * 0.05)} ${f(R * 0.18)}l${f(R * 0.05)} ${f(-R * 0.18)}`} fill="#ffb347" fillOpacity={0.85} />
1381+ </g>
1382+ <ellipse cx={f(rkx - R * 0.3)} cy={f(rky + R * 0.5)} rx={f(R * 0.12)} ry={f(R * 0.32)} fill={`url(#${id}-rr3)`} transform={`rotate(32 ${f(rkx - R * 0.3)} ${f(rky + R * 0.5)})`} />
1383+ <Shape shape="rocket" color="#f1f5f9" accent={p.secondary} x={rkx} y={rky} size={R * 1.15} rotate={32} />
1384+ {!compact ? <Verb x={cx + R * 0.5} y={cy + R * 0.85} text="SECURE PAYLOAD" color={p.secondary} size={R * 0.068} /> : null}
1385+ </g>
1386+ );
1387+}
1388+
1389+/* 24 — BANK HEIST: inside the vault, safe-deposit wall, growing SC bags, red/blue siren sweep */
1390+function BankHeist(s: Stage) {
1391+ const { W, H, cx, cy, R, id, p, compact } = s;
1392+ const floor = cy + R * 0.98;
1393+ const lampX = cx + R * 0.1;
1394+ const lampY = cy - R * 1.22;
1395+ const bags = [
1396+ { x: cx - R * 0.82, y: floor - R * 0.02, k: 0.5, m: "×1.4" },
1397+ { x: cx - R * 0.28, y: floor - R * 0.06, k: 0.68, m: "×2" },
1398+ { x: cx + R * 0.45, y: floor - R * 0.12, k: 1.0, m: "×4" },
1399+ ];
1400+ let bars = "";
1401+ for (let i = 0; i < 6; i++) bars += `M${f(cx + R * 0.95 + (i % 3) * R * 0.02)} ${f(floor - R * 0.09 * (Math.floor(i / 3) + 1))}h${f(R * 0.26)}v${f(R * 0.08)}h${f(-R * 0.26)}z `;
1402+ return (
1403+ <g>
1404+ <defs>
1405+ <pattern id={`${id}-bh0`} width={f(R * 0.2)} height={f(R * 0.14)} patternUnits="userSpaceOnUse">
1406+ <rect x={1} y={1} width={f(R * 0.2 - 2)} height={f(R * 0.14 - 2)} rx={2} fill="#1b1811" stroke="#3a3324" strokeWidth={1} />
1407+ <circle cx={f(R * 0.1)} cy={f(R * 0.07)} r={1.2} fill={p.primary} fillOpacity={0.55} />
1408+ </pattern>
1409+ <Linear id={`${id}-bh1`} stops={[[0, "#3a2f1a"], [1, "#0b0a08"]]} />
1410+ <Radial id={`${id}-bh2`} color={p.glow} o0={0.5} />
1411+ </defs>
1412+ <rect x={0} y={0} width={W} height={f(floor)} fill={`url(#${id}-bh0)`} />
1413+ <rect x={0} y={f(floor)} width={W} height={f(H - floor)} fill={`url(#${id}-bh1)`} />
1414+ <line x1={0} y1={f(floor)} x2={W} y2={f(floor)} stroke={p.primary} strokeOpacity={0.7} strokeWidth={2} />
1415+ <polygon points={`${f(lampX)},${f(lampY)} ${f(cx - R * 1.5)},${f(floor + R * 0.5)} ${f(cx - R * 0.55)},${f(floor + R * 0.5)}`} fill="#ef4444" fillOpacity={0.22} />
1416+ <polygon points={`${f(lampX)},${f(lampY)} ${f(cx + R * 0.7)},${f(floor + R * 0.5)} ${f(cx + R * 1.6)},${f(floor + R * 0.5)}`} fill="#3b82f6" fillOpacity={0.22} />
1417+ <path d={`M${f(lampX - R * 0.16)} ${f(lampY + R * 0.1)}a${f(R * 0.16)} ${f(R * 0.16)} 0 0 1 ${f(R * 0.32)} 0z`} fill="#ef4444" />
1418+ <path d={`M${f(lampX)} ${f(lampY + R * 0.1)}a${f(R * 0.16)} ${f(R * 0.16)} 0 0 1 ${f(R * 0.16)} 0z`} fill="#3b82f6" />
1419+ <rect x={f(lampX - R * 0.2)} y={f(lampY + R * 0.1)} width={f(R * 0.4)} height={f(R * 0.05)} fill="#52525b" />
1420+ <circle cx={f(cx + R * 1.55)} cy={f(cy - R * 0.1)} r={f(R * 1.05)} fill="#14110c" stroke={p.primary} strokeWidth={f(R * 0.06)} />
1421+ <circle cx={f(cx + R * 1.55)} cy={f(cy - R * 0.1)} r={f(R * 0.86)} fill="none" stroke={p.primary} strokeOpacity={0.5} strokeWidth={2} />
1422+ <path d={dots([[cx + R * 0.55, cy - R * 0.1, R * 0.03], [cx + R * 0.66, cy - R * 0.62, R * 0.03], [cx + R * 0.66, cy + R * 0.42, R * 0.03]])} fill={p.glow} />
1423+ <circle cx={f(cx + R * 0.45)} cy={f(floor - R * 0.4)} r={f(R * 0.7)} fill={`url(#${id}-bh2)`} />
1424+ <path d={bars} fill={p.primary} stroke="#5a4520" strokeWidth={1} />
1425+ {(compact ? bags.slice(2) : bags).map((b) => (
1426+ <g key={b.m}>
1427+ <Shape shape="bag" color={p.primary} accent="#8a6a2a" x={b.x} y={b.y - R * 0.34 * b.k} size={R * 0.95 * b.k} />
1428+ <Small x={b.x} y={b.y - R * 0.3 * b.k} text="SC" color="#3a2a08" size={R * 0.16 * b.k} weight={800} tracking="0.02em" />
1429+ {!compact ? <Pill x={b.x} y={b.y + R * 0.12} text={b.m} color={p.primary} ink={p.glow} size={R * 0.07} /> : null}
1430+ </g>
1431+ ))}
1432+ {!compact ? <Small x={cx - R * 1.1} y={cy - R * 0.38} text="×1.1 ×1.4 ×2 ×3 ×4 ×12 ×50" color={p.glow} size={R * 0.062} tracking="0.12em" anchor="start" opacity={0.8} /> : null}
1433+ {!compact ? <Verb x={cx - R * 0.6} y={cy - R * 0.66} text="ESCAPE NOW" color={p.secondary} ink="#fff" size={R * 0.078} /> : null}
1434+ </g>
1435+ );
1436+}
1437+
1438+/* 25 — VOLCANO: explorer on a rope in a crystal-lined shaft, lava rising from below */
1439+function Volcano(s: Stage) {
1440+ const { W, H, cx, cy, R, id, p, compact } = s;
1441+ const lavaY = cy + R * 0.82;
1442+ const wall = (side: 1 | -1) => {
1443+ let d = `M${f(cx + side * W)} 0 L${f(cx + side * R * 0.62)} 0`;
1444+ let y = 0;
1445+ while (y < H) {
1446+ y += R * 0.22;
1447+ d += ` L${f(cx + side * R * (0.7 + s.rnd() * 0.22))} ${f(y)}`;
1448+ }
1449+ return d + ` L${f(cx + side * W)} ${f(H)} Z`;
1450+ };
1451+ const left = wall(-1);
1452+ const right = wall(1);
1453+ const embers = scatterDots(s, 30, [cx - R * 0.8, lavaY - R * 1.4, R * 1.6, R * 1.4], 0.8, 2.4);
1454+ let lava = `M${f(cx - R * 1.3)} ${f(lavaY)}`;
1455+ for (let i = 0; i < 8; i++) lava += ` q${f(R * 0.16)} ${f(i % 2 ? R * 0.08 : -R * 0.08)} ${f(R * 0.33)} 0`;
1456+ lava += ` V${f(H)} H${f(cx - R * 1.3)} Z`;
1457+ const ex = cx - R * 0.12;
1458+ const ey = cy - R * 0.02;
1459+ const rx = cx + R * 0.5;
1460+ const rungs: Array<[number, number, string]> = [[rx, cy - R * 0.98, "1.5× ASH CAVES"], [rx, cy - R * 0.48, "3× OBSIDIAN"], [rx, cy + R * 0.02, "8× CRYSTALS"], [rx, cy + R * 0.52, "30× MAGMA"]];
1461+ return (
1462+ <g>
1463+ <defs>
1464+ <Linear id={`${id}-vc1`} stops={[[0, "#fde047"], [0.35, p.glow], [1, "#7f1d1d"]]} />
1465+ <Radial id={`${id}-vc2`} color={p.primary} o0={0.75} />
1466+ </defs>
1467+ <ellipse cx={f(cx)} cy={f(lavaY)} rx={f(R * 1.4)} ry={f(R * 1.1)} fill={`url(#${id}-vc2)`} />
1468+ <path d={left} fill="#170705" stroke="#4a1a10" strokeWidth={1.5} strokeLinejoin="round" />
1469+ <path d={right} fill="#170705" stroke="#4a1a10" strokeWidth={1.5} strokeLinejoin="round" />
1470+ {!compact ? (
1471+ <>
1472+ <Shape shape="crystal" color={p.secondary} accent="#fff7d6" x={cx - R * 0.82} y={cy - R * 0.62} size={R * 0.5} rotate={40} />
1473+ <Shape shape="crystal" color={p.secondary} accent="#fff7d6" x={cx + R * 0.86} y={cy + R * 0.32} size={R * 0.55} rotate={-38} />
1474+ <Shape shape="crystal" color="#f0abfc" accent="#fdf4ff" x={cx - R * 0.94} y={cy + R * 0.12} size={R * 0.4} rotate={62} />
1475+ <Shape shape="crystal" color={p.secondary} accent="#fff7d6" x={cx + R * 0.9} y={cy - R * 0.72} size={R * 0.36} rotate={-70} />
1476+ </>
1477+ ) : null}
1478+ <line x1={f(ex)} y1={0} x2={f(ex)} y2={f(ey - R * 0.3)} stroke="#d6b07a" strokeWidth={2.5} />
1479+ <line x1={f(ex)} y1={0} x2={f(ex)} y2={f(ey - R * 0.3)} stroke="#6b4a1a" strokeWidth={2.5} strokeDasharray="3 5" />
1480+ <g fill="#120404" stroke={p.glow} strokeWidth={1.2} strokeLinejoin="round">
1481+ <circle cx={f(ex)} cy={f(ey - R * 0.22)} r={f(R * 0.085)} />
1482+ <rect x={f(ex - R * 0.09)} y={f(ey - R * 0.13)} width={f(R * 0.18)} height={f(R * 0.3)} rx={f(R * 0.04)} />
1483+ <path d={`M${f(ex - R * 0.06)} ${f(ey - R * 0.1)}L${f(ex - R * 0.03)} ${f(ey - R * 0.38)} M${f(ex + R * 0.06)} ${f(ey - R * 0.1)}L${f(ex + R * 0.04)} ${f(ey - R * 0.38)} M${f(ex - R * 0.05)} ${f(ey + R * 0.17)}L${f(ex - R * 0.16)} ${f(ey + R * 0.42)} M${f(ex + R * 0.05)} ${f(ey + R * 0.17)}L${f(ex + R * 0.12)} ${f(ey + R * 0.44)}`} fill="none" strokeWidth={f(R * 0.05)} strokeLinecap="round" />
1484+ </g>
1485+ <polygon points={`${f(ex + R * 0.06)},${f(ey - R * 0.24)} ${f(ex + R * 0.55)},${f(ey - R * 0.02)} ${f(ex + R * 0.5)},${f(ey + R * 0.3)}`} fill="#fde68a" fillOpacity={0.22} />
1486+ <circle cx={f(ex + R * 0.07)} cy={f(ey - R * 0.24)} r={f(R * 0.03)} fill="#fde68a" />
1487+ <path d={lava} fill={`url(#${id}-vc1)`} />
1488+ <path d={lava} fill="none" stroke="#fff7d6" strokeOpacity={0.7} strokeWidth={2} />
1489+ <path d={`M${f(cx - R * 0.3)} ${f(lavaY - R * 0.3)}l${f(R * 0.1)} ${f(-R * 0.12)}l${f(R * 0.1)} ${f(R * 0.12)} M${f(cx + R * 0.15)} ${f(lavaY - R * 0.24)}l${f(R * 0.1)} ${f(-R * 0.12)}l${f(R * 0.1)} ${f(R * 0.12)}`} fill="none" stroke={p.secondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
1490+ <path d={embers} fill={p.secondary} fillOpacity={0.85} />
1491+ {!compact ? <Rungs items={rungs} color={p.secondary} hot="#fff" size={R * 0.06} anchor="start" /> : null}
1492+ {!compact ? <Verb x={cx - R * 0.62} y={cy + R * 0.52} text="EVACUATE" color={p.secondary} size={R * 0.078} /> : null}
1493+ </g>
1494+ );
1495+}
1496+
1497+/* 26 — BLACK HOLE: lensed disc, time-dilation streaks, a ship spiralling to the horizon */
1498+function BlackHole(s: Stage) {
1499+ const { W, H, cx, cy, R, id, p, compact } = s;
1500+ const bx = cx + R * 0.12;
1501+ const by = cy + R * 0.12;
1502+ const rb = R * 0.4;
1503+ const stars = scatterDots(s, 60, [0, 0, W, H], 0.5, 1.3);
1504+ let streaks = "";
1505+ for (let i = 0; i < 44; i++) {
1506+ const a = s.rnd() * PI * 2;
1507+ const r0 = rb * 1.7 + s.rnd() * R * 0.5;
1508+ const r1 = r0 + R * (0.25 + s.rnd() * 0.9);
1509+ streaks += `M${f(bx + Math.cos(a) * r0)} ${f(by + Math.sin(a) * r0)}L${f(bx + Math.cos(a) * r1)} ${f(by + Math.sin(a) * r1)} `;
1510+ }
1511+ const sp = (t: number): [number, number] => {
1512+ const r = R * 1.3 * Math.exp(-0.42 * t);
1513+ return [bx + Math.cos(t - 2.6) * r, by + Math.sin(t - 2.6) * r * 0.92];
1514+ };
1515+ let spiral = "";
1516+ for (let i = 0; i <= 60; i++) {
1517+ const [x, y] = sp((i / 60) * PI * 2.4);
1518+ spiral += `${i ? "L" : "M"}${f(x)} ${f(y)} `;
1519+ }
1520+ const [shx, shy] = sp(PI * 1.35);
1521+ const [nx, ny] = sp(PI * 1.42);
1522+ const ang = (Math.atan2(ny - shy, nx - shx) * 180) / PI;
1523+ const rungs: Array<[number, number, string]> = ([0.12, 0.5, 0.86, 1.15] as const).map((k, i) => {
1524+ const [x, y] = sp(PI * k);
1525+ return [x, y - R * 0.08, ["1.5×", "4×", "15×", "60×"][i]];
1526+ });
1527+ const disc = `rotate(-14 ${f(bx)} ${f(by)})`;
1528+ return (
1529+ <g>
1530+ <defs>
1531+ <Linear id={`${id}-bk1`} stops={[[0, p.primary, 0], [0.35, "#fff", 0.95], [0.7, p.secondary, 0.9], [1, p.primary, 0]]} dir="h" />
1532+ <Radial id={`${id}-bk2`} color={p.primary} o0={0.4} />
1533+ </defs>
1534+ <path d={stars} fill="#fff" fillOpacity={0.7} />
1535+ <circle cx={f(bx)} cy={f(by)} r={f(R * 1.5)} fill={`url(#${id}-bk2)`} />
1536+ <path d={streaks} stroke={p.primary} strokeOpacity={0.5} strokeWidth={1.2} strokeLinecap="round" />
1537+ <g transform={disc}>
1538+ <path d={`M${f(bx - R * 1.3)} ${f(by)} A${f(R * 1.3)} ${f(R * 0.24)} 0 0 1 ${f(bx + R * 1.3)} ${f(by)}`} fill="none" stroke={`url(#${id}-bk1)`} strokeWidth={f(R * 0.09)} strokeOpacity={0.65} />
1539+ </g>
1540+ <circle cx={f(bx)} cy={f(by)} r={f(rb * 1.12)} fill="none" stroke="#fff" strokeOpacity={0.95} strokeWidth={f(R * 0.02)} />
1541+ <circle cx={f(bx)} cy={f(by)} r={f(rb * 1.25)} fill="none" stroke={p.secondary} strokeOpacity={0.55} strokeWidth={f(R * 0.05)} />
1542+ <circle cx={f(bx)} cy={f(by)} r={f(rb)} fill="#000" />
1543+ <g transform={disc}>
1544+ <path d={`M${f(bx - R * 1.3)} ${f(by)} A${f(R * 1.3)} ${f(R * 0.24)} 0 0 0 ${f(bx + R * 1.3)} ${f(by)}`} fill="none" stroke={`url(#${id}-bk1)`} strokeWidth={f(R * 0.11)} />
1545+ <path d={`M${f(bx - R * 1.3)} ${f(by)} A${f(R * 1.3)} ${f(R * 0.24)} 0 0 0 ${f(bx + R * 1.3)} ${f(by)}`} fill="none" stroke="#fff" strokeOpacity={0.85} strokeWidth={1.5} />
1546+ </g>
1547+ <path d={spiral} fill="none" stroke={p.secondary} strokeOpacity={0.85} strokeWidth={1.6} strokeDasharray="4 6" />
1548+ {!compact ? <Rungs items={rungs} color={p.glow} hot="#fff" size={R * 0.07} /> : null}
1549+ <g transform={`translate(${f(shx)} ${f(shy)}) rotate(${f(ang)})`}>
1550+ <path d={`M${f(-R * 0.55)} 0 h${f(-R * 0.35)}`} stroke={p.secondary} strokeWidth={f(R * 0.05)} strokeLinecap="round" strokeOpacity={0.7} />
1551+ <polygon points={`${f(R * 0.16)},0 ${f(-R * 0.12)},${f(-R * 0.1)} ${f(-R * 0.06)},0 ${f(-R * 0.12)},${f(R * 0.1)}`} fill="#f5f3ff" stroke={p.primary} strokeWidth={1} />
1552+ </g>
1553+ {!compact ? <Verb x={cx - R * 0.75} y={cy + R * 0.88} text="WARP OUT" color={p.secondary} size={R * 0.078} /> : null}
1554+ </g>
1555+ );
1556+}
1557+
1558+/* 27 — FREEFALL: skydiver above a patchwork of fields rushing up, ghost of the unopened chute */
1559+function Freefall(s: Stage) {
1560+ const { W, H, cx, cy, R, id, p, compact } = s;
1561+ const y0 = cy + R * 0.3;
1562+ const cols = 8;
1563+ const tints = ["#365314", "#65a30d", "#a16207", "#4d7c0f", "#ca8a04", "#1a2e05"];
1564+ const paths = tints.map(() => "");
1565+ for (let k = 0; k < 6; k++) {
1566+ const t0 = Math.pow(k / 6, 1.55);
1567+ const t1 = Math.pow((k + 1) / 6, 1.55);
1568+ const ya = y0 + (H - y0) * t0;
1569+ const yb = y0 + (H - y0) * t1;
1570+ const spreadA = 1 + k * 0.16;
1571+ const spreadB = 1 + (k + 1) * 0.16;
1572+ const cw = R * 0.36;
1573+ for (let j = 0; j < cols; j++) {
1574+ const c = Math.floor(s.rnd() * tints.length);
1575+ const xa0 = cx + (j - cols / 2) * cw * spreadA;
1576+ const xa1 = cx + (j + 1 - cols / 2) * cw * spreadA;
1577+ const xb0 = cx + (j - cols / 2) * cw * spreadB;
1578+ const xb1 = cx + (j + 1 - cols / 2) * cw * spreadB;
1579+ paths[c] += `M${f(xa0)} ${f(ya)}L${f(xa1)} ${f(ya)}L${f(xb1)} ${f(yb)}L${f(xb0)} ${f(yb)}Z `;
1580+ }
1581+ }
1582+ let speed = "";
1583+ for (let i = 0; i < 14; i++) {
1584+ const x = cx - R * 1.2 + s.rnd() * R * 2.4;
1585+ const y = cy - R * 1.2 + s.rnd() * R * 1.4;
1586+ speed += `M${f(x)} ${f(y)}v${f(R * (0.15 + s.rnd() * 0.35))} `;
1587+ }
1588+ const dx = cx;
1589+ const dy = cy - R * 0.32;
1590+ const rx = cx + R * 1.12;
1591+ const rungs: Array<[number, number, string]> = [[rx, cy - R * 0.95, "1.3× TERMINAL"], [rx, cy - R * 0.45, "3× CLOUD DECK"], [rx, cy + R * 0.05, "10× TREETOPS"], [rx, cy + R * 0.55, "40× GROUND RUSH"]];
1592+ return (
1593+ <g>
1594+ <defs>
1595+ <Linear id={`${id}-ff1`} stops={[[0, "#0b1a34"], [1, "#1e3a5f"]]} />
1596+ <Radial id={`${id}-ff2`} color="#fff" o0={0.18} />
1597+ </defs>
1598+ <rect width={W} height={H} fill={`url(#${id}-ff1)`} />
1599+ {paths.map((d, i) => (d ? <path key={i} d={d} fill={tints[i]} stroke="#0b1a1a" strokeWidth={1} /> : null))}
1600+ <rect x={0} y={f(y0)} width={W} height={f(H - y0)} fill={`url(#${id}-ff1)`} opacity={0.35} />
1601+ <ellipse cx={f(cx - R * 0.7)} cy={f(cy + R * 0.42)} rx={f(R * 0.5)} ry={f(R * 0.12)} fill={`url(#${id}-ff2)`} />
1602+ <ellipse cx={f(cx + R * 0.55)} cy={f(cy + R * 0.62)} rx={f(R * 0.42)} ry={f(R * 0.1)} fill={`url(#${id}-ff2)`} />
1603+ <path d={speed} stroke={p.glow} strokeOpacity={0.5} strokeWidth={1.5} strokeLinecap="round" />
1604+ {!compact ? (
1605+ <>
1606+ <path d={`M${f(dx - R * 0.72)} ${f(dy - R * 0.62)} A${f(R * 0.72)} ${f(R * 0.5)} 0 0 1 ${f(dx + R * 0.72)} ${f(dy - R * 0.62)}`} fill="none" stroke={p.secondary} strokeOpacity={0.75} strokeWidth={2} strokeDasharray="5 6" />
1607+ <path d={`M${f(dx - R * 0.72)} ${f(dy - R * 0.62)}L${f(dx - R * 0.1)} ${f(dy - R * 0.02)} M${f(dx + R * 0.72)} ${f(dy - R * 0.62)}L${f(dx + R * 0.1)} ${f(dy - R * 0.02)} M${f(dx - R * 0.25)} ${f(dy - R * 0.98)}L${f(dx - R * 0.03)} ${f(dy - R * 0.05)} M${f(dx + R * 0.25)} ${f(dy - R * 0.98)}L${f(dx + R * 0.03)} ${f(dy - R * 0.05)}`} stroke={p.secondary} strokeOpacity={0.45} strokeWidth={1} strokeDasharray="3 5" />
1608+ </>
1609+ ) : null}
1610+ <g stroke="#0b1a34" strokeWidth={1.2} strokeLinejoin="round" strokeLinecap="round">
1611+ <path d={`M${f(dx - R * 0.1)} ${f(dy - R * 0.04)}L${f(dx - R * 0.5)} ${f(dy - R * 0.3)} M${f(dx + R * 0.1)} ${f(dy - R * 0.04)}L${f(dx + R * 0.5)} ${f(dy - R * 0.3)} M${f(dx - R * 0.08)} ${f(dy + R * 0.3)}L${f(dx - R * 0.42)} ${f(dy + R * 0.62)} M${f(dx + R * 0.08)} ${f(dy + R * 0.3)}L${f(dx + R * 0.42)} ${f(dy + R * 0.62)}`} fill="none" stroke={p.secondary} strokeWidth={f(R * 0.09)} />
1612+ <rect x={f(dx - R * 0.14)} y={f(dy - R * 0.14)} width={f(R * 0.28)} height={f(R * 0.5)} rx={f(R * 0.08)} fill={p.secondary} />
1613+ <rect x={f(dx - R * 0.08)} y={f(dy - R * 0.1)} width={f(R * 0.16)} height={f(R * 0.42)} rx={f(R * 0.05)} fill="#1e293b" stroke="none" />
1614+ <circle cx={f(dx)} cy={f(dy - R * 0.27)} r={f(R * 0.12)} fill="#f8fafc" />
1615+ <path d={`M${f(dx - R * 0.1)} ${f(dy - R * 0.28)}h${f(R * 0.2)}`} stroke="#1e293b" strokeWidth={f(R * 0.06)} />
1616+ </g>
1617+ {!compact ? <Rungs items={rungs} color={p.glow} hot={p.secondary} size={R * 0.06} anchor="end" /> : null}
1618+ {!compact ? <Verb x={cx - R * 0.74} y={cy - R * 0.02} text="OPEN CHUTE" color={p.secondary} size={R * 0.078} /> : null}
1619+ </g>
1620+ );
1621+}
1622+
1623+/* 28 — CORE MELTDOWN: reactor vessel with glowing fuel rods, vertical temperature gauge with a red limit */
1624+function CoreMeltdown(s: Stage) {
1625+ const { cx, cy, R, id, p, compact } = s;
1626+ const vx = cx - R * 0.32;
1627+ const vw = R * 1.1;
1628+ const top = cy - R * 1.02;
1629+ const bottom = cy + R * 0.98;
1630+ let rods = "";
1631+ for (let i = 0; i < 5; i++) rods += `M${f(vx - vw * 0.36 + i * vw * 0.18)} ${f(top + R * 0.25)}h${f(vw * 0.08)}v${f(bottom - top - R * 0.5)}h${f(-vw * 0.08)}z `;
1632+ let bands = "";
1633+ for (let i = 1; i < 4; i++) bands += `M${f(vx - vw / 2)} ${f(top + ((bottom - top) * i) / 4)}h${f(vw)} `;
1634+ const gx = cx + R * 0.7;
1635+ const gw = R * 0.22;
1636+ const gTop = cy - R * 1.02;
1637+ const gBot = cy + R * 0.9;
1638+ const level = gBot - (gBot - gTop) * 0.74;
1639+ const limit = gBot - (gBot - gTop) * 0.82;
1640+ let grad = "";
1641+ for (let i = 0; i <= 20; i++) grad += `M${f(gx + gw / 2 + 2)} ${f(gBot - ((gBot - gTop) * i) / 20)}h${f(i % 5 === 0 ? R * 0.07 : R * 0.035)} `;
1642+ const rungs: Array<[number, number, string]> = [[gx + gw / 2 + R * 0.12, gBot - (gBot - gTop) * 0.2, "1.5×"], [gx + gw / 2 + R * 0.12, gBot - (gBot - gTop) * 0.45, "3×"], [gx + gw / 2 + R * 0.12, gBot - (gBot - gTop) * 0.65, "8×"], [gx + gw / 2 + R * 0.12, gBot - (gBot - gTop) * 0.8, "25×"]];
1643+ let readouts = "";
1644+ for (let i = 0; i < 9; i++) readouts += `M${f(cx - R * 1.1 + i * R * 0.25)} ${f(bottom + R * 0.12)}h${f(R * 0.16)}v${f(R * 0.05)}h${f(-R * 0.16)}z `;
1645+ return (
1646+ <g>
1647+ <defs>
1648+ <Linear id={`${id}-cm1`} stops={[[0, "#ef4444"], [0.25, p.secondary], [1, p.primary]]} />
1649+ <Radial id={`${id}-cm2`} color={p.primary} o0={0.55} />
1650+ <Linear id={`${id}-cm3`} stops={[[0, "#fff", 0.35], [0.5, p.glow, 0.95], [1, "#fff", 0.35]]} dir="h" />
1651+ <pattern id={`${id}-cm4`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)">
1652+ <rect width="3" height="6" fill="#ef4444" />
1653+ </pattern>
1654+ </defs>
1655+ <ellipse cx={f(vx)} cy={f(cy)} rx={f(R * 1.2)} ry={f(R * 1.3)} fill={`url(#${id}-cm2)`} />
1656+ <rect x={f(vx - vw / 2 - R * 0.1)} y={f(top - R * 0.12)} width={f(vw + R * 0.2)} height={f(R * 0.14)} rx={f(R * 0.03)} fill="#26332a" stroke={p.primary} strokeOpacity={0.5} strokeWidth={1} />
1657+ <rect x={f(vx - vw / 2 - R * 0.1)} y={f(bottom - R * 0.02)} width={f(vw + R * 0.2)} height={f(R * 0.14)} rx={f(R * 0.03)} fill="#26332a" stroke={p.primary} strokeOpacity={0.5} strokeWidth={1} />
1658+ <rect x={f(vx - vw / 2)} y={f(top)} width={f(vw)} height={f(bottom - top)} rx={f(R * 0.08)} fill="#0b1408" stroke={p.primary} strokeOpacity={0.9} strokeWidth={2} />
1659+ <path d={rods} fill={`url(#${id}-cm3)`} />
1660+ <path d={bands} stroke="#26332a" strokeWidth={f(R * 0.05)} />
1661+ <path d={bands} stroke={p.primary} strokeOpacity={0.4} strokeWidth={1} />
1662+ <path d={`M${f(vx - vw / 2)} ${f(cy - R * 0.5)}h${f(-R * 0.35)}v${f(R * 1.35)}h${f(R * 0.35)} M${f(vx + vw / 2)} ${f(cy - R * 0.2)}h${f(R * 0.22)}`} fill="none" stroke="#4b5563" strokeWidth={f(R * 0.06)} />
1663+ <rect x={f(gx - gw / 2)} y={f(gTop)} width={f(gw)} height={f(gBot - gTop)} rx={f(gw / 2)} fill="#0f1a0c" stroke="#3f4a3a" strokeWidth={2} />
1664+ <rect x={f(gx - gw / 2 + 3)} y={f(level)} width={f(gw - 6)} height={f(gBot - level - 3)} rx={f(gw / 2 - 3)} fill={`url(#${id}-cm1)`} />
1665+ <rect x={f(gx - gw / 2 + 3)} y={f(gTop + 3)} width={f(gw - 6)} height={f(limit - gTop - 3)} rx={f(gw / 2 - 3)} fill={`url(#${id}-cm4)`} opacity={0.75} />
1666+ <line x1={f(gx - gw / 2 - R * 0.06)} y1={f(limit)} x2={f(gx + gw / 2 + R * 0.06)} y2={f(limit)} stroke="#ef4444" strokeWidth={2.5} />
1667+ <path d={grad} stroke="#9ca3af" strokeOpacity={0.6} strokeWidth={1} />
1668+ {!compact ? <Rungs items={rungs} color={p.primary} hot={p.secondary} size={R * 0.065} anchor="start" /> : null}
1669+ {!compact ? <Small x={gx + gw / 2 + R * 0.12} y={limit - R * 0.12} text="LIMIT" color="#ef4444" size={R * 0.06} anchor="start" weight={800} tracking="0.2em" /> : null}
1670+ {!compact ? <Shape shape="warning" color={p.secondary} x={cx - R * 1.02} y={cy + R * 0.1} size={R * 0.46} /> : null}
1671+ {!compact ? <path d={readouts} fill={p.primary} fillOpacity={0.35} /> : null}
1672+ {!compact ? <Verb x={cx - R * 0.72} y={cy + R * 0.72} text="SHUT DOWN" color="#ef4444" ink="#fff" size={R * 0.078} /> : null}
1673+ </g>
1674+ );
1675+}
1676+
1677+/* 29 — STORM CHASE: truck racing down a highway into a tornado, debris and lightning */
1678+function StormChase(s: Stage) {
1679+ const { W, H, cx, cy, R, id, p, compact } = s;
1680+ const ground = cy + R * 0.72;
1681+ const fx = cx + R * 0.42;
1682+ const funnel = `M${f(fx - R * 0.95)} ${f(cy - R * 1.1)} C${f(fx - R * 0.6)} ${f(cy - R * 0.4)} ${f(fx - R * 0.35)} ${f(cy + R * 0.2)} ${f(fx - R * 0.12)} ${f(ground)} L${f(fx + R * 0.12)} ${f(ground)} C${f(fx + R * 0.3)} ${f(cy + R * 0.2)} ${f(fx + R * 0.55)} ${f(cy - R * 0.4)} ${f(fx + R * 0.95)} ${f(cy - R * 1.1)} Z`;
1683+ let ribs = "";
1684+ for (let i = 1; i < 6; i++) {
1685+ const t = i / 6;
1686+ const y = cy - R * 1.1 + (ground - (cy - R * 1.1)) * t;
1687+ const hw = R * (0.95 - 0.83 * t);
1688+ ribs += `M${f(fx - hw)} ${f(y)} q${f(hw)} ${f(R * 0.1)} ${f(hw * 2)} 0 `;
1689+ }
1690+ let debris = "";
1691+ for (let i = 0; i < 22; i++) {
1692+ const a = s.rnd() * PI * 2;
1693+ const d = R * (0.35 + s.rnd() * 0.85);
1694+ const x = fx + Math.cos(a) * d;
1695+ const y = cy - R * 0.2 + Math.sin(a) * d * 0.7;
1696+ const k = R * (0.02 + s.rnd() * 0.04);
1697+ debris += `M${f(x)} ${f(y)}l${f(k * 1.5)} ${f(k * 0.6)}l${f(-k * 0.6)} ${f(k * 1.2)}l${f(-k * 1.5)} ${f(-k * 0.6)}z `;
1698+ }
1699+ const bolt = `M${f(cx - R * 0.72)} ${f(cy - R * 1.15)} l${f(R * 0.12)} ${f(R * 0.4)} l${f(-R * 0.14)} ${f(R * 0.04)} l${f(R * 0.2)} ${f(R * 0.5)} l${f(-R * 0.12)} ${f(0)} l${f(R * 0.14)} ${f(R * 0.45)}`;
1700+ const roadL = `M${f(cx - R * 1.6)} ${f(H)} L${f(fx - R * 0.1)} ${f(ground)} L${f(fx + R * 0.1)} ${f(ground)} L${f(cx + R * 0.7)} ${f(H)} Z`;
1701+ let dashes = "";
1702+ for (let i = 0; i < 7; i++) {
1703+ const t0 = Math.pow(i / 7, 1.4);
1704+ const t1 = Math.pow((i + 0.45) / 7, 1.4);
1705+ const px0 = cx - R * 0.45 + (fx - (cx - R * 0.45)) * t0;
1706+ const py0 = H + (ground - H) * t0;
1707+ const px1 = cx - R * 0.45 + (fx - (cx - R * 0.45)) * t1;
1708+ const py1 = H + (ground - H) * t1;
1709+ dashes += `M${f(px0)} ${f(py0)}L${f(px1)} ${f(py1)} `;
1710+ }
1711+ const tx = cx - R * 0.42;
1712+ const ty = cy + R * 1.05;
1713+ const rx = cx + R * 1.14;
1714+ const rungs: Array<[number, number, string]> = [[rx, cy + R * 1.06, "1.5× OUTFLOW"], [rx, cy + R * 0.88, "3× DEBRIS FIELD"], [rx, cy + R * 0.7, "8× WALL CLOUD"], [rx, cy + R * 0.5, "30× VORTEX"]];
1715+ return (
1716+ <g>
1717+ <defs>
1718+ <Linear id={`${id}-sc1`} stops={[[0, "#0b0f16"], [0.5, "#1f2937"], [1, "#0f172a"]]} />
1719+ <Linear id={`${id}-sc2`} stops={[[0, "#475569"], [1, "#0f172a"]]} />
1720+ <Radial id={`${id}-sc3`} color={p.secondary} o0={0.45} />
1721+ </defs>
1722+ <rect width={W} height={H} fill={`url(#${id}-sc1)`} />
1723+ <ellipse cx={f(cx - R * 0.2)} cy={f(cy - R * 1.2)} rx={f(R * 1.5)} ry={f(R * 0.45)} fill="#111827" />
1724+ <ellipse cx={f(fx + R * 0.3)} cy={f(cy - R * 1.05)} rx={f(R * 1.2)} ry={f(R * 0.4)} fill="#1f2937" />
1725+ <circle cx={f(cx - R * 0.6)} cy={f(cy - R * 0.4)} r={f(R * 0.7)} fill={`url(#${id}-sc3)`} />
1726+ <path d={bolt} fill="none" stroke={p.secondary} strokeOpacity={0.35} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" />
1727+ <path d={bolt} fill="none" stroke="#f7fee7" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
1728+ <rect x={0} y={f(ground)} width={W} height={f(H - ground)} fill="#0b1210" />
1729+ <path d={funnel} fill={`url(#${id}-sc2)`} stroke={p.glow} strokeOpacity={0.55} strokeWidth={1.5} />
1730+ <path d={ribs} fill="none" stroke={p.glow} strokeOpacity={0.35} strokeWidth={1.2} />
1731+ <path d={debris} fill="#64748b" stroke="#cbd5e1" strokeOpacity={0.6} strokeWidth={0.8} />
1732+ <path d={roadL} fill="#1e293b" stroke="#475569" strokeWidth={1} />
1733+ <path d={dashes} stroke="#fde68a" strokeWidth={f(R * 0.03)} strokeLinecap="round" />
1734+ {!compact ? <Rungs items={rungs} color={p.glow} hot={p.secondary} size={R * 0.058} anchor="end" /> : null}
1735+ <polygon points={`${f(tx - R * 0.2)},${f(ty - R * 0.2)} ${f(tx + R * 0.2)},${f(ty - R * 0.2)} ${f(fx + R * 0.05)},${f(ground + R * 0.05)} ${f(fx - R * 0.35)},${f(ground + R * 0.05)}`} fill="#fef3c7" fillOpacity={0.12} />
1736+ <g>
1737+ <rect x={f(tx - R * 0.42)} y={f(ty - R * 0.28)} width={f(R * 0.84)} height={f(R * 0.4)} rx={f(R * 0.05)} fill="#1c1f26" stroke="#94a3b8" strokeWidth={1.5} />
1738+ <rect x={f(tx - R * 0.3)} y={f(ty - R * 0.55)} width={f(R * 0.6)} height={f(R * 0.3)} rx={f(R * 0.05)} fill="#1c1f26" stroke="#94a3b8" strokeWidth={1.5} />
1739+ <rect x={f(tx - R * 0.24)} y={f(ty - R * 0.5)} width={f(R * 0.48)} height={f(R * 0.18)} rx={f(R * 0.03)} fill="#334155" />
1740+ <path d={dots([[tx - R * 0.34, ty - R * 0.02, R * 0.04], [tx + R * 0.34, ty - R * 0.02, R * 0.04]])} fill="#ef4444" />
1741+ <rect x={f(tx - R * 0.44)} y={f(ty + R * 0.08)} width={f(R * 0.18)} height={f(R * 0.12)} rx={f(R * 0.03)} fill="#000" />
1742+ <rect x={f(tx + R * 0.26)} y={f(ty + R * 0.08)} width={f(R * 0.18)} height={f(R * 0.12)} rx={f(R * 0.03)} fill="#000" />
1743+ <path d={`M${f(tx - R * 0.3)} ${f(ty - R * 0.55)}v${f(-R * 0.22)}h${f(R * 0.06)}`} stroke="#94a3b8" strokeWidth={1.5} fill="none" />
1744+ <circle cx={f(tx - R * 0.24)} cy={f(ty - R * 0.78)} r={f(R * 0.03)} fill={p.secondary} />
1745+ </g>
1746+ {!compact ? <Verb x={cx - R * 0.7} y={cy - R * 0.02} text="ESCAPE" color={p.secondary} size={R * 0.078} /> : null}
1747+ </g>
1748+ );
1749+}
1750+
1751+/* 30 — ELEVATOR 999: a lit cabin in an endless shaft, tall condensed floor numbers, express floors */
1752+function Elevator999(s: Stage) {
1753+ const { H, cx, cy, R, id, p, compact } = s;
1754+ const railL = cx - R * 0.66;
1755+ const railR = cx + R * 0.66;
1756+ let beams = "";
1757+ for (let y = -R * 0.1; y < H + R; y += R * 0.32) beams += `M${f(railL)} ${f(y)}H${f(railR)} `;
1758+ const cabW = R * 0.98;
1759+ const cabH = R * 1.22;
1760+ const cabY = cy + R * 0.02;
1761+ const floors = [
1762+ { n: "25", m: "×3.4", y: cy + R * 1.0 },
1763+ { n: "50", m: "×11", y: cy + R * 0.52 },
1764+ { n: "100", m: "×131", y: cy - R * 0.32 },
1765+ { n: "250", m: "×198k", y: cy - R * 0.76 },
1766+ ];
1767+ let chevrons = "";
1768+ for (let i = 0; i < 3; i++) chevrons += `M${f(railR + R * 0.2)} ${f(cy - R * 0.05 - i * R * 0.16)}l${f(R * 0.08)} ${f(-R * 0.09)}l${f(R * 0.08)} ${f(R * 0.09)} `;
1769+ return (
1770+ <g>
1771+ <defs>
1772+ <Linear id={`${id}-el1`} stops={[[0, "#000", 1], [0.28, p.bg, 0.1], [1, p.bg, 0]]} />
1773+ <Linear id={`${id}-el2`} stops={[[0, p.glow, 0.9], [1, p.primary, 0.2]]} />
1774+ <Radial id={`${id}-el3`} color={p.glow} o0={0.5} />
1775+ </defs>
1776+ <rect x={f(railL)} y={0} width={f(railR - railL)} height={H} fill="#0c0d13" />
1777+ <path d={beams} stroke="#2a2d3a" strokeWidth={1.5} />
1778+ <line x1={f(railL)} y1={0} x2={f(railL)} y2={H} stroke={p.primary} strokeOpacity={0.7} strokeWidth={3} />
1779+ <line x1={f(railR)} y1={0} x2={f(railR)} y2={H} stroke={p.primary} strokeOpacity={0.7} strokeWidth={3} />
1780+ <rect x={f(railL)} y={0} width={f(railR - railL)} height={f(cy)} fill={`url(#${id}-el1)`} />
1781+ <path d={`M${f(cx - R * 0.2)} 0V${f(cabY - cabH / 2)} M${f(cx + R * 0.2)} 0V${f(cabY - cabH / 2)}`} stroke="#9aa0b4" strokeWidth={1.5} />
1782+ <ellipse cx={f(cx)} cy={f(cabY)} rx={f(R * 0.9)} ry={f(R * 0.95)} fill={`url(#${id}-el3)`} />
1783+ <rect x={f(cx - cabW / 2)} y={f(cabY - cabH / 2)} width={f(cabW)} height={f(cabH)} rx={f(R * 0.05)} fill="#151821" stroke={p.primary} strokeWidth={2.5} />
1784+ <rect x={f(cx - cabW / 2 + R * 0.08)} y={f(cabY - cabH / 2 + R * 0.2)} width={f(cabW - R * 0.16)} height={f(cabH - R * 0.28)} fill={`url(#${id}-el2)`} />
1785+ <rect x={f(cx - cabW / 2 + R * 0.08)} y={f(cabY - cabH / 2 + R * 0.2)} width={f(cabW / 2 - R * 0.11)} height={f(cabH - R * 0.28)} fill="#1b1f2c" stroke={p.primary} strokeOpacity={0.6} strokeWidth={1} />
1786+ <rect x={f(cx + R * 0.03)} y={f(cabY - cabH / 2 + R * 0.2)} width={f(cabW / 2 - R * 0.11)} height={f(cabH - R * 0.28)} fill="#1b1f2c" stroke={p.primary} strokeOpacity={0.6} strokeWidth={1} />
1787+ <rect x={f(cx - cabW / 2 + R * 0.2)} y={f(cabY - cabH / 2 + R * 0.05)} width={f(cabW - R * 0.4)} height={f(R * 0.12)} rx={2} fill="#05060a" stroke={p.secondary} strokeOpacity={0.8} strokeWidth={1} />
1788+ <Small x={cx} y={cabY - cabH / 2 + R * 0.11} text="▲ 137" color={p.secondary} size={R * 0.075} weight={800} tracking="0.2em" />
1789+ {!compact ? (
1790+ <>
1791+ {floors.map((fl) => (
1792+ <g key={fl.n}>
1793+ <line x1={f(railL - R * 0.5)} y1={f(fl.y + R * 0.1)} x2={f(railL - R * 0.04)} y2={f(fl.y + R * 0.1)} stroke={p.primary} strokeOpacity={0.5} strokeWidth={1} />
1794+ <text x={f(railL - R * 0.08)} y={f(fl.y)} textAnchor="end" dominantBaseline="central" fontSize={f(R * 0.3)} fontWeight={800} fill={p.primary} fillOpacity={fl.n === "250" ? 1 : 0.75} transform={`translate(${f(railL - R * 0.08)} 0) scale(0.66 1) translate(${f(-(railL - R * 0.08))} 0)`} style={{ fontFamily: FONT, letterSpacing: "-0.04em" }}>
1795+ {fl.n}
1796+ </text>
1797+ <Small x={railR + R * 0.1} y={fl.y} text={fl.m} color={fl.n === "250" ? p.secondary : p.glow} size={R * (fl.n === "250" ? 0.085 : 0.065)} anchor="start" weight={700} tracking="0.04em" opacity={fl.n === "250" ? 1 : 0.7} />
1798+ </g>
1799+ ))}
1800+ <path d={chevrons} fill="none" stroke={p.secondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
1801+ <Verb x={cx} y={cabY + cabH / 2 + R * 0.28} text="STEP OUT" color={p.secondary} size={R * 0.078} />
1802+ </>
1803+ ) : null}
1804+ </g>
1805+ );
1806+}
1807+
1808+/* ---------------------------------------------------- Beyond Slots originals */
1809+
1810+/* 31 — DROPZONE: capsule ricocheting through a peg tower, energy gates, value buckets, Deep Drop below */
1811+function Dropzone(s: Stage) {
1812+ const { cx, cy, R, id, p, compact } = s;
1813+ const rows = compact ? 6 : 9;
1814+ const gap = R * 0.2;
1815+ const top = cy - R * 1.02;
1816+ const pegs: Array<[number, number, number]> = [];
1817+ for (let r = 0; r < rows; r++) for (let k = 0; k <= r + 2; k++) pegs.push([cx + (k - (r + 2) / 2) * gap, top + r * gap, R * 0.022]);
1818+ let lane = 0;
1819+ let path = `M${f(cx)} ${f(top - gap * 0.8)}`;
1820+ const lit: Array<[number, number, number]> = [];
1821+ for (let r = 0; r < rows; r++) {
1822+ lane += s.rnd() > 0.5 ? 0.5 : -0.5;
1823+ const x = cx + lane * gap;
1824+ path += ` L${f(x)} ${f(top + r * gap + gap * 0.5)}`;
1825+ lit.push([x + (s.rnd() > 0.5 ? gap / 2 : -gap / 2), top + r * gap, R * 0.03]);
1826+ }
1827+ const capR = Math.min(rows - 1, 5);
1828+ const capX = cx + lane * gap * (capR / rows);
1829+ const capY = top + capR * gap + gap * 0.5;
1830+ const bucketY = top + rows * gap + gap * 0.3;
1831+ const values = ["25", "6", "2.5", "1.4", ".4", "1.4", "2.5", "6", "25"];
1832+ const bw = gap * 1.15;
1833+ const bx0 = cx - (values.length * bw) / 2;
1834+ const deepY = bucketY + gap * 1.35;
1835+ const deepPegs = scatterDots(s, 12, [cx - R * 0.9, deepY + gap * 0.3, R * 1.8, gap * 1.4], R * 0.02, R * 0.02);
1836+ return (
1837+ <g>
1838+ <defs>
1839+ <Radial id={`${id}-dz1`} color={p.primary} o0={0.35} />
1840+ <Radial id={`${id}-dz2`} color={p.glow} o0={1} mid={[0.4, p.primary, 0.7]} />
1841+ <Linear id={`${id}-dz3`} stops={[[0, p.secondary, 0.9], [0.5, p.primary, 0.25], [1, p.secondary, 0.9]]} dir="h" />
1842+ </defs>
1843+ <polygon points={`${f(cx - gap * 1.4)},${f(top - gap)} ${f(cx + gap * 1.4)},${f(top - gap)} ${f(cx + ((rows + 2) / 2) * gap + gap * 0.6)},${f(bucketY)} ${f(cx - ((rows + 2) / 2) * gap - gap * 0.6)},${f(bucketY)}`} fill={`url(#${id}-dz1)`} stroke={p.primary} strokeOpacity={0.35} strokeWidth={1} />
1844+ <path d={dots(pegs)} fill="#cbd5e1" fillOpacity={0.85} />
1845+ <path d={path} fill="none" stroke={p.primary} strokeOpacity={0.8} strokeWidth={1.5} strokeDasharray="3 4" />
1846+ <path d={dots(lit)} fill={p.glow} />
1847+ {!compact ? (
1848+ <>
1849+ <rect x={f(cx - gap * 1.3)} y={f(top + gap * 2.5 - 2)} width={f(gap * 1.1)} height={4} rx={2} fill={p.secondary} />
1850+ <Pill x={cx - gap * 1.85} y={top + gap * 2.5} text="×2" color={p.secondary} ink={p.secondary} size={R * 0.055} />
1851+ <rect x={f(cx + gap * 0.9)} y={f(top + gap * 6.5 - 2)} width={f(gap * 1.1)} height={4} rx={2} fill={p.secondary} />
1852+ <Pill x={cx + gap * 2.6} y={top + gap * 6.5} text="×3" color={p.secondary} ink={p.secondary} size={R * 0.055} />
1853+ </>
1854+ ) : null}
1855+ <circle cx={f(capX)} cy={f(capY)} r={f(gap * 0.5)} fill={`url(#${id}-dz2)`} opacity={0.8} />
1856+ <rect x={f(capX - gap * 0.16)} y={f(capY - gap * 0.3)} width={f(gap * 0.32)} height={f(gap * 0.6)} rx={f(gap * 0.16)} fill="#e0fbff" stroke={p.primary} strokeWidth={1.5} />
1857+ <rect x={f(bx0)} y={f(bucketY)} width={f(values.length * bw)} height={f(gap * 0.9)} fill={`url(#${id}-dz3)`} opacity={0.35} />
1858+ {values.map((v, i) => {
1859+ const edge = Math.abs(i - 4) / 4;
1860+ return (
1861+ <g key={i}>
1862+ <rect x={f(bx0 + i * bw + 1.5)} y={f(bucketY)} width={f(bw - 3)} height={f(gap * 0.9)} rx={3} fill="#050a14" fillOpacity={0.8} stroke={edge > 0.6 ? p.secondary : p.primary} strokeOpacity={0.4 + edge * 0.6} strokeWidth={1} />
1863+ {!compact ? <Small x={bx0 + i * bw + bw / 2} y={bucketY + gap * 0.45} text={v} color={edge > 0.6 ? p.secondary : "#e0fbff"} size={R * (edge > 0.6 ? 0.07 : 0.058)} weight={800} tracking="0" /> : null}
1864+ </g>
1865+ );
1866+ })}
1867+ {!compact ? (
1868+ <>
1869+ <rect x={f(cx - R * 1.0)} y={f(deepY)} width={f(R * 2.0)} height={f(gap * 2.2)} rx={4} fill={p.secondary} fillOpacity={0.06} stroke={p.secondary} strokeOpacity={0.6} strokeWidth={1} strokeDasharray="6 5" />
1870+ <path d={deepPegs} fill={p.secondary} fillOpacity={0.55} />
1871+ <Pill x={cx} y={deepY} text="DEEP DROP ×0 → ×10" color={p.secondary} ink={p.secondary} bg={p.bg} size={R * 0.058} />
1872+ <Verb x={cx + R * 0.92} y={cy - R * 0.5} text="DROP" color={p.primary} size={R * 0.078} />
1873+ </>
1874+ ) : null}
1875+ </g>
1876+ );
1877+}
1878+
1879+/* 32 — GRID//BREAK: 8×8 neon block grid, a wave down one column, detonating cluster, ×2 ×4 ×8 chain */
1880+function GridBreak(s: Stage) {
1881+ const { W, H, cx, cy, R, id, p, compact } = s;
1882+ const n = 8;
1883+ const c = R * 0.225;
1884+ const gx = cx - (n * c) / 2 - R * 0.2;
1885+ const gy = cy - (n * c) / 2 - R * 0.05;
1886+ const col = 5;
1887+ const tints = [p.primary, p.secondary, "#a78bfa", "#f472b6", "#facc15", "#22d3ee"];
1888+ const paths = tints.map(() => "");
1889+ const blown = new Set(["5,3", "5,4", "4,4", "5,5", "6,4", "6,5"]);
1890+ for (let r = 0; r < n; r++) {
1891+ for (let k = 0; k < n; k++) {
1892+ if (blown.has(`${k},${r}`)) continue;
1893+ const t = Math.floor(s.rnd() * tints.length);
1894+ paths[t] += `M${f(gx + k * c + 2)} ${f(gy + r * c + 2)}h${f(c - 4)}v${f(c - 4)}h${f(-(c - 4))}z `;
1895+ }
1896+ }
1897+ const ex = gx + col * c + c / 2;
1898+ const ey = gy + 4 * c + c / 2;
1899+ let shards = "";
1900+ for (let i = 0; i < 12; i++) {
1901+ const a = s.rnd() * PI * 2;
1902+ const d = c * (0.9 + s.rnd() * 1.6);
1903+ const k = c * (0.12 + s.rnd() * 0.16);
1904+ shards += `M${f(ex + Math.cos(a) * d)} ${f(ey + Math.sin(a) * d)}h${f(k)}v${f(k)}h${f(-k)}z `;
1905+ }
1906+ let scan = "";
1907+ for (let y = 0; y < H; y += 5) scan += `M0 ${f(y)}H${f(W)} `;
1908+ return (
1909+ <g>
1910+ <defs>
1911+ <Linear id={`${id}-gb1`} stops={[[0, p.secondary, 0], [0.5, p.secondary, 0.55], [1, p.secondary, 0.15]]} />
1912+ <Radial id={`${id}-gb2`} color={p.glow} o0={0.9} />
1913+ </defs>
1914+ <path d={scan} stroke="#fff" strokeOpacity={0.035} strokeWidth={1.5} />
1915+ <rect x={f(gx - R * 0.08)} y={f(gy - R * 0.08)} width={f(n * c + R * 0.16)} height={f(n * c + R * 0.16)} rx={f(R * 0.06)} fill="#070b10" stroke={p.primary} strokeOpacity={0.5} strokeWidth={2} />
1916+ {paths.map((d, i) => (d ? <path key={i} d={d} fill={tints[i]} fillOpacity={0.82} /> : null))}
1917+ <rect x={f(gx + col * c)} y={f(gy - R * 0.3)} width={f(c)} height={f(n * c + R * 0.3)} fill={`url(#${id}-gb1)`} />
1918+ <line x1={f(ex)} y1={f(gy - R * 0.3)} x2={f(ex)} y2={f(gy + n * c)} stroke="#fff" strokeOpacity={0.85} strokeWidth={1.5} />
1919+ <polygon points={`${f(ex - c * 0.45)},${f(gy + n * c + R * 0.22)} ${f(ex + c * 0.45)},${f(gy + n * c + R * 0.22)} ${f(ex)},${f(gy + n * c + R * 0.06)}`} fill={p.secondary} />
1920+ <circle cx={f(ex)} cy={f(ey)} r={f(c * 1.5)} fill={`url(#${id}-gb2)`} />
1921+ <polygon points={svgStar(ex, ey, c * 1.7, c * 0.7, 8)} fill={p.glow} fillOpacity={0.9} />
1922+ <polygon points={svgStar(ex, ey, c * 0.9, c * 0.35, 8)} fill="#fff" />
1923+ <path d={shards} fill={p.primary} />
1924+ {!compact ? (
1925+ <>
1926+ <Pill x={gx + n * c + R * 0.28} y={gy + c * 5.5} text="×2" color={p.primary} ink={p.primary} size={R * 0.07} />
1927+ <Pill x={gx + n * c + R * 0.29} y={gy + c * 3.9} text="×4" color={p.primary} ink={p.glow} size={R * 0.085} />
1928+ <Pill x={gx + n * c + R * 0.3} y={gy + c * 2.0} text="×8" color={p.secondary} ink="#fff" bg="#0c2233" size={R * 0.1} />
1929+ <Small x={gx + n * c + R * 0.3} y={gy + c * 1.0} text="CHAIN" color={p.secondary} size={R * 0.06} tracking="0.24em" />
1930+ <Verb x={ex} y={gy + n * c + R * 0.45} text="FIRE WAVE" color={p.secondary} size={R * 0.072} />
1931+ </>
1932+ ) : null}
1933+ </g>
1934+ );
1935+}
1936+
1937+/* 33 — THE VAULT: colossal door, five concentric locked layers, digits being revealed at the core */
1938+function TheVault(s: Stage) {
1939+ const { cx, cy, R, id, p, compact } = s;
1940+ const vx = cx;
1941+ const vy = cy + R * 0.08;
1942+ let bolts = "";
1943+ const bl: Array<[number, number, number]> = [];
1944+ for (let i = 0; i < 18; i++) {
1945+ const a = (i / 18) * PI * 2;
1946+ bl.push([vx + Math.cos(a) * R * 1.3, vy + Math.sin(a) * R * 1.3, R * 0.028]);
1947+ }
1948+ bolts = dots(bl);
1949+ let ticks = "";
1950+ for (let i = 0; i < 60; i++) {
1951+ const a = (i / 60) * PI * 2;
1952+ const len = i % 5 === 0 ? R * 0.1 : R * 0.05;
1953+ ticks += `M${f(vx + Math.cos(a) * R * 1.05)} ${f(vy + Math.sin(a) * R * 1.05)}L${f(vx + Math.cos(a) * (R * 1.05 - len))} ${f(vy + Math.sin(a) * (R * 1.05 - len))} `;
1954+ }
1955+ const gaugeA0 = PI * 0.75;
1956+ const gaugeA1 = PI * 2.05;
1957+ const layers = ["1.20×", "1.71×", "3.12×", "7.79×", "31.2×"];
1958+ const lx = cx - R * 1.12;
1959+ const rungs: Array<[number, number, string]> = layers.map((l, i) => [lx, cy + R * 0.9 - i * R * 0.36, l]);
1960+ const digits = ["4", "7", "?"];
1961+ return (
1962+ <g>
1963+ <defs>
1964+ <Radial id={`${id}-tv1`} color={p.glow} o0={0.95} mid={[0.5, p.primary, 0.45]} />
1965+ <Linear id={`${id}-tv2`} stops={[[0, "#2a2530"], [0.5, "#0f0d13"], [1, "#26212c"]]} dir="d" />
1966+ </defs>
1967+ <circle cx={f(vx)} cy={f(vy)} r={f(R * 1.42)} fill={`url(#${id}-tv2)`} stroke={p.primary} strokeOpacity={0.9} strokeWidth={f(R * 0.03)} />
1968+ <circle cx={f(vx)} cy={f(vy)} r={f(R * 1.18)} fill="#100e14" stroke={p.primary} strokeOpacity={0.5} strokeWidth={1.5} />
1969+ <path d={bolts} fill={p.primary} stroke="#5a4a20" strokeWidth={1} />
1970+ <path d={ticks} stroke={p.glow} strokeOpacity={0.6} strokeWidth={1.2} />
1971+ <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.92)} fill="#0b0a0e" stroke={p.primary} strokeOpacity={0.7} strokeWidth={2} />
1972+ <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.82)} fill="none" stroke={p.secondary} strokeOpacity={0.9} strokeWidth={f(R * 0.05)} strokeDasharray={`${f(R * 0.09)} ${f(R * 0.06)}`} />
1973+ <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.82)} fill="none" stroke={p.secondary} strokeOpacity={0.35} strokeWidth={f(R * 0.12)} strokeDasharray={`${f(R * 0.4)} ${f(R * 0.9)}`} />
1974+ <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.7)} fill="#141117" stroke={p.primary} strokeOpacity={0.6} strokeWidth={1.5} />
1975+ <path d={svgArc(vx, vy, R * 0.6, gaugeA0, gaugeA1)} fill="none" stroke="#2b2630" strokeWidth={f(R * 0.09)} strokeLinecap="round" />
1976+ <path d={svgArc(vx, vy, R * 0.6, gaugeA0, gaugeA0 + (gaugeA1 - gaugeA0) * 0.7)} fill="none" stroke={p.primary} strokeWidth={f(R * 0.09)} strokeLinecap="round" />
1977+ <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.48)} fill={`url(#${id}-tv1)`} />
1978+ <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.48)} fill="none" stroke={p.glow} strokeWidth={2} />
1979+ {digits.map((d, i) => (
1980+ <g key={i}>
1981+ <rect x={f(vx - R * 0.36 + i * R * 0.25)} y={f(vy - R * 0.16)} width={f(R * 0.22)} height={f(R * 0.32)} rx={f(R * 0.03)} fill="#0b0a0e" stroke={d === "?" ? p.secondary : p.primary} strokeWidth={1.5} />
1982+ <Small x={vx - R * 0.25 + i * R * 0.25} y={vy} text={d} color={d === "?" ? p.secondary : p.glow} size={R * 0.2} weight={800} tracking="0" />
1983+ </g>
1984+ ))}
1985+ <circle cx={f(vx)} cy={f(vy - R * 1.3)} r={f(R * 0.05)} fill="#ef4444" />
1986+ <circle cx={f(vx)} cy={f(vy - R * 1.3)} r={f(R * 0.11)} fill="#ef4444" fillOpacity={0.3} />
1987+ {!compact ? (
1988+ <>
1989+ <path d={`M${f(lx - R * 0.02)} ${f(cy + R * 1.0)}V${f(cy - R * 0.62)}`} stroke={p.primary} strokeOpacity={0.35} strokeWidth={1} />
1990+ <Rungs items={rungs} color={p.primary} hot={p.glow} size={R * 0.065} anchor="start" />
1991+ <Small x={cx + R * 1.02} y={cy - R * 0.68} text="LAYER 3 / 5" color={p.secondary} size={R * 0.062} tracking="0.22em" anchor="end" />
1992+ <Small x={cx + R * 1.02} y={cy - R * 0.54} text="BIOMETRIC RING" color={p.glow} size={R * 0.055} tracking="0.16em" anchor="end" opacity={0.75} />
1993+ <Verb x={cx - R * 0.42} y={cy + R * 1.14} text="SECURE" color={p.primary} size={R * 0.072} />
1994+ <Verb x={cx + R * 0.45} y={cy + R * 1.14} text="OPEN" color={p.secondary} size={R * 0.072} />
1995+ </>
1996+ ) : null}
1997+ </g>
1998+ );
1999+}
2000+
2001+/* 34 — ORBIT: burning core, concentric orbits with planets/satellites/comets, an impulse beam, Supernova */
2002+function Orbit(s: Stage) {
2003+ const { W, H, cx, cy, R, id, p, compact } = s;
2004+ const stars = scatterDots(s, 60, [0, 0, W, H], 0.5, 1.3);
2005+ const ox = cx;
2006+ const oy = cy + R * 0.05;
2007+ const orbits = [0.42, 0.68, 0.94, 1.2];
2008+ const debris: Array<[number, number, number]> = [];
2009+ for (let i = 0; i < 16; i++) {
2010+ const r = R * orbits[Math.floor(s.rnd() * orbits.length)];
2011+ const a = s.rnd() * PI * 2;
2012+ debris.push([ox + Math.cos(a) * r, oy + Math.sin(a) * r, R * 0.018]);
2013+ }
2014+ const at = (k: number, a: number): [number, number] => [ox + Math.cos(a) * R * orbits[k], oy + Math.sin(a) * R * orbits[k]];
2015+ const [sx, sy] = at(0, -2.2);
2016+ const [px, py] = at(1, 0.6);
2017+ const [cmx, cmy] = at(2, -0.9);
2018+ const [ggx, ggy] = at(3, 2.62);
2019+ const [qx, qy] = at(3, -0.62);
2020+ const beamA = -1.05;
2021+ const beamLen = R * 1.45;
2022+ const bx1 = ox + Math.cos(beamA - 0.09) * beamLen;
2023+ const by1 = oy + Math.sin(beamA - 0.09) * beamLen;
2024+ const bx2 = ox + Math.cos(beamA + 0.09) * beamLen;
2025+ const by2 = oy + Math.sin(beamA + 0.09) * beamLen;
2026+ return (
2027+ <g>
2028+ <defs>
2029+ <Radial id={`${id}-ob1`} color={p.secondary} o0={0.8} mid={[0.25, p.secondary, 0.3]} />
2030+ <Radial id={`${id}-ob2`} color="#fff" o0={1} mid={[0.45, p.secondary, 1]} o1={0.9} />
2031+ <Linear id={`${id}-ob3`} stops={[[0, p.secondary, 0.7], [1, p.secondary, 0]]} />
2032+ </defs>
2033+ <path d={stars} fill="#fff" fillOpacity={0.7} />
2034+ <circle cx={f(ox)} cy={f(oy)} r={f(R * 0.75)} fill={`url(#${id}-ob1)`} />
2035+ {orbits.map((k, i) => (
2036+ <circle key={i} cx={f(ox)} cy={f(oy)} r={f(R * k)} fill="none" stroke={p.primary} strokeOpacity={i === 3 ? 0.45 : 0.6} strokeWidth={1.2} strokeDasharray={i === 3 ? "4 7" : undefined} />
2037+ ))}
2038+ <polygon points={`${f(ox)},${f(oy)} ${f(bx1)},${f(by1)} ${f(bx2)},${f(by2)}`} fill={`url(#${id}-ob3)`} opacity={0.65} />
2039+ <line x1={f(ox)} y1={f(oy)} x2={f(ox + Math.cos(beamA) * beamLen)} y2={f(oy + Math.sin(beamA) * beamLen)} stroke="#fff" strokeOpacity={0.9} strokeWidth={1.5} />
2040+ <circle cx={f(ox + Math.cos(beamA) * R * 0.94)} cy={f(oy + Math.sin(beamA) * R * 0.94)} r={f(R * 0.06)} fill="#fff" />
2041+ <polygon points={svgStar(ox, oy, R * 0.36, R * 0.18, 12)} fill={p.secondary} fillOpacity={0.6} />
2042+ <circle cx={f(ox)} cy={f(oy)} r={f(R * 0.2)} fill={`url(#${id}-ob2)`} />
2043+ <path d={dots(debris)} fill="#e2e8f0" fillOpacity={0.85} />
2044+ <Shape shape="satellite" color="#cbd5e1" accent={p.secondary} x={sx} y={sy} size={R * 0.34} rotate={-20} />
2045+ <Shape shape="planet" color="#60a5fa" accent={p.primary} x={px} y={py} size={R * 0.5} rotate={-15} />
2046+ <Shape shape="comet" color="#fef3c7" accent={p.secondary} x={cmx} y={cmy} size={R * 0.46} />
2047+ <Shape shape="planet" color="#fb923c" accent="#fde68a" x={ggx} y={ggy} size={R * 0.72} rotate={12} />
2048+ {!compact ? (
2049+ <>
2050+ <polygon points={svgStar(qx, qy, R * 0.16, R * 0.05, 4)} fill={p.primary} />
2051+ <Small x={sx} y={sy + R * 0.22} text="0.8×" color={p.glow} size={R * 0.055} weight={700} tracking="0.02em" />
2052+ <Small x={px} y={py + R * 0.3} text="2×" color={p.glow} size={R * 0.06} weight={700} tracking="0.02em" />
2053+ <Small x={cmx + R * 0.02} y={cmy - R * 0.26} text="4×" color={p.glow} size={R * 0.06} weight={700} tracking="0.02em" />
2054+ <Small x={ggx} y={ggy + R * 0.4} text="8×" color={p.glow} size={R * 0.065} weight={800} tracking="0.02em" />
2055+ <Small x={qx} y={qy - R * 0.22} text="QUASAR 25×" color={p.primary} size={R * 0.055} weight={800} tracking="0.1em" />
2056+ <Pill x={cx - R * 0.82} y={cy - R * 0.62} text="SUPERNOVA" color={p.secondary} ink={p.secondary} size={R * 0.062} />
2057+ <Verb x={cx + R * 0.7} y={cy + R * 1.08} text="FIRE IMPULSE" color={p.secondary} size={R * 0.072} />
2058+ </>
2059+ ) : null}
2060+ </g>
2061+ );
2062+}
2063+
2064+/* 35 — ESCAPE 99: tower cross-section with rooms and a stairwell, checkpoint rail 10 · 25 · 50 · 75 · 99 */
2065+function Escape99(s: Stage) {
2066+ const { H, cx, cy, R, id, p, compact } = s;
2067+ const tw = R * 1.16;
2068+ const tx = cx - R * 0.3;
2069+ const top = cy - R * 1.12;
2070+ const fh = R * 0.29;
2071+ const nFloors = Math.floor((H - top) / fh) + 1;
2072+ let floors = "";
2073+ for (let i = 1; i < nFloors; i++) floors += `M${f(tx - tw / 2)} ${f(top + i * fh)}h${f(tw)} `;
2074+ let stairs = "";
2075+ for (let i = 0; i < nFloors; i++) {
2076+ const y = top + i * fh;
2077+ stairs += `M${f(tx - R * 0.1)} ${f(y + fh)} l${f(R * 0.05)} ${f(-fh * 0.25)} h${f(R * 0.05)} l${f(R * 0.05)} ${f(-fh * 0.25)} h${f(R * 0.05)} l${f(R * 0.05)} ${f(-fh * 0.25)} `;
2078+ }
2079+ const roomX = (side: -1 | 1) => tx + side * (R * 0.1 + (tw / 2 - R * 0.1) / 2);
2080+ const fy = (i: number) => top + i * fh + fh / 2;
2081+ let crenel = "";
2082+ for (let i = 0; i < 6; i++) crenel += `M${f(tx - tw / 2 + i * (tw / 6) + 2)} ${f(top)}v${f(-R * 0.08)}h${f(tw / 12)}v${f(R * 0.08)}z `;
2083+ let spikes = "";
2084+ for (let i = 0; i < 5; i++) spikes += `M${f(roomX(-1) - R * 0.14 + i * R * 0.07)} ${f(fy(4) + fh * 0.4)}l${f(R * 0.035)} ${f(-fh * 0.55)}l${f(R * 0.035)} ${f(fh * 0.55)}z `;
2085+ const hx = tx;
2086+ const hy = fy(3) + fh * 0.05;
2087+ const rx = cx + R * 0.9;
2088+ const cps = [
2089+ { n: "10", m: "×1.4", t: 0 },
2090+ { n: "25", m: "×2.9", t: 0.25 },
2091+ { n: "50", m: "×18", t: 0.5 },
2092+ { n: "75", m: "×190", t: 0.75 },
2093+ { n: "99", m: "×3k", t: 1 },
2094+ ];
2095+ const railTop = cy - R * 0.72;
2096+ const railBot = cy + R * 1.05;
2097+ const leaves = scatterDots(s, 16, [cx - R * 1.25, cy + R * 0.4, R * 0.7, R * 0.9], 1.2, 2.6);
2098+ return (
2099+ <g>
2100+ <defs>
2101+ <Radial id={`${id}-es1`} color={p.primary} o0={0.4} />
2102+ <Radial id={`${id}-es2`} color={p.secondary} o0={0.9} />
2103+ <Linear id={`${id}-es3`} stops={[[0, p.glow, 0.8], [1, p.primary, 0.1]]} />
2104+ </defs>
2105+ <ellipse cx={f(tx)} cy={f(top)} rx={f(R * 1.2)} ry={f(R * 0.7)} fill={`url(#${id}-es1)`} />
2106+ <rect x={f(tx - tw / 2)} y={f(top)} width={f(tw)} height={f(H - top)} fill="#0a1411" stroke={p.primary} strokeOpacity={0.75} strokeWidth={2} />
2107+ <path d={crenel} fill="#0a1411" stroke={p.primary} strokeOpacity={0.75} strokeWidth={1.5} />
2108+ <path d={floors} stroke={p.primary} strokeOpacity={0.35} strokeWidth={1} />
2109+ <rect x={f(tx - R * 0.1)} y={f(top)} width={f(R * 0.2)} height={f(H - top)} fill="#06090a" />
2110+ <path d={stairs} fill="none" stroke={p.primary} strokeOpacity={0.6} strokeWidth={1.2} />
2111+ <circle cx={f(tx)} cy={f(top + fh * 0.5)} r={f(R * 0.22)} fill={`url(#${id}-es2)`} />
2112+ <Shape shape="ring" color="#a78bfa" accent="#f5f3ff" x={roomX(1)} y={fy(1)} size={fh * 0.9} />
2113+ <Shape shape="chest" color="#b8741c" accent="#fde68a" x={roomX(-1)} y={fy(2) + fh * 0.05} size={fh * 0.9} />
2114+ <Shape shape="skull" color="#f8fafc" accent="#94a3b8" x={roomX(1)} y={fy(3)} size={fh * 0.8} />
2115+ <path d={spikes} fill={p.secondary} />
2116+ <Shape shape="hex" color={p.primary} accent={p.glow} x={roomX(1)} y={fy(5)} size={fh * 0.85} label="×" />
2117+ <path d={`M${f(roomX(-1))} ${f(fy(6) + fh * 0.35)}V${f(fy(6))} L${f(roomX(-1) - R * 0.14)} ${f(fy(6) - fh * 0.3)} M${f(roomX(-1))} ${f(fy(6))} L${f(roomX(-1) + R * 0.14)} ${f(fy(6) - fh * 0.3)}`} fill="none" stroke={p.secondary} strokeWidth={2.5} strokeLinecap="round" />
2118+ <Shape shape="chest" color="#b8741c" accent="#fde68a" x={roomX(1)} y={fy(7) + fh * 0.05} size={fh * 0.9} />
2119+ <g fill={p.glow} stroke="#06090a" strokeWidth={1}>
2120+ <circle cx={f(hx)} cy={f(hy - fh * 0.22)} r={f(fh * 0.13)} />
2121+ <rect x={f(hx - fh * 0.1)} y={f(hy - fh * 0.1)} width={f(fh * 0.2)} height={f(fh * 0.32)} rx={f(fh * 0.05)} />
2122+ <path d={`M${f(hx - fh * 0.06)} ${f(hy + fh * 0.2)}l${f(-fh * 0.1)} ${f(fh * 0.22)} M${f(hx + fh * 0.06)} ${f(hy + fh * 0.2)}l${f(fh * 0.12)} ${f(fh * 0.2)}`} stroke={p.glow} strokeWidth={f(fh * 0.09)} strokeLinecap="round" fill="none" />
2123+ </g>
2124+ <path d={leaves} fill={p.primary} fillOpacity={0.45} />
2125+ {!compact ? (
2126+ <>
2127+ <line x1={f(rx)} y1={f(railTop)} x2={f(rx)} y2={f(railBot)} stroke={`url(#${id}-es3)`} strokeWidth={3} strokeLinecap="round" />
2128+ {cps.map((cp) => {
2129+ const y = railBot - (railBot - railTop) * cp.t;
2130+ const summit = cp.n === "99";
2131+ return (
2132+ <g key={cp.n}>
2133+ <circle cx={f(rx)} cy={f(y)} r={f(R * (summit ? 0.05 : 0.03))} fill={summit ? p.secondary : p.glow} />
2134+ <Small x={rx - R * 0.09} y={y} text={cp.n} color={summit ? p.secondary : "#fff"} size={R * (summit ? 0.11 : 0.075)} anchor="end" weight={800} tracking="0" />
2135+ <Small x={rx + R * 0.09} y={y} text={cp.m} color={summit ? p.secondary : p.glow} size={R * (summit ? 0.07 : 0.055)} anchor="start" weight={700} tracking="0.04em" opacity={summit ? 1 : 0.75} />
2136+ </g>
2137+ );
2138+ })}
2139+ <Verb x={cx - R * 0.98} y={cy - R * 0.62} text="CLIMB" color={p.primary} size={R * 0.075} />
2140+ <Verb x={cx - R * 0.9} y={cy - R * 0.3} text="CASH OUT" color={p.secondary} size={R * 0.068} />
2141+ </>
2142+ ) : null}
2143+ </g>
2144+ );
2145+}
2146+
1176 /* Fallback for unknown slugs: palette glow + the game's top symbols if known. */ 2147 /* Fallback for unknown slugs: palette glow + the game's top symbols if known. */
1177 function Generic(s: Stage) { 2148 function Generic(s: Stage) {
1178 const { W, H, cx, cy, R, id, p, compact } = s; 2149 const { W, H, cx, cy, R, id, p, compact } = s;
@@ -1217,6 +2188,23 @@ const SCENES: Record<string, (s: Stage) => React.ReactNode> = {
1217 "reel-reactor": ReelReactor, 2188 "reel-reactor": ReelReactor,
1218 obsidian: Obsidian, 2189 obsidian: Obsidian,
1219 "spinza-original": SpinzaOriginal, 2190 "spinza-original": SpinzaOriginal,
2191+ /* Risk Games */
2192+ skyfall: Skyfall,
2193+ "deep-dive": DeepDive,
2194+ "rocket-run": RocketRun,
2195+ "bank-heist": BankHeist,
2196+ volcano: Volcano,
2197+ "black-hole": BlackHole,
2198+ freefall: Freefall,
2199+ "core-meltdown": CoreMeltdown,
2200+ "storm-chase": StormChase,
2201+ "elevator-999": Elevator999,
2202+ /* Beyond Slots */
2203+ dropzone: Dropzone,
2204+ "grid-break": GridBreak,
2205+ "the-vault": TheVault,
2206+ orbit: Orbit,
2207+ "escape-99": Escape99,
1220 }; 2208 };
1221 2209
1222 /* ------------------------------------------------------------------- title */ 2210 /* ------------------------------------------------------------------- title */
@@ -1243,6 +2231,12 @@ interface TitleSpec {
1243 outline?: string; 2231 outline?: string;
1244 /** Scale factor applied to the fitted size (Obsidian whispers). */ 2232 /** Scale factor applied to the fitted size (Obsidian whispers). */
1245 scale?: number; 2233 scale?: number;
2234+ /** Monospace face (control-room / puzzle-grid voices). */
2235+ mono?: boolean;
2236+ /** Horizontal glyph stretch: < 1 tall condensed, > 1 extended. */
2237+ stretch?: number;
2238+ /** A substring of the name rendered in another colour (GRID//BREAK). */
2239+ accent?: { text: string; fill: string };
1246 } 2240 }
1247 2241
1248 const TITLES: Record<string, TitleSpec> = { 2242 const TITLES: Record<string, TitleSpec> = {
@@ -1266,6 +2260,23 @@ const TITLES: Record<string, TitleSpec> = {
1266 "reel-reactor": { caps: true, tracking: -0.03, weight: 800, fill: "#a3e635", glow: "#22c55e", eyebrow: "EVERY DEAD SPIN HEATS THE CORE", eyebrowColor: "#facc15" }, 2260 "reel-reactor": { caps: true, tracking: -0.03, weight: 800, fill: "#a3e635", glow: "#22c55e", eyebrow: "EVERY DEAD SPIN HEATS THE CORE", eyebrowColor: "#facc15" },
1267 obsidian: { caps: true, tracking: 0.42, weight: 700, fill: "#f5e6c8", align: "center", scale: 0.62 }, 2261 obsidian: { caps: true, tracking: 0.42, weight: 700, fill: "#f5e6c8", align: "center", scale: 0.62 },
1268 "spinza-original": { caps: true, tracking: -0.02, weight: 800, fill: ["#ffffff", "#c4b5fd"], glow: "#8b5cf6", subline: true, eyebrowColor: "#22d3ee" }, 2262 "spinza-original": { caps: true, tracking: -0.02, weight: 800, fill: ["#ffffff", "#c4b5fd"], glow: "#8b5cf6", subline: true, eyebrowColor: "#22d3ee" },
2263+ /* Risk Games — each in its own voice */
2264+ skyfall: { caps: true, tracking: 0.2, weight: 700, fill: ["#ffffff", "#7dd3fc"], glow: "#7dd3fc", eyebrow: "ALTITUDE PAYS · RISK GAME", eyebrowColor: "#fbbf24" },
2265+ "deep-dive": { caps: true, tracking: 0.3, weight: 700, fill: ["#dffaff", "#22d3ee"], glow: "#0e7490", eyebrow: "1,000 M = ×2 · 3,000 M = ×5", eyebrowColor: "#34d399" },
2266+ "rocket-run": { caps: true, tracking: -0.05, weight: 800, skew: -9, fill: ["#fff7ed", "#fb923c"], glow: "#fb923c", eyebrow: "MARS OR BUST", eyebrowColor: "#f472b6" },
2267+ "bank-heist": { caps: true, tracking: 0.08, weight: 800, fill: ["#f5e6c8", "#c9a961"], glow: "#ef4444", eyebrow: "THE BAG KEEPS FILLING", eyebrowColor: "#ef4444" },
2268+ volcano: { caps: true, tracking: -0.01, weight: 800, fill: ["#ffd166", "#ff5c3d"], outline: "#2a0c07", glow: "#ff8a5c", eyebrow: "DEEPER CRYSTALS · RISING LAVA", eyebrowColor: "#fbbf24" },
2269+ "black-hole": { caps: true, tracking: 0.34, weight: 700, stretch: 1.14, fill: ["#ffffff", "#a78bfa"], glow: "#f0abfc", eyebrow: "TIME SLOWS · THE MULTIPLIER DOESN'T", eyebrowColor: "#f0abfc" },
2270+ freefall: { caps: true, tracking: -0.06, weight: 800, skew: 14, fill: ["#ffffff", "#60a5fa"], glow: "#60a5fa", eyebrow: "PULL LATE · NOT TOO LATE", eyebrowColor: "#f97316" },
2271+ "core-meltdown": { caps: true, tracking: 0.1, weight: 700, mono: true, fill: "#bef264", glow: "#a3e635", eyebrow: "TEMP RISING · LIMIT 100 %", eyebrowColor: "#facc15" },
2272+ "storm-chase": { caps: true, tracking: -0.03, weight: 800, skew: -6, fill: ["#f8fafc", "#94a3b8"], glow: "#a3e635", eyebrow: "DRIVE INTO THE WALL CLOUD", eyebrowColor: "#a3e635" },
2273+ "elevator-999": { caps: true, tracking: 0.04, weight: 800, stretch: 0.74, fill: ["#f3e2ad", "#c9a961"], glow: "#38bdf8", eyebrow: "EVERY FLOOR IS A DECISION", eyebrowColor: "#38bdf8", split: true },
2274+ /* Beyond Slots */
2275+ dropzone: { caps: true, tracking: 0.22, weight: 800, fill: ["#ffffff", "#22d3ee"], glow: "#f472b6", eyebrow: "RELEASE THE CAPSULE", eyebrowColor: "#f472b6" },
2276+ "grid-break": { caps: true, tracking: 0.02, weight: 700, mono: true, fill: "#bef264", accent: { text: "//", fill: "#38bdf8" }, glow: "#a3e635", eyebrow: "ONE WAVE · INFINITE CHAINS", eyebrowColor: "#38bdf8" },
2277+ "the-vault": { caps: true, tracking: 0.3, weight: 700, fill: ["#fff1c2", "#c9a961"], glow: "#c9a961", eyebrow: "FIVE LAYERS · ONE DECISION AT EACH", eyebrowColor: "#38bdf8" },
2278+ orbit: { caps: true, tracking: 0.5, weight: 700, fill: ["#ffffff", "#f0abfc"], glow: "#facc15", eyebrow: "FIRE THE IMPULSE · RIDE THE CHAOS", eyebrowColor: "#facc15" },
2279+ "escape-99": { caps: true, tracking: -0.02, weight: 800, fill: ["#d1fae5", "#34d399"], outline: "#06090a", glow: "#f97316", eyebrow: "99 FLOORS · CASH OUT ON ANY", eyebrowColor: "#f97316", split: true },
1269 }; 2280 };
1270 2281
1271 function splitLines(name: string, spec: TitleSpec, v: ArtVariant): string[] { 2282 function splitLines(name: string, spec: TitleSpec, v: ArtVariant): string[] {
@@ -1278,23 +2289,25 @@ function splitLines(name: string, spec: TitleSpec, v: ArtVariant): string[] {
1278 return [words.slice(0, mid).join(" "), words.slice(mid).join(" ")]; 2289 return [words.slice(0, mid).join(" "), words.slice(mid).join(" ")];
1279 } 2290 }
1280 2291
1281−function estimateWidth(text: string, caps: boolean, tracking: number): number { 2292+function estimateWidth(text: string, caps: boolean, tracking: number, mono = false, stretch = 1): number {
1282− const cw = caps ? 0.7 : 0.6; 2293+ const cw = mono ? 0.64 : caps ? 0.7 : 0.6;
1283− return text.length * (cw + tracking) - tracking; 2294+ return (text.length * (cw + tracking) - tracking) * stretch;
1284 } 2295 }
1285 2296
1286 function Title({ s, name, showName }: { s: Stage; name: string; showName: boolean }) { 2297 function Title({ s, name, showName }: { s: Stage; name: string; showName: boolean }) {
1287 if (!showName) return null; 2298 if (!showName) return null;
1288 const { W, H, v, id, p } = s; 2299 const { W, H, v, id, p } = s;
1289− const spec = TITLES[s.game?.slug ?? ""] ?? { caps: true, tracking: -0.03, weight: 800, glow: p.glow }; 2300+ const spec = TITLES[s.slug] ?? { caps: true, tracking: -0.03, weight: 800, glow: p.glow };
1290 const caps = spec.caps !== false; 2301 const caps = spec.caps !== false;
1291 const tracking = spec.tracking ?? -0.03; 2302 const tracking = spec.tracking ?? -0.03;
1292 const weight = spec.weight ?? 800; 2303 const weight = spec.weight ?? 800;
2304+ const stretch = spec.stretch ?? 1;
2305+ const face = spec.mono ? MONO : FONT;
1293 const rawLines = splitLines(name, spec, v); 2306 const rawLines = splitLines(name, spec, v);
1294 const lines = caps ? rawLines.map((l) => l.toUpperCase()) : rawLines; 2307 const lines = caps ? rawLines.map((l) => l.toUpperCase()) : rawLines;
1295 const cfg = v === "card" ? { pad: 22, maxFont: 54, avail: W - 44, eye: 11 } : v === "hero" ? { pad: 84, maxFont: 168, avail: W * 0.44, eye: 22 } : v === "banner" ? { pad: 56, maxFont: 112, avail: W * 0.42, eye: 18 } : { pad: 14, maxFont: 26, avail: W - 28, eye: 0 }; 2308 const cfg = v === "card" ? { pad: 22, maxFont: 54, avail: W - 44, eye: 11 } : v === "hero" ? { pad: 84, maxFont: 168, avail: W * 0.44, eye: 22 } : v === "banner" ? { pad: 56, maxFont: 112, avail: W * 0.42, eye: 18 } : { pad: 14, maxFont: 26, avail: W - 28, eye: 0 };
1296 const fitLines = spec.subline ? [lines[0]] : lines; 2309 const fitLines = spec.subline ? [lines[0]] : lines;
1297− const est = Math.max(...fitLines.map((l) => estimateWidth(l, caps, tracking)), 1); 2310+ const est = Math.max(...fitLines.map((l) => estimateWidth(l, caps, tracking, spec.mono, stretch)), 1);
1298 let size = Math.min(cfg.maxFont, cfg.avail / est); 2311 let size = Math.min(cfg.maxFont, cfg.avail / est);
1299 if (spec.scale) size *= spec.scale; 2312 if (spec.scale) size *= spec.scale;
1300 const subSize = size * 0.34; 2313 const subSize = size * 0.34;
@@ -1307,17 +2320,30 @@ function Title({ s, name, showName }: { s: Stage; name: string; showName: boolea
1307 const fill = Array.isArray(spec.fill) ? `url(#${id}-title)` : (spec.fill ?? "#fff"); 2320 const fill = Array.isArray(spec.fill) ? `url(#${id}-title)` : (spec.fill ?? "#fff");
1308 const eyebrow = spec.eyebrow && cfg.eye > 0 && v !== "tile"; 2321 const eyebrow = spec.eyebrow && cfg.eye > 0 && v !== "tile";
1309 const eyeSize = v === "hero" ? size * 0.14 : v === "banner" ? size * 0.15 : Math.max(9.5, size * 0.2); 2322 const eyeSize = v === "hero" ? size * 0.14 : v === "banner" ? size * 0.15 : Math.max(9.5, size * 0.2);
1310− const fontStyle: React.CSSProperties = { fontFamily: FONT, letterSpacing: `${tracking}em` }; 2323+ const fontStyle: React.CSSProperties = { fontFamily: face, letterSpacing: `${tracking}em` };
1311 2324
1312− const render = (props: React.SVGProps<SVGTextElement>, key: string) => 2325+ const render = (props: React.SVGProps<SVGTextElement>, key: string, main = false) =>
1313 lines.map((l, i) => { 2326 lines.map((l, i) => {
1314 const isSub = spec.subline && i === 1; 2327 const isSub = spec.subline && i === 1;
1315 const y = isSub ? baseY + subSize * 1.7 : baseY + i * lineH; 2328 const y = isSub ? baseY + subSize * 1.7 : baseY + i * lineH;
1316− const base: React.CSSProperties = isSub ? { fontFamily: FONT, letterSpacing: "0.32em" } : fontStyle; 2329+ const base: React.CSSProperties = isSub ? { fontFamily: face, letterSpacing: "0.32em" } : fontStyle;
1317− const transform = spec.skew ? `translate(${f(x)} ${f(y)}) skewX(${spec.skew}) translate(${f(-x)} ${f(-y)})` : undefined; 2330+ const ops: string[] = [];
2331+ if (spec.skew) ops.push(`skewX(${spec.skew})`);
2332+ if (stretch !== 1 && !isSub) ops.push(`scale(${stretch} 1)`);
2333+ const transform = ops.length ? `translate(${f(x)} ${f(y)}) ${ops.join(" ")} translate(${f(-x)} ${f(-y)})` : undefined;
2334+ const acc = spec.accent;
2335+ const k = main && acc && !isSub ? l.indexOf(acc.text) : -1;
1318 return ( 2336 return (
1319 <text key={`${key}${i}`} x={f(x)} y={f(y)} textAnchor={anchor} fontSize={f(isSub ? subSize : size)} fontWeight={isSub ? 700 : weight} transform={transform} {...props} style={{ ...base, ...props.style }}> 2337 <text key={`${key}${i}`} x={f(x)} y={f(y)} textAnchor={anchor} fontSize={f(isSub ? subSize : size)} fontWeight={isSub ? 700 : weight} transform={transform} {...props} style={{ ...base, ...props.style }}>
1320− {l} 2338+ {k >= 0 && acc ? (
2339+ <>
2340+ {l.slice(0, k)}
2341+ <tspan fill={acc.fill}>{acc.text}</tspan>
2342+ {l.slice(k + acc.text.length)}
2343+ </>
2344+ ) : (
2345+ l
2346+ )}
1321 </text> 2347 </text>
1322 ); 2348 );
1323 }); 2349 });
@@ -1330,14 +2356,14 @@ function Title({ s, name, showName }: { s: Stage; name: string; showName: boolea
1330 </defs> 2356 </defs>
1331 ) : null} 2357 ) : null}
1332 {eyebrow ? ( 2358 {eyebrow ? (
1333− <text x={f(x)} y={f(baseY - size * 1.02)} textAnchor={anchor} fontSize={f(eyeSize)} fontWeight={700} fill={spec.eyebrowColor ?? p.secondary} style={{ fontFamily: FONT, letterSpacing: "0.22em" }}> 2359+ <text x={f(x)} y={f(baseY - size * 1.02)} textAnchor={anchor} fontSize={f(eyeSize)} fontWeight={700} fill={spec.eyebrowColor ?? p.secondary} style={{ fontFamily: face, letterSpacing: "0.22em" }}>
1334 {spec.eyebrow} 2360 {spec.eyebrow}
1335 </text> 2361 </text>
1336 ) : null} 2362 ) : null}
1337 {spec.glow && v !== "tile" ? render({ fill: spec.glow, fillOpacity: 0.5, style: { filter: `blur(${f(size * 0.16)}px)` } }, "g") : null} 2363 {spec.glow && v !== "tile" ? render({ fill: spec.glow, fillOpacity: 0.5, style: { filter: `blur(${f(size * 0.16)}px)` } }, "g") : null}
1338 {spec.shadow ? render({ fill: spec.shadow, transform: `translate(${f(size * 0.07)} ${f(size * 0.07)})` }, "s") : null} 2364 {spec.shadow ? render({ fill: spec.shadow, transform: `translate(${f(size * 0.07)} ${f(size * 0.07)})` }, "s") : null}
1339 {spec.outline ? render({ fill: "none", stroke: spec.outline, strokeWidth: f(size * 0.09), strokeLinejoin: "round" }, "o") : null} 2365 {spec.outline ? render({ fill: "none", stroke: spec.outline, strokeWidth: f(size * 0.09), strokeLinejoin: "round" }, "o") : null}
1340− {render({ fill }, "t")} 2366+ {render({ fill }, "t", true)}
1341 </g> 2367 </g>
1342 ); 2368 );
1343 } 2369 }
@@ -1358,6 +2384,7 @@ export function GameArt({ slug, name, palette, variant = "card", className, show
1358 W, 2384 W,
1359 H, 2385 H,
1360 v: variant, 2386 v: variant,
2387+ slug,
1361 cx, 2388 cx,
1362 cy, 2389 cy,
1363 R, 2390 R,
modified apps/web/src/components/lobby/symbol-svg.tsx +91 −0
@@ -322,6 +322,97 @@ function Glyph({ style, size }: { style: SymbolStyle; size: number }) {
322 {style.label ? <Label text={style.label} fontSize={size * 0.3} color="#fff" /> : null} 322 {style.label ? <Label text={style.label} fontSize={size * 0.3} color="#fff" /> : null}
323 </> 323 </>
324 ); 324 );
325+ /* ----------------------------------------------------------- themed set */
326+ case "lock":
327+ return (
328+ <>
329+ <rect x={f(-r * 0.75)} y={f(-r * 0.1)} width={f(r * 1.5)} height={f(r * 1.1)} rx={f(r * 0.2)} fill={main} />
330+ <path d={arcPath(0, -r * 0.15, r * 0.5, Math.PI, 0)} fill="none" stroke={accent} strokeWidth={sw(0.05)} />
331+ <circle cy={f(r * 0.35)} r={f(r * 0.16)} fill={INK} />
332+ <rect x={f(-r * 0.06)} y={f(r * 0.35)} width={f(r * 0.12)} height={f(r * 0.35)} fill={INK} />
333+ </>
334+ );
335+ case "key":
336+ return (
337+ <>
338+ <circle cx={f(-r * 0.45)} cy={f(-r * 0.35)} r={f(r * 0.42)} fill="none" stroke={main} strokeWidth={sw(0.06)} />
339+ <path d={`M${f(-r * 0.15)} ${f(-r * 0.05)}L${f(r * 0.85)} ${f(r * 0.95)}`} stroke={main} strokeWidth={sw(0.06)} />
340+ <path d={`M${f(r * 0.45)} ${f(r * 0.55)}L${f(r * 0.7)} ${f(r * 0.3)} M${f(r * 0.65)} ${f(r * 0.75)}L${f(r * 0.9)} ${f(r * 0.5)}`} stroke={accent} strokeWidth={sw(0.05)} />
341+ </>
342+ );
343+ case "bag":
344+ return (
345+ <>
346+ <path d={`M${f(-r * 0.75)} ${f(r * 0.9)} C${f(-r * 1.1)} ${f(-r * 0.3)} ${f(-r * 0.3)} ${f(-r * 0.6)} 0 ${f(-r * 0.55)} C${f(r * 0.3)} ${f(-r * 0.6)} ${f(r * 1.1)} ${f(-r * 0.3)} ${f(r * 0.75)} ${f(r * 0.9)}Z`} fill={main} />
347+ <rect x={f(-r * 0.3)} y={f(-r * 0.95)} width={f(r * 0.6)} height={f(r * 0.35)} rx={f(r * 0.1)} fill={accent} />
348+ <line x1={f(-r * 0.35)} y1={f(r * 0.3)} x2={f(r * 0.35)} y2={f(r * 0.3)} stroke={darkenHex(main, 0.3)} strokeWidth={sw(0.03)} />
349+ </>
350+ );
351+ case "warning":
352+ return (
353+ <>
354+ <polygon points={pts([0, -r, r * 0.95, r * 0.75, -r * 0.95, r * 0.75])} fill={main} />
355+ <polygon points={pts([0, -r * 0.6, r * 0.55, r * 0.55, -r * 0.55, r * 0.55])} fill={INK} />
356+ <rect x={f(-r * 0.08)} y={f(-r * 0.3)} width={f(r * 0.16)} height={f(r * 0.5)} fill={main} />
357+ <circle cy={f(r * 0.38)} r={f(r * 0.09)} fill={main} />
358+ </>
359+ );
360+ case "gauge":
361+ return (
362+ <>
363+ <path d={arcPath(0, r * 0.2, r * 0.9, Math.PI, 0)} fill="none" stroke={darkenHex(main, 0.3)} strokeWidth={sw(0.09)} />
364+ <path d={arcPath(0, r * 0.2, r * 0.9, Math.PI, Math.PI * 1.6)} fill="none" stroke={main} strokeWidth={sw(0.09)} />
365+ <line x1={0} y1={f(r * 0.2)} x2={f(r * 0.45)} y2={f(-r * 0.5)} stroke={accent} strokeWidth={sw(0.04)} />
366+ <circle cy={f(r * 0.2)} r={f(r * 0.12)} fill={accent} />
367+ </>
368+ );
369+ case "rocket":
370+ return (
371+ <>
372+ <path d={`M0 ${f(-r)} C${f(r * 0.6)} ${f(-r * 0.4)} ${f(r * 0.5)} ${f(r * 0.4)} ${f(r * 0.35)} ${f(r * 0.6)} L${f(-r * 0.35)} ${f(r * 0.6)} C${f(-r * 0.5)} ${f(r * 0.4)} ${f(-r * 0.6)} ${f(-r * 0.4)} 0 ${f(-r)}Z`} fill={main} />
373+ <polygon points={pts([-r * 0.35, r * 0.2, -r * 0.75, r * 0.8, -r * 0.35, r * 0.6])} fill={accent} />
374+ <polygon points={pts([r * 0.35, r * 0.2, r * 0.75, r * 0.8, r * 0.35, r * 0.6])} fill={accent} />
375+ <circle cy={f(-r * 0.25)} r={f(r * 0.18)} fill={INK} />
376+ <polygon points={pts([-r * 0.2, r * 0.6, r * 0.2, r * 0.6, 0, r])} fill="#ffb347" />
377+ </>
378+ );
379+ case "chest":
380+ return (
381+ <>
382+ <rect x={f(-r * 0.9)} y={f(-r * 0.2)} width={f(r * 1.8)} height={f(r * 1.0)} rx={f(r * 0.1)} fill={main} />
383+ <rect x={f(-r * 0.9)} y={f(-r * 0.75)} width={f(r * 1.8)} height={f(r * 0.6)} rx={f(r * 0.3)} fill={darkenHex(main, 0.2)} />
384+ <rect x={f(-r * 0.9)} y={f(-r * 0.2)} width={f(r * 1.8)} height={f(r * 0.12)} fill={accent} />
385+ <rect x={f(-r * 0.15)} y={f(-r * 0.2)} width={f(r * 0.3)} height={f(r * 0.35)} rx={f(r * 0.05)} fill={accent} />
386+ </>
387+ );
388+ case "planet":
389+ return (
390+ <>
391+ <circle r={f(r * 0.7)} fill={main} />
392+ <ellipse cy={f(r * 0.05)} rx={f(r * 1.05)} ry={f(r * 0.28)} fill="none" stroke={accent} strokeWidth={sw(0.04)} />
393+ <circle cx={f(-r * 0.25)} cy={f(-r * 0.25)} r={f(r * 0.18)} fill="#fff" fillOpacity={0.3} />
394+ <ellipse cx={f(r * 0.15)} cy={f(r * 0.15)} rx={f(r * 0.3)} ry={f(r * 0.12)} fill={darkenHex(main, 0.25)} fillOpacity={0.8} />
395+ </>
396+ );
397+ case "comet":
398+ return (
399+ <>
400+ <polygon points={pts([r * 0.5, -r * 0.5, -r * 1.0, r * 0.15, -r * 0.6, r * 0.6])} fill={accent} fillOpacity={0.5} />
401+ <line x1={f(r * 0.5)} y1={f(-r * 0.5)} x2={f(-r * 0.7)} y2={f(r * 0.7)} stroke={accent} strokeOpacity={0.4} strokeWidth={sw(0.05)} />
402+ <circle cx={f(r * 0.5)} cy={f(-r * 0.5)} r={f(r * 0.4)} fill={main} />
403+ <circle cx={f(r * 0.4)} cy={f(-r * 0.6)} r={f(r * 0.12)} fill="#fff" fillOpacity={0.6} />
404+ </>
405+ );
406+ case "satellite":
407+ return (
408+ <>
409+ <rect x={f(-r * 0.3)} y={f(-r * 0.3)} width={f(r * 0.6)} height={f(r * 0.6)} rx={f(r * 0.1)} fill={main} />
410+ <rect x={f(-r * 1.0)} y={f(-r * 0.18)} width={f(r * 0.6)} height={f(r * 0.36)} fill={accent} />
411+ <rect x={f(r * 0.4)} y={f(-r * 0.18)} width={f(r * 0.6)} height={f(r * 0.36)} fill={accent} />
412+ <path d={[-0.8, -0.6, 0.6, 0.8].map((x) => `M${f(x * r)} ${f(-r * 0.18)}V${f(r * 0.18)}`).join(" ")} stroke={darkenHex(main, 0.4)} strokeWidth={sw(0.012)} />
413+ <path d={arcPath(0, -r * 0.55, r * 0.3, Math.PI, 0)} fill="none" stroke={main} strokeWidth={sw(0.03)} />
414+ </>
415+ );
325 default: 416 default:
326 return <circle r={f(r)} fill={main} />; 417 return <circle r={f(r)} fill={main} />;
327 } 418 }
added games/certifications/dropzone.json +301 −0
@@ -0,0 +1,301 @@
1+{
2+ "game": "dropzone",
3+ "name": "Dropzone",
4+ "version": "1.0.0",
5+ "spins": 5000000,
6+ "configuredRtp": 0.96,
7+ "observedRtp": 0.961199776,
8+ "deviation": 0.0011997759999999857,
9+ "hitRate": 0.9514886,
10+ "bonusRate": 0,
11+ "freeSpinRate": 0,
12+ "maxWinMultiplier": 2787.59,
13+ "stdDev": 5.956893806457615,
14+ "status": "PASS",
15+ "checks": [
16+ {
17+ "name": "definition",
18+ "pass": true,
19+ "detail": "rtp 0.96, cap 3000×, payScale 0.5557"
20+ },
21+ {
22+ "name": "spins",
23+ "pass": true,
24+ "detail": "5,000,000 rounds"
25+ },
26+ {
27+ "name": "rtp-deviation",
28+ "pass": true,
29+ "detail": "0.120% (tolerance ±0.80%)"
30+ },
31+ {
32+ "name": "rtp-band",
33+ "pass": true,
34+ "detail": "96.12%"
35+ },
36+ {
37+ "name": "max-win",
38+ "pass": true,
39+ "detail": "2787.6× (cap 3000×)"
40+ },
41+ {
42+ "name": "cap-share",
43+ "pass": true,
44+ "detail": "0 capped rounds"
45+ }
46+ ],
47+ "certifiedAt": "2026-09-08T03:31:10.758Z",
48+ "rules": {
49+ "maxDeviation": 0.004,
50+ "minSpins": 1000000,
51+ "rtpBand": [
52+ 0.93,
53+ 0.99
54+ ],
55+ "hitRateBand": [
56+ 0.08,
57+ 0.6
58+ ],
59+ "maxCappedShare": 0.0005
60+ },
61+ "distribution": [
62+ {
63+ "label": "0×",
64+ "min": 0,
65+ "max": 0,
66+ "count": 242557,
67+ "share": 0.0485114
68+ },
69+ {
70+ "label": "0–1×",
71+ "min": 0.000001,
72+ "max": 1,
73+ "count": 3853719,
74+ "share": 0.7707438
75+ },
76+ {
77+ "label": "1–2×",
78+ "min": 1,
79+ "max": 2,
80+ "count": 474152,
81+ "share": 0.0948304
82+ },
83+ {
84+ "label": "2–5×",
85+ "min": 2,
86+ "max": 5,
87+ "count": 289973,
88+ "share": 0.0579946
89+ },
90+ {
91+ "label": "5–10×",
92+ "min": 5,
93+ "max": 10,
94+ "count": 85791,
95+ "share": 0.0171582
96+ },
97+ {
98+ "label": "10–20×",
99+ "min": 10,
100+ "max": 20,
101+ "count": 32797,
102+ "share": 0.0065594
103+ },
104+ {
105+ "label": "20–50×",
106+ "min": 20,
107+ "max": 50,
108+ "count": 16217,
109+ "share": 0.0032434
110+ },
111+ {
112+ "label": "50–100×",
113+ "min": 50,
114+ "max": 100,
115+ "count": 3371,
116+ "share": 0.0006742
117+ },
118+ {
119+ "label": "100–500×",
120+ "min": 100,
121+ "max": 500,
122+ "count": 1331,
123+ "share": 0.0002662
124+ },
125+ {
126+ "label": "500×+",
127+ "min": 500,
128+ "max": null,
129+ "count": 92,
130+ "share": 0.0000184
131+ }
132+ ],
133+ "convergence": [
134+ {
135+ "spins": 125000,
136+ "rtp": 0.9639352
137+ },
138+ {
139+ "spins": 250000,
140+ "rtp": 0.97022192
141+ },
142+ {
143+ "spins": 375000,
144+ "rtp": 0.97694008
145+ },
146+ {
147+ "spins": 500000,
148+ "rtp": 0.97236112
149+ },
150+ {
151+ "spins": 625000,
152+ "rtp": 0.969624864
153+ },
154+ {
155+ "spins": 750000,
156+ "rtp": 0.97004356
157+ },
158+ {
159+ "spins": 875000,
160+ "rtp": 0.9692053714285714
161+ },
162+ {
163+ "spins": 1000000,
164+ "rtp": 0.96449322
165+ },
166+ {
167+ "spins": 1125000,
168+ "rtp": 0.9654234666666667
169+ },
170+ {
171+ "spins": 1250000,
172+ "rtp": 0.96520872
173+ },
174+ {
175+ "spins": 1375000,
176+ "rtp": 0.9645822327272727
177+ },
178+ {
179+ "spins": 1500000,
180+ "rtp": 0.9617119933333333
181+ },
182+ {
183+ "spins": 1625000,
184+ "rtp": 0.9598878892307692
185+ },
186+ {
187+ "spins": 1750000,
188+ "rtp": 0.9605027028571429
189+ },
190+ {
191+ "spins": 1875000,
192+ "rtp": 0.9631120373333333
193+ },
194+ {
195+ "spins": 2000000,
196+ "rtp": 0.961650675
197+ },
198+ {
199+ "spins": 2125000,
200+ "rtp": 0.9622069317647058
201+ },
202+ {
203+ "spins": 2250000,
204+ "rtp": 0.9610357333333334
205+ },
206+ {
207+ "spins": 2375000,
208+ "rtp": 0.9609856042105264
209+ },
210+ {
211+ "spins": 2500000,
212+ "rtp": 0.961821972
213+ },
214+ {
215+ "spins": 2625000,
216+ "rtp": 0.9612519161904762
217+ },
218+ {
219+ "spins": 2750000,
220+ "rtp": 0.9623464254545454
221+ },
222+ {
223+ "spins": 2875000,
224+ "rtp": 0.9620102573913043
225+ },
226+ {
227+ "spins": 3000000,
228+ "rtp": 0.96253723
229+ },
230+ {
231+ "spins": 3125000,
232+ "rtp": 0.9627412608
233+ },
234+ {
235+ "spins": 3250000,
236+ "rtp": 0.9623729507692308
237+ },
238+ {
239+ "spins": 3375000,
240+ "rtp": 0.9630034607407407
241+ },
242+ {
243+ "spins": 3500000,
244+ "rtp": 0.9633927771428571
245+ },
246+ {
247+ "spins": 3625000,
248+ "rtp": 0.9635726565517241
249+ },
250+ {
251+ "spins": 3750000,
252+ "rtp": 0.9635082693333333
253+ },
254+ {
255+ "spins": 3875000,
256+ "rtp": 0.9632522658064516
257+ },
258+ {
259+ "spins": 4000000,
260+ "rtp": 0.9624044975
261+ },
262+ {
263+ "spins": 4125000,
264+ "rtp": 0.9625301551515152
265+ },
266+ {
267+ "spins": 4250000,
268+ "rtp": 0.9622728329411765
269+ },
270+ {
271+ "spins": 4375000,
272+ "rtp": 0.9620660937142858
273+ },
274+ {
275+ "spins": 4500000,
276+ "rtp": 0.9626329466666667
277+ },
278+ {
279+ "spins": 4625000,
280+ "rtp": 0.9622044237837838
281+ },
282+ {
283+ "spins": 4750000,
284+ "rtp": 0.962079132631579
285+ },
286+ {
287+ "spins": 4875000,
288+ "rtp": 0.9615967856410257
289+ },
290+ {
291+ "spins": 5000000,
292+ "rtp": 0.961199776
293+ }
294+ ],
295+ "featureCounts": {
296+ "Gate": 1739382,
297+ "Deep Drop": 400671,
298+ "Portal": 653799
299+ },
300+ "durationMs": 16998
301+}
added games/certifications/escape-99.json +297 −0
@@ -0,0 +1,297 @@
1+{
2+ "game": "escape-99",
3+ "name": "Escape 99",
4+ "version": "1.0.0",
5+ "spins": 5000000,
6+ "configuredRtp": 0.96,
7+ "observedRtp": 0.843717692,
8+ "deviation": -0.11628230799999995,
9+ "hitRate": 0.177482,
10+ "bonusRate": 0,
11+ "freeSpinRate": 0,
12+ "maxWinMultiplier": 5000,
13+ "stdDev": 19.088674673847127,
14+ "status": "FAIL",
15+ "checks": [
16+ {
17+ "name": "definition",
18+ "pass": true,
19+ "detail": "rtp 0.96, cap 5000×, payScale 1"
20+ },
21+ {
22+ "name": "spins",
23+ "pass": true,
24+ "detail": "5,000,000 rounds"
25+ },
26+ {
27+ "name": "rtp-deviation",
28+ "pass": false,
29+ "detail": "-11.628% (tolerance ±1.50%)"
30+ },
31+ {
32+ "name": "rtp-band",
33+ "pass": false,
34+ "detail": "84.37%"
35+ },
36+ {
37+ "name": "max-win",
38+ "pass": true,
39+ "detail": "5000.0× (cap 5000×)"
40+ },
41+ {
42+ "name": "cap-share",
43+ "pass": true,
44+ "detail": "0 capped rounds"
45+ }
46+ ],
47+ "certifiedAt": "2026-09-08T03:48:00.592Z",
48+ "rules": {
49+ "maxDeviation": 0.004,
50+ "minSpins": 1000000,
51+ "rtpBand": [
52+ 0.93,
53+ 0.99
54+ ],
55+ "hitRateBand": [
56+ 0.08,
57+ 0.6
58+ ],
59+ "maxCappedShare": 0.0005
60+ },
61+ "distribution": [
62+ {
63+ "label": "0×",
64+ "min": 0,
65+ "max": 0,
66+ "count": 4112590,
67+ "share": 0.822518
68+ },
69+ {
70+ "label": "0–1×",
71+ "min": 0.000001,
72+ "max": 1,
73+ "count": 48544,
74+ "share": 0.0097088
75+ },
76+ {
77+ "label": "1–2×",
78+ "min": 1,
79+ "max": 2,
80+ "count": 530219,
81+ "share": 0.1060438
82+ },
83+ {
84+ "label": "2–5×",
85+ "min": 2,
86+ "max": 5,
87+ "count": 202949,
88+ "share": 0.0405898
89+ },
90+ {
91+ "label": "5–10×",
92+ "min": 5,
93+ "max": 10,
94+ "count": 57963,
95+ "share": 0.0115926
96+ },
97+ {
98+ "label": "10–20×",
99+ "min": 10,
100+ "max": 20,
101+ "count": 25310,
102+ "share": 0.005062
103+ },
104+ {
105+ "label": "20–50×",
106+ "min": 20,
107+ "max": 50,
108+ "count": 14186,
109+ "share": 0.0028372
110+ },
111+ {
112+ "label": "50–100×",
113+ "min": 50,
114+ "max": 100,
115+ "count": 4449,
116+ "share": 0.0008898
117+ },
118+ {
119+ "label": "100–500×",
120+ "min": 100,
121+ "max": 500,
122+ "count": 3180,
123+ "share": 0.000636
124+ },
125+ {
126+ "label": "500×+",
127+ "min": 500,
128+ "max": null,
129+ "count": 610,
130+ "share": 0.000122
131+ }
132+ ],
133+ "convergence": [
134+ {
135+ "spins": 125000,
136+ "rtp": 0.74817352
137+ },
138+ {
139+ "spins": 250000,
140+ "rtp": 0.77398808
141+ },
142+ {
143+ "spins": 375000,
144+ "rtp": 0.7763465333333334
145+ },
146+ {
147+ "spins": 500000,
148+ "rtp": 0.78804504
149+ },
150+ {
151+ "spins": 625000,
152+ "rtp": 0.80086168
153+ },
154+ {
155+ "spins": 750000,
156+ "rtp": 0.8139142933333333
157+ },
158+ {
159+ "spins": 875000,
160+ "rtp": 0.8288938971428571
161+ },
162+ {
163+ "spins": 1000000,
164+ "rtp": 0.82749557
165+ },
166+ {
167+ "spins": 1125000,
168+ "rtp": 0.8316278222222222
169+ },
170+ {
171+ "spins": 1250000,
172+ "rtp": 0.836861112
173+ },
174+ {
175+ "spins": 1375000,
176+ "rtp": 0.8343994036363637
177+ },
178+ {
179+ "spins": 1500000,
180+ "rtp": 0.8424341
181+ },
182+ {
183+ "spins": 1625000,
184+ "rtp": 0.8511155876923077
185+ },
186+ {
187+ "spins": 1750000,
188+ "rtp": 0.84591704
189+ },
190+ {
191+ "spins": 1875000,
192+ "rtp": 0.85105528
193+ },
194+ {
195+ "spins": 2000000,
196+ "rtp": 0.850496275
197+ },
198+ {
199+ "spins": 2125000,
200+ "rtp": 0.8494788376470588
201+ },
202+ {
203+ "spins": 2250000,
204+ "rtp": 0.8470352311111111
205+ },
206+ {
207+ "spins": 2375000,
208+ "rtp": 0.8486985178947368
209+ },
210+ {
211+ "spins": 2500000,
212+ "rtp": 0.849853204
213+ },
214+ {
215+ "spins": 2625000,
216+ "rtp": 0.8491573676190476
217+ },
218+ {
219+ "spins": 2750000,
220+ "rtp": 0.8480010181818182
221+ },
222+ {
223+ "spins": 2875000,
224+ "rtp": 0.8488998191304348
225+ },
226+ {
227+ "spins": 3000000,
228+ "rtp": 0.84491513
229+ },
230+ {
231+ "spins": 3125000,
232+ "rtp": 0.8439477728
233+ },
234+ {
235+ "spins": 3250000,
236+ "rtp": 0.8411797446153846
237+ },
238+ {
239+ "spins": 3375000,
240+ "rtp": 0.8436860622222222
241+ },
242+ {
243+ "spins": 3500000,
244+ "rtp": 0.84567104
245+ },
246+ {
247+ "spins": 3625000,
248+ "rtp": 0.8452766537931035
249+ },
250+ {
251+ "spins": 3750000,
252+ "rtp": 0.8427253173333333
253+ },
254+ {
255+ "spins": 3875000,
256+ "rtp": 0.8443765806451613
257+ },
258+ {
259+ "spins": 4000000,
260+ "rtp": 0.8477031125
261+ },
262+ {
263+ "spins": 4125000,
264+ "rtp": 0.8480632145454545
265+ },
266+ {
267+ "spins": 4250000,
268+ "rtp": 0.84873276
269+ },
270+ {
271+ "spins": 4375000,
272+ "rtp": 0.8482617485714286
273+ },
274+ {
275+ "spins": 4500000,
276+ "rtp": 0.84866652
277+ },
278+ {
279+ "spins": 4625000,
280+ "rtp": 0.8469645081081081
281+ },
282+ {
283+ "spins": 4750000,
284+ "rtp": 0.8453844863157894
285+ },
286+ {
287+ "spins": 4875000,
288+ "rtp": 0.8433274276923077
289+ },
290+ {
291+ "spins": 5000000,
292+ "rtp": 0.843717692
293+ }
294+ ],
295+ "featureCounts": {},
296+ "durationMs": 750088
297+}
added games/certifications/grid-break.json +302 −0
@@ -0,0 +1,302 @@
1+{
2+ "game": "grid-break",
3+ "name": "Grid//Break",
4+ "version": "1.0.0",
5+ "spins": 5000000,
6+ "configuredRtp": 0.96,
7+ "observedRtp": 0.962304202,
8+ "deviation": 0.002304202000000033,
9+ "hitRate": 0.5994606,
10+ "bonusRate": 0,
11+ "freeSpinRate": 0,
12+ "maxWinMultiplier": 402.36,
13+ "stdDev": 4.096724817716841,
14+ "status": "PASS",
15+ "checks": [
16+ {
17+ "name": "definition",
18+ "pass": true,
19+ "detail": "rtp 0.96, cap 2500×, payScale 0.1739"
20+ },
21+ {
22+ "name": "spins",
23+ "pass": true,
24+ "detail": "5,000,000 rounds"
25+ },
26+ {
27+ "name": "rtp-deviation",
28+ "pass": true,
29+ "detail": "0.230% (tolerance ±0.55%)"
30+ },
31+ {
32+ "name": "rtp-band",
33+ "pass": true,
34+ "detail": "96.23%"
35+ },
36+ {
37+ "name": "max-win",
38+ "pass": true,
39+ "detail": "402.4× (cap 2500×)"
40+ },
41+ {
42+ "name": "cap-share",
43+ "pass": true,
44+ "detail": "0 capped rounds"
45+ }
46+ ],
47+ "certifiedAt": "2026-09-08T03:34:43.230Z",
48+ "rules": {
49+ "maxDeviation": 0.004,
50+ "minSpins": 1000000,
51+ "rtpBand": [
52+ 0.93,
53+ 0.99
54+ ],
55+ "hitRateBand": [
56+ 0.08,
57+ 0.6
58+ ],
59+ "maxCappedShare": 0.0005
60+ },
61+ "distribution": [
62+ {
63+ "label": "0×",
64+ "min": 0,
65+ "max": 0,
66+ "count": 2002697,
67+ "share": 0.4005394
68+ },
69+ {
70+ "label": "0–1×",
71+ "min": 0.000001,
72+ "max": 1,
73+ "count": 2190151,
74+ "share": 0.4380302
75+ },
76+ {
77+ "label": "1–2×",
78+ "min": 1,
79+ "max": 2,
80+ "count": 371286,
81+ "share": 0.0742572
82+ },
83+ {
84+ "label": "2–5×",
85+ "min": 2,
86+ "max": 5,
87+ "count": 256437,
88+ "share": 0.0512874
89+ },
90+ {
91+ "label": "5–10×",
92+ "min": 5,
93+ "max": 10,
94+ "count": 90355,
95+ "share": 0.018071
96+ },
97+ {
98+ "label": "10–20×",
99+ "min": 10,
100+ "max": 20,
101+ "count": 49392,
102+ "share": 0.0098784
103+ },
104+ {
105+ "label": "20–50×",
106+ "min": 20,
107+ "max": 50,
108+ "count": 32870,
109+ "share": 0.006574
110+ },
111+ {
112+ "label": "50–100×",
113+ "min": 50,
114+ "max": 100,
115+ "count": 6144,
116+ "share": 0.0012288
117+ },
118+ {
119+ "label": "100–500×",
120+ "min": 100,
121+ "max": 500,
122+ "count": 668,
123+ "share": 0.0001336
124+ },
125+ {
126+ "label": "500×+",
127+ "min": 500,
128+ "max": null,
129+ "count": 0,
130+ "share": 0
131+ }
132+ ],
133+ "convergence": [
134+ {
135+ "spins": 125000,
136+ "rtp": 0.95604352
137+ },
138+ {
139+ "spins": 250000,
140+ "rtp": 0.9629272
141+ },
142+ {
143+ "spins": 375000,
144+ "rtp": 0.96270616
145+ },
146+ {
147+ "spins": 500000,
148+ "rtp": 0.9636362
149+ },
150+ {
151+ "spins": 625000,
152+ "rtp": 0.962187536
153+ },
154+ {
155+ "spins": 750000,
156+ "rtp": 0.9595883333333334
157+ },
158+ {
159+ "spins": 875000,
160+ "rtp": 0.9608969714285714
161+ },
162+ {
163+ "spins": 1000000,
164+ "rtp": 0.96129772
165+ },
166+ {
167+ "spins": 1125000,
168+ "rtp": 0.9626801244444444
169+ },
170+ {
171+ "spins": 1250000,
172+ "rtp": 0.960792928
173+ },
174+ {
175+ "spins": 1375000,
176+ "rtp": 0.9619385527272727
177+ },
178+ {
179+ "spins": 1500000,
180+ "rtp": 0.9628839866666666
181+ },
182+ {
183+ "spins": 1625000,
184+ "rtp": 0.9627842769230769
185+ },
186+ {
187+ "spins": 1750000,
188+ "rtp": 0.9619497485714286
189+ },
190+ {
191+ "spins": 1875000,
192+ "rtp": 0.9629062293333334
193+ },
194+ {
195+ "spins": 2000000,
196+ "rtp": 0.962967655
197+ },
198+ {
199+ "spins": 2125000,
200+ "rtp": 0.96312696
201+ },
202+ {
203+ "spins": 2250000,
204+ "rtp": 0.9632352222222222
205+ },
206+ {
207+ "spins": 2375000,
208+ "rtp": 0.9636849389473684
209+ },
210+ {
211+ "spins": 2500000,
212+ "rtp": 0.9633209
213+ },
214+ {
215+ "spins": 2625000,
216+ "rtp": 0.9640879123809524
217+ },
218+ {
219+ "spins": 2750000,
220+ "rtp": 0.9641040218181818
221+ },
222+ {
223+ "spins": 2875000,
224+ "rtp": 0.963744747826087
225+ },
226+ {
227+ "spins": 3000000,
228+ "rtp": 0.9636835233333333
229+ },
230+ {
231+ "spins": 3125000,
232+ "rtp": 0.96312744
233+ },
234+ {
235+ "spins": 3250000,
236+ "rtp": 0.9632511661538462
237+ },
238+ {
239+ "spins": 3375000,
240+ "rtp": 0.9630610192592592
241+ },
242+ {
243+ "spins": 3500000,
244+ "rtp": 0.9632510885714286
245+ },
246+ {
247+ "spins": 3625000,
248+ "rtp": 0.9631946565517241
249+ },
250+ {
251+ "spins": 3750000,
252+ "rtp": 0.9628689813333333
253+ },
254+ {
255+ "spins": 3875000,
256+ "rtp": 0.9622925806451613
257+ },
258+ {
259+ "spins": 4000000,
260+ "rtp": 0.9620684275
261+ },
262+ {
263+ "spins": 4125000,
264+ "rtp": 0.9618926812121212
265+ },
266+ {
267+ "spins": 4250000,
268+ "rtp": 0.961978894117647
269+ },
270+ {
271+ "spins": 4375000,
272+ "rtp": 0.962476928
273+ },
274+ {
275+ "spins": 4500000,
276+ "rtp": 0.9626553066666667
277+ },
278+ {
279+ "spins": 4625000,
280+ "rtp": 0.9625427632432433
281+ },
282+ {
283+ "spins": 4750000,
284+ "rtp": 0.9627719031578947
285+ },
286+ {
287+ "spins": 4875000,
288+ "rtp": 0.9627279158974359
289+ },
290+ {
291+ "spins": 5000000,
292+ "rtp": 0.962304202
293+ }
294+ ],
295+ "featureCounts": {
296+ "Chain reaction": 2736491,
297+ "×2 block": 1172959,
298+ "Bomb": 1164332,
299+ "Line clear": 785333
300+ },
301+ "durationMs": 212471
302+}
added games/certifications/orbit.json +307 −0
@@ -0,0 +1,307 @@
1+{
2+ "game": "orbit",
3+ "name": "Orbit",
4+ "version": "1.0.0",
5+ "spins": 5000000,
6+ "configuredRtp": 0.96,
7+ "observedRtp": 0.962501212,
8+ "deviation": 0.002501212000000086,
9+ "hitRate": 0.6607512,
10+ "bonusRate": 0,
11+ "freeSpinRate": 0,
12+ "maxWinMultiplier": 352.98,
13+ "stdDev": 3.2575740288460002,
14+ "status": "PASS",
15+ "checks": [
16+ {
17+ "name": "definition",
18+ "pass": true,
19+ "detail": "rtp 0.96, cap 2000×, payScale 0.4347"
20+ },
21+ {
22+ "name": "spins",
23+ "pass": true,
24+ "detail": "5,000,000 rounds"
25+ },
26+ {
27+ "name": "rtp-deviation",
28+ "pass": true,
29+ "detail": "0.250% (tolerance ±0.44%)"
30+ },
31+ {
32+ "name": "rtp-band",
33+ "pass": true,
34+ "detail": "96.25%"
35+ },
36+ {
37+ "name": "max-win",
38+ "pass": true,
39+ "detail": "353.0× (cap 2000×)"
40+ },
41+ {
42+ "name": "cap-share",
43+ "pass": true,
44+ "detail": "0 capped rounds"
45+ }
46+ ],
47+ "certifiedAt": "2026-09-08T03:35:30.504Z",
48+ "rules": {
49+ "maxDeviation": 0.004,
50+ "minSpins": 1000000,
51+ "rtpBand": [
52+ 0.93,
53+ 0.99
54+ ],
55+ "hitRateBand": [
56+ 0.08,
57+ 0.6
58+ ],
59+ "maxCappedShare": 0.0005
60+ },
61+ "distribution": [
62+ {
63+ "label": "0×",
64+ "min": 0,
65+ "max": 0,
66+ "count": 1696244,
67+ "share": 0.3392488
68+ },
69+ {
70+ "label": "0–1×",
71+ "min": 0.000001,
72+ "max": 1,
73+ "count": 2187053,
74+ "share": 0.4374106
75+ },
76+ {
77+ "label": "1–2×",
78+ "min": 1,
79+ "max": 2,
80+ "count": 544113,
81+ "share": 0.1088226
82+ },
83+ {
84+ "label": "2–5×",
85+ "min": 2,
86+ "max": 5,
87+ "count": 437113,
88+ "share": 0.0874226
89+ },
90+ {
91+ "label": "5–10×",
92+ "min": 5,
93+ "max": 10,
94+ "count": 55856,
95+ "share": 0.0111712
96+ },
97+ {
98+ "label": "10–20×",
99+ "min": 10,
100+ "max": 20,
101+ "count": 62399,
102+ "share": 0.0124798
103+ },
104+ {
105+ "label": "20–50×",
106+ "min": 20,
107+ "max": 50,
108+ "count": 14664,
109+ "share": 0.0029328
110+ },
111+ {
112+ "label": "50–100×",
113+ "min": 50,
114+ "max": 100,
115+ "count": 1263,
116+ "share": 0.0002526
117+ },
118+ {
119+ "label": "100–500×",
120+ "min": 100,
121+ "max": 500,
122+ "count": 1295,
123+ "share": 0.000259
124+ },
125+ {
126+ "label": "500×+",
127+ "min": 500,
128+ "max": null,
129+ "count": 0,
130+ "share": 0
131+ }
132+ ],
133+ "convergence": [
134+ {
135+ "spins": 125000,
136+ "rtp": 0.97083088
137+ },
138+ {
139+ "spins": 250000,
140+ "rtp": 0.96837148
141+ },
142+ {
143+ "spins": 375000,
144+ "rtp": 0.9636322133333334
145+ },
146+ {
147+ "spins": 500000,
148+ "rtp": 0.9639165
149+ },
150+ {
151+ "spins": 625000,
152+ "rtp": 0.967868816
153+ },
154+ {
155+ "spins": 750000,
156+ "rtp": 0.9689794533333334
157+ },
158+ {
159+ "spins": 875000,
160+ "rtp": 0.9701950628571429
161+ },
162+ {
163+ "spins": 1000000,
164+ "rtp": 0.96775131
165+ },
166+ {
167+ "spins": 1125000,
168+ "rtp": 0.9673317688888889
169+ },
170+ {
171+ "spins": 1250000,
172+ "rtp": 0.966221696
173+ },
174+ {
175+ "spins": 1375000,
176+ "rtp": 0.9646939418181818
177+ },
178+ {
179+ "spins": 1500000,
180+ "rtp": 0.9648843866666666
181+ },
182+ {
183+ "spins": 1625000,
184+ "rtp": 0.9645462646153846
185+ },
186+ {
187+ "spins": 1750000,
188+ "rtp": 0.9643484285714286
189+ },
190+ {
191+ "spins": 1875000,
192+ "rtp": 0.963997552
193+ },
194+ {
195+ "spins": 2000000,
196+ "rtp": 0.9643276
197+ },
198+ {
199+ "spins": 2125000,
200+ "rtp": 0.9642172376470588
201+ },
202+ {
203+ "spins": 2250000,
204+ "rtp": 0.9643788711111111
205+ },
206+ {
207+ "spins": 2375000,
208+ "rtp": 0.9644876463157894
209+ },
210+ {
211+ "spins": 2500000,
212+ "rtp": 0.964746868
213+ },
214+ {
215+ "spins": 2625000,
216+ "rtp": 0.9648717828571428
217+ },
218+ {
219+ "spins": 2750000,
220+ "rtp": 0.9645632727272727
221+ },
222+ {
223+ "spins": 2875000,
224+ "rtp": 0.9644537913043478
225+ },
226+ {
227+ "spins": 3000000,
228+ "rtp": 0.9641617
229+ },
230+ {
231+ "spins": 3125000,
232+ "rtp": 0.9645400864
233+ },
234+ {
235+ "spins": 3250000,
236+ "rtp": 0.96440376
237+ },
238+ {
239+ "spins": 3375000,
240+ "rtp": 0.9638297244444445
241+ },
242+ {
243+ "spins": 3500000,
244+ "rtp": 0.9638747685714286
245+ },
246+ {
247+ "spins": 3625000,
248+ "rtp": 0.9640188386206896
249+ },
250+ {
251+ "spins": 3750000,
252+ "rtp": 0.9640946293333333
253+ },
254+ {
255+ "spins": 3875000,
256+ "rtp": 0.9640729806451613
257+ },
258+ {
259+ "spins": 4000000,
260+ "rtp": 0.963503645
261+ },
262+ {
263+ "spins": 4125000,
264+ "rtp": 0.96363272
265+ },
266+ {
267+ "spins": 4250000,
268+ "rtp": 0.9635847976470588
269+ },
270+ {
271+ "spins": 4375000,
272+ "rtp": 0.9635665302857143
273+ },
274+ {
275+ "spins": 4500000,
276+ "rtp": 0.9631729377777778
277+ },
278+ {
279+ "spins": 4625000,
280+ "rtp": 0.9633420108108108
281+ },
282+ {
283+ "spins": 4750000,
284+ "rtp": 0.9630729915789473
285+ },
286+ {
287+ "spins": 4875000,
288+ "rtp": 0.9627274441025641
289+ },
290+ {
291+ "spins": 5000000,
292+ "rtp": 0.962501212
293+ }
294+ ],
295+ "featureCounts": {
296+ "Deflection": 2072875,
297+ "Satellite": 1189930,
298+ "Gas giant": 245728,
299+ "Planet": 848906,
300+ "New orbit": 512586,
301+ "Comet": 468932,
302+ "Supernova": 132406,
303+ "Debris": 1516107,
304+ "Quasar": 49894
305+ },
306+ "durationMs": 7176
307+}
added games/certifications/the-vault.json +297 −0
@@ -0,0 +1,297 @@
1+{
2+ "game": "the-vault",
3+ "name": "The Vault",
4+ "version": "1.0.0",
5+ "spins": 3000000,
6+ "configuredRtp": 0.96,
7+ "observedRtp": 0.9606461366666667,
8+ "deviation": 0.0006461366666666857,
9+ "hitRate": 0.364491,
10+ "bonusRate": 0,
11+ "freeSpinRate": 0,
12+ "maxWinMultiplier": 31.17,
13+ "stdDev": 2.784813176146086,
14+ "status": "PASS",
15+ "checks": [
16+ {
17+ "name": "definition",
18+ "pass": true,
19+ "detail": "rtp 0.96, cap 50×, payScale 1"
20+ },
21+ {
22+ "name": "spins",
23+ "pass": true,
24+ "detail": "3,000,000 rounds"
25+ },
26+ {
27+ "name": "rtp-deviation",
28+ "pass": true,
29+ "detail": "0.065% (tolerance ±0.48%)"
30+ },
31+ {
32+ "name": "rtp-band",
33+ "pass": true,
34+ "detail": "96.06%"
35+ },
36+ {
37+ "name": "max-win",
38+ "pass": true,
39+ "detail": "31.2× (cap 50×)"
40+ },
41+ {
42+ "name": "cap-share",
43+ "pass": true,
44+ "detail": "0 capped rounds"
45+ }
46+ ],
47+ "certifiedAt": "2026-09-08T03:49:22.126Z",
48+ "rules": {
49+ "maxDeviation": 0.004,
50+ "minSpins": 1000000,
51+ "rtpBand": [
52+ 0.93,
53+ 0.99
54+ ],
55+ "hitRateBand": [
56+ 0.08,
57+ 0.6
58+ ],
59+ "maxCappedShare": 0.0005
60+ },
61+ "distribution": [
62+ {
63+ "label": "0×",
64+ "min": 0,
65+ "max": 0,
66+ "count": 1906527,
67+ "share": 0.635509
68+ },
69+ {
70+ "label": "0–1×",
71+ "min": 0.000001,
72+ "max": 1,
73+ "count": 0,
74+ "share": 0
75+ },
76+ {
77+ "label": "1–2×",
78+ "min": 1,
79+ "max": 2,
80+ "count": 817011,
81+ "share": 0.272337
82+ },
83+ {
84+ "label": "2–5×",
85+ "min": 2,
86+ "max": 5,
87+ "count": 183824,
88+ "share": 0.061274666666666665
89+ },
90+ {
91+ "label": "5–10×",
92+ "min": 5,
93+ "max": 10,
94+ "count": 74032,
95+ "share": 0.024677333333333332
96+ },
97+ {
98+ "label": "10–20×",
99+ "min": 10,
100+ "max": 20,
101+ "count": 0,
102+ "share": 0
103+ },
104+ {
105+ "label": "20–50×",
106+ "min": 20,
107+ "max": 50,
108+ "count": 18606,
109+ "share": 0.006202
110+ },
111+ {
112+ "label": "50–100×",
113+ "min": 50,
114+ "max": 100,
115+ "count": 0,
116+ "share": 0
117+ },
118+ {
119+ "label": "100–500×",
120+ "min": 100,
121+ "max": 500,
122+ "count": 0,
123+ "share": 0
124+ },
125+ {
126+ "label": "500×+",
127+ "min": 500,
128+ "max": null,
129+ "count": 0,
130+ "share": 0
131+ }
132+ ],
133+ "convergence": [
134+ {
135+ "spins": 75000,
136+ "rtp": 0.9582056
137+ },
138+ {
139+ "spins": 150000,
140+ "rtp": 0.9543438666666667
141+ },
142+ {
143+ "spins": 225000,
144+ "rtp": 0.9503724888888889
145+ },
146+ {
147+ "spins": 300000,
148+ "rtp": 0.9514903
149+ },
150+ {
151+ "spins": 375000,
152+ "rtp": 0.95467408
153+ },
154+ {
155+ "spins": 450000,
156+ "rtp": 0.9527761777777778
157+ },
158+ {
159+ "spins": 525000,
160+ "rtp": 0.954324
161+ },
162+ {
163+ "spins": 600000,
164+ "rtp": 0.9564638
165+ },
166+ {
167+ "spins": 675000,
168+ "rtp": 0.9569747851851852
169+ },
170+ {
171+ "spins": 750000,
172+ "rtp": 0.9583412
173+ },
174+ {
175+ "spins": 825000,
176+ "rtp": 0.9591383515151515
177+ },
178+ {
179+ "spins": 900000,
180+ "rtp": 0.9604935111111111
181+ },
182+ {
183+ "spins": 975000,
184+ "rtp": 0.9588733641025641
185+ },
186+ {
187+ "spins": 1050000,
188+ "rtp": 0.9592387809523809
189+ },
190+ {
191+ "spins": 1125000,
192+ "rtp": 0.9586845066666667
193+ },
194+ {
195+ "spins": 1200000,
196+ "rtp": 0.958702575
197+ },
198+ {
199+ "spins": 1275000,
200+ "rtp": 0.9590246117647059
201+ },
202+ {
203+ "spins": 1350000,
204+ "rtp": 0.9600741259259259
205+ },
206+ {
207+ "spins": 1425000,
208+ "rtp": 0.9609325824561403
209+ },
210+ {
211+ "spins": 1500000,
212+ "rtp": 0.9617418933333334
213+ },
214+ {
215+ "spins": 1575000,
216+ "rtp": 0.9620120317460318
217+ },
218+ {
219+ "spins": 1650000,
220+ "rtp": 0.9620848848484849
221+ },
222+ {
223+ "spins": 1725000,
224+ "rtp": 0.961910452173913
225+ },
226+ {
227+ "spins": 1800000,
228+ "rtp": 0.961642
229+ },
230+ {
231+ "spins": 1875000,
232+ "rtp": 0.961627776
233+ },
234+ {
235+ "spins": 1950000,
236+ "rtp": 0.9609179692307692
237+ },
238+ {
239+ "spins": 2025000,
240+ "rtp": 0.9611022666666666
241+ },
242+ {
243+ "spins": 2100000,
244+ "rtp": 0.9611009952380952
245+ },
246+ {
247+ "spins": 2175000,
248+ "rtp": 0.9603351724137931
249+ },
250+ {
251+ "spins": 2250000,
252+ "rtp": 0.9607563688888889
253+ },
254+ {
255+ "spins": 2325000,
256+ "rtp": 0.9608575483870968
257+ },
258+ {
259+ "spins": 2400000,
260+ "rtp": 0.9606920166666667
261+ },
262+ {
263+ "spins": 2475000,
264+ "rtp": 0.9609252646464647
265+ },
266+ {
267+ "spins": 2550000,
268+ "rtp": 0.9609430862745098
269+ },
270+ {
271+ "spins": 2625000,
272+ "rtp": 0.9612338628571429
273+ },
274+ {
275+ "spins": 2700000,
276+ "rtp": 0.9613437925925926
277+ },
278+ {
279+ "spins": 2775000,
280+ "rtp": 0.9615058558558559
281+ },
282+ {
283+ "spins": 2850000,
284+ "rtp": 0.9610267473684211
285+ },
286+ {
287+ "spins": 2925000,
288+ "rtp": 0.9606571794871794
289+ },
290+ {
291+ "spins": 3000000,
292+ "rtp": 0.9606461366666667
293+ }
294+ ],
295+ "featureCounts": {},
296+ "durationMs": 23177
297+}
added games/src/arcade/index.ts +199 −0
@@ -0,0 +1,199 @@
1+import { defineArcadeGame, type ArcadeGameDefinition } from "@spinza/game-core";
2+import calibration from "../calibration.json";
3+
4+/**
5+ * Spinza Originals — Beyond Slots. Five interactive originals sharing the
6+ * arcade engine: two ladder games (analytic RTP) and three instant games
7+ * (payScale calibrated by simulation, see calibration.json).
8+ */
9+
10+export const dropzone = defineArcadeGame({
11+ slug: "dropzone",
12+ name: "Dropzone",
13+ version: "1.0.0",
14+ tagline: "Release the capsule. Pray for the edge.",
15+ description:
16+ "Drop a capsule from the top of a vertical tower. It ricochets through pegs, energy gates and portals down to a row of buckets whose values depend on the risk profile you pick. Some descents trigger Deep Drop: the tower extends and the capsule keeps falling into a far riskier zone where the multipliers stack — or vanish.",
17+ theme: "arcade tower drop",
18+ mode: "instant",
19+ rtp: 0.96,
20+ maxMultiplier: 3000,
21+ volatility: "high",
22+ presentation: { scene: "dropzone", palette: { primary: "#22d3ee", secondary: "#f472b6", glow: "#67e8f9", bg: "#050a14", surface: "#0b1a2e" }, ambience: "arcade", verb: "DROP" },
23+ config: {
24+ rows: 12,
25+ lanes: 13,
26+ buckets: {
27+ low: [8, 3, 1.6, 1.2, 1, 0.7, 0.5, 0.7, 1, 1.2, 1.6, 3, 8],
28+ medium: [25, 6, 2.5, 1.4, 0.8, 0.4, 0.2, 0.4, 0.8, 1.4, 2.5, 6, 25],
29+ high: [120, 18, 4, 1.2, 0.4, 0.1, 0, 0.1, 0.4, 1.2, 4, 18, 120],
30+ },
31+ gateChancePerRow: 0.035,
32+ gateValues: [1.5, 2, 3],
33+ portalChancePerRow: 0.012,
34+ deepDropChance: 0.08,
35+ deepRows: 8,
36+ deepBuckets: [10, 4, 2, 1, 0, 1, 2, 4, 10],
37+ },
38+ featureNames: ["3 risk profiles", "Energy gates", "Portals", "Deep Drop"],
39+ rules: [
40+ "Choose a risk profile (Low / Medium / High) and a release lane, then press DROP.",
41+ "The capsule bounces left or right at every peg row (12 rows) and lands in one of 13 buckets.",
42+ "Energy gates multiply the result by ×1.5, ×2 or ×3; portals hop the capsule one to three lanes sideways.",
43+ "Bucket values are normalised per release lane, so every lane has the same expected value — edges are just wilder.",
44+ "Deep Drop (8% of drops): the tower extends by 8 rows and the capsule falls into a second bucket row that multiplies the result by 0× to 10×.",
45+ "Bucket values shown are already scaled to the game's certified RTP. Maximum win: 3,000× the bet.",
46+ ],
47+});
48+
49+export const gridBreak = defineArcadeGame({
50+ slug: "grid-break",
51+ name: "Grid//Break",
52+ version: "1.0.0",
53+ tagline: "One wave. Infinite chains.",
54+ description:
55+ "An 8×8 grid of energy blocks. Fire a wave down a column: every cluster it touches detonates, blocks fall, fresh ones drop in and new clusters detonate in a chain. Longer chains climb the multiplier ladder. Bombs clear 3×3, line blocks wipe a row, ×2 blocks double the step. It plays like a lightning-fast puzzle game.",
56+ theme: "futuristic puzzle grid",
57+ mode: "instant",
58+ rtp: 0.96,
59+ maxMultiplier: 2500,
60+ volatility: "medium",
61+ presentation: { scene: "gridbreak", palette: { primary: "#a3e635", secondary: "#38bdf8", glow: "#bef264", bg: "#05080c", surface: "#0e1620" }, ambience: "quantum", verb: "FIRE WAVE" },
62+ config: {
63+ size: 8,
64+ colors: 6,
65+ values: [1, 1.5, 2.5, 4, 7, 12],
66+ weights: [26, 22, 19, 15, 11, 7],
67+ specialChance: { bomb: 0.02, line: 0.012, x2: 0.02 },
68+ chainLadder: [1, 2, 4, 8, 16, 32, 64, 128],
69+ minCluster: 4,
70+ maxChains: 40,
71+ },
72+ featureNames: ["Chain reactions", "Bombs", "Line clears", "×2 blocks"],
73+ rules: [
74+ "Pick a column and fire the wave. Clusters of 4+ connected same-colour blocks touching that column detonate.",
75+ "Blocks fall, new blocks drop in; any new cluster of 4+ detonates automatically — a chain.",
76+ "Each chain step doubles the multiplier: ×1, ×2, ×4, ×8, ×16, ×32, ×64, ×128.",
77+ "Bomb blocks clear the 3×3 around them, line blocks clear their whole row, ×2 blocks double the step.",
78+ "Block values: the rarer the colour, the higher the value. Maximum win: 2,500× the bet.",
79+ ],
80+});
81+
82+export const theVault = defineArcadeGame({
83+ slug: "the-vault",
84+ name: "The Vault",
85+ version: "1.0.0",
86+ tagline: "Five layers. One decision at each.",
87+ description:
88+ "Face a colossal vault with five locked layers. Each layer reveals its digits one by one; when a layer opens, secure your multiplier or attempt the next lock. The deeper you go, the more spectacular the vault — and the higher the risk. Open all five in one run to trigger the fictional jackpot.",
89+ theme: "monumental vault",
90+ mode: "ladder",
91+ rtp: 0.96,
92+ maxMultiplier: 50,
93+ volatility: "high",
94+ presentation: { scene: "vault", palette: { primary: "#c9a961", secondary: "#38bdf8", glow: "#e8cf8f", bg: "#08070a", surface: "#16131a" }, ambience: "heist", verb: "OPEN NEXT LOCK", secondaryVerb: "SECURE" },
95+ config: {
96+ layers: [0.8, 0.7, 0.55, 0.4, 0.25],
97+ digitsPerLayer: 3,
98+ layerNames: ["Outer door", "Time lock", "Biometric ring", "Pressure chamber", "The core"],
99+ },
100+ featureNames: ["Secure or push", "5 layers", "Fictional jackpot"],
101+ rules: [
102+ "Place a bet. The first layer opens automatically — its 3 digits are revealed one by one.",
103+ "After each opened layer, SECURE your multiplier or OPEN the next lock.",
104+ "Chances the lock yields: 80% → 70% → 55% → 40% → 25%. Multipliers after each layer: 1.20× → 1.71× → 3.12× → 7.79× → 31.2×.",
105+ "A failed digit triggers the alarm: the run ends and the bet is lost.",
106+ "Opening all five layers in one run pays the fictional jackpot (31.2×). Expected return is 96% whatever your stopping point.",
107+ ],
108+});
109+
110+export const orbit = defineArcadeGame({
111+ slug: "orbit",
112+ name: "Orbit",
113+ version: "1.0.0",
114+ tagline: "Fire the impulse. Ride the chaos.",
115+ description:
116+ "Planets, satellites and debris circle a burning core. Aim an impulse through the orbits: every object it touches adds value, may deflect the impulse or spawn a brand-new orbit. Some rounds cascade into fifteen objects spinning at once. Supernova collapses everything and turns the survivors into multipliers.",
117+ theme: "orbital mechanics",
118+ mode: "instant",
119+ rtp: 0.96,
120+ maxMultiplier: 2000,
121+ volatility: "high",
122+ presentation: { scene: "orbit", palette: { primary: "#f0abfc", secondary: "#facc15", glow: "#f5d0fe", bg: "#07030f", surface: "#150a26" }, ambience: "space", verb: "FIRE IMPULSE" },
123+ config: {
124+ orbits: 3,
125+ objectsPerOrbit: [3, 6],
126+ beamHalfWidth: 14,
127+ objectTypes: [
128+ { id: "debris", label: "Debris", value: 0.3, weight: 34 },
129+ { id: "satellite", label: "Satellite", value: 0.8, weight: 26, deflect: 40 },
130+ { id: "planet", label: "Planet", value: 2, weight: 18, deflect: 20 },
131+ { id: "comet", label: "Comet", value: 4, weight: 10, spawn: true },
132+ { id: "gasgiant", label: "Gas giant", value: 8, weight: 5, deflect: 60 },
133+ { id: "quasar", label: "Quasar", value: 25, weight: 1, spawn: true },
134+ ],
135+ supernovaChance: 0.04,
136+ maxOrbits: 7,
137+ },
138+ featureNames: ["Aim the impulse", "Deflections", "New orbits", "Supernova"],
139+ rules: [
140+ "Choose an angle and fire. The impulse travels outward through every orbit.",
141+ "An object within the beam is collected: Debris 0.3×, Satellite 0.8×, Planet 2×, Comet 4×, Gas giant 8×, Quasar 25× (values scaled to the certified RTP).",
142+ "Satellites, planets and gas giants deflect the impulse; comets and quasars spawn a new orbit (up to 7).",
143+ "Supernova (4% of scoring rounds): every remaining object becomes +0.5× on the collected total.",
144+ "Maximum win: 2,000× the bet.",
145+ ],
146+});
147+
148+export const escape99 = defineArcadeGame({
149+ slug: "escape-99",
150+ name: "Escape 99",
151+ version: "1.0.0",
152+ tagline: "Ninety-nine floors. Cash out on any of them.",
153+ description:
154+ "A lightning-fast roguelike. Start on floor 1 of a 99-floor tower and climb room by room: chests, guardians, traps, runes, portals and forks between a safe corridor and a risky one. Cash out on any floor, or push for the summit — floor 99 triggers the final sequence.",
155+ theme: "roguelike tower",
156+ mode: "ladder",
157+ rtp: 0.96,
158+ maxMultiplier: 5000,
159+ volatility: "extreme",
160+ presentation: { scene: "escape", palette: { primary: "#34d399", secondary: "#f97316", glow: "#6ee7b7", bg: "#06090a", surface: "#0f1a16" }, ambience: "jungle", verb: "CLIMB", secondaryVerb: "CASH OUT" },
161+ config: {
162+ floors: 99,
163+ bands: [
164+ [10, 0.97],
165+ [25, 0.95],
166+ [50, 0.93],
167+ [75, 0.91],
168+ [99, 0.89],
169+ ],
170+ checkpoints: [10, 25, 50, 75, 99],
171+ rooms: [
172+ { kind: "chest", label: "Treasure room", weight: 26, detail: "a chest glints in the dark" },
173+ { kind: "enemy", label: "Guardian hall", weight: 22, detail: "something stirs" },
174+ { kind: "trap", label: "Trap corridor", weight: 18, detail: "pressure plates everywhere" },
175+ { kind: "multiplier", label: "Rune chamber", weight: 16, detail: "runes pulse with energy" },
176+ { kind: "room", label: "Quiet stairwell", weight: 18, detail: "nothing but echoes" },
177+ ],
178+ forkChance: 0.18,
179+ portalChance: 0.06,
180+ },
181+ featureNames: ["99 floors", "Forks", "Portals", "Checkpoints", "Final sequence"],
182+ rules: [
183+ "Place a bet: you enter floor 1 automatically. Each CLIMB advances one floor (three through a portal).",
184+ "Floor safety: 97% up to floor 10, 95% to 25, 93% to 50, 91% to 75, 89% beyond. Each survived floor multiplies by 1 ÷ safety.",
185+ "Forks offer a safe corridor (+6% safety, smaller jump) or a risky one (−18%, bigger jump) — equal expected value.",
186+ "CASH OUT at any floor after the first. Checkpoints (10, 25, 50, 75, 99) are marked on the tower.",
187+ "Floor 99 ends the run with the final sequence. Expected return is 96% for any stopping strategy; maximum win 5,000×.",
188+ ],
189+});
190+
191+type Calibration = Record<string, { payScale: number; version: string }>;
192+const withCalibration = (def: ArcadeGameDefinition): ArcadeGameDefinition => {
193+ const c = (calibration as Calibration)[def.slug];
194+ return c && c.version === def.version ? { ...def, payScale: c.payScale } : def;
195+};
196+
197+export const RAW_ARCADE_GAMES: ArcadeGameDefinition[] = [dropzone, gridBreak, theVault, orbit, escape99];
198+export const ARCADE_GAMES: ArcadeGameDefinition[] = RAW_ARCADE_GAMES.map(withCalibration);
199+export const ARCADE_BY_SLUG = new Map(ARCADE_GAMES.map((g) => [g.slug, g]));
modified games/src/calibration.json +21 −0
@@ -138,5 +138,26 @@
138 "calibratedAt": "2026-09-08T01:54:22.020Z", 138 "calibratedAt": "2026-09-08T01:54:22.020Z",
139 "spins": 10000000, 139 "spins": 10000000,
140 "observedRtp": 0.95927 140 "observedRtp": 0.95927
141+ },
142+ "dropzone": {
143+ "payScale": 0.5557,
144+ "version": "1.0.0",
145+ "calibratedAt": "2026-09-08T03:26:19.097Z",
146+ "spins": 1500000,
147+ "observedRtp": 0.95852
148+ },
149+ "grid-break": {
150+ "payScale": 0.1739,
151+ "version": "1.0.0",
152+ "calibratedAt": "2026-09-08T03:30:44.038Z",
153+ "spins": 1500000,
154+ "observedRtp": 0.95949
155+ },
156+ "orbit": {
157+ "payScale": 0.4347,
158+ "version": "1.0.0",
159+ "calibratedAt": "2026-09-08T03:30:53.175Z",
160+ "spins": 1500000,
161+ "observedRtp": 0.96
141 } 162 }
142 } 163 }
modified games/src/client.ts +1 −0
@@ -5,3 +5,4 @@
5 */ 5 */
6 export { GAMES, GAMES_BY_SLUG, getGame, FEATURED_ORDER, FIRST_GAME_RECOMMENDATIONS } from "./index"; 6 export { GAMES, GAMES_BY_SLUG, getGame, FEATURED_ORDER, FIRST_GAME_RECOMMENDATIONS } from "./index";
7 export { CRASH_GAMES, CRASH_BY_SLUG } from "./crash"; 7 export { CRASH_GAMES, CRASH_BY_SLUG } from "./crash";
8+export { ARCADE_GAMES, ARCADE_BY_SLUG } from "./arcade";
modified games/src/index.ts +1 −0
@@ -30,3 +30,4 @@ export const FEATURED_ORDER = ["spinza-original", "neon-vault", "cosmic-collapse
30 export const FIRST_GAME_RECOMMENDATIONS = ["neon-vault", "cosmic-collapse", "spinza-original"]; 30 export const FIRST_GAME_RECOMMENDATIONS = ["neon-vault", "cosmic-collapse", "spinza-original"];
31 export { RAW } from "./registry"; 31 export { RAW } from "./registry";
32 export { CRASH_GAMES, CRASH_BY_SLUG } from "./crash"; 32 export { CRASH_GAMES, CRASH_BY_SLUG } from "./crash";
33+export { ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "./arcade";
added packages/database/drizzle/0002_nosy_giant_man.sql +22 −0
@@ -0,0 +1,22 @@
1+CREATE TABLE "arcade_sessions" (
2+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3+ "round_id" varchar(32) NOT NULL,
4+ "user_id" uuid NOT NULL,
5+ "game_id" uuid NOT NULL,
6+ "game_slug" varchar(64) NOT NULL,
7+ "game_version" varchar(16) NOT NULL,
8+ "client_round_id" uuid NOT NULL,
9+ "bet" bigint NOT NULL,
10+ "state" jsonb NOT NULL,
11+ "status" varchar(12) DEFAULT 'running' NOT NULL,
12+ "win" bigint DEFAULT 0 NOT NULL,
13+ "started_at" timestamp with time zone DEFAULT now() NOT NULL,
14+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
15+ "settled_at" timestamp with time zone
16+);
17+--> statement-breakpoint
18+ALTER TABLE "arcade_sessions" ADD CONSTRAINT "arcade_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
19+ALTER TABLE "arcade_sessions" ADD CONSTRAINT "arcade_sessions_game_id_games_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
20+CREATE UNIQUE INDEX "arcade_sessions_round_id_idx" ON "arcade_sessions" USING btree ("round_id");--> statement-breakpoint
21+CREATE UNIQUE INDEX "arcade_sessions_user_client_idx" ON "arcade_sessions" USING btree ("user_id","client_round_id");--> statement-breakpoint
22+CREATE INDEX "arcade_sessions_user_status_idx" ON "arcade_sessions" USING btree ("user_id","status");
\ No newline at end of file
added packages/database/drizzle/meta/0002_snapshot.json +2988 −0
@@ -0,0 +1,2988 @@
1+{
2+ "id": "2e2fe479-1d38-4656-9964-b1f0fda2ba45",
3+ "prevId": "423e47ca-9681-4a28-97a8-d582818cd2d7",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.achievements": {
8+ "name": "achievements",
9+ "schema": "",
10+ "columns": {
11+ "key": {
12+ "name": "key",
13+ "type": "varchar(48)",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "name": {
18+ "name": "name",
19+ "type": "varchar(80)",
20+ "primaryKey": false,
21+ "notNull": true
22+ },
23+ "description": {
24+ "name": "description",
25+ "type": "text",
26+ "primaryKey": false,
27+ "notNull": true
28+ },
29+ "category": {
30+ "name": "category",
31+ "type": "varchar(24)",
32+ "primaryKey": false,
33+ "notNull": true
34+ },
35+ "metric": {
36+ "name": "metric",
37+ "type": "varchar(32)",
38+ "primaryKey": false,
39+ "notNull": true
40+ },
41+ "target": {
42+ "name": "target",
43+ "type": "bigint",
44+ "primaryKey": false,
45+ "notNull": true
46+ },
47+ "reward_credits": {
48+ "name": "reward_credits",
49+ "type": "integer",
50+ "primaryKey": false,
51+ "notNull": true,
52+ "default": 0
53+ },
54+ "reward_xp": {
55+ "name": "reward_xp",
56+ "type": "integer",
57+ "primaryKey": false,
58+ "notNull": true,
59+ "default": 0
60+ },
61+ "icon": {
62+ "name": "icon",
63+ "type": "varchar(32)",
64+ "primaryKey": false,
65+ "notNull": true,
66+ "default": "'star'"
67+ },
68+ "sort_order": {
69+ "name": "sort_order",
70+ "type": "integer",
71+ "primaryKey": false,
72+ "notNull": true,
73+ "default": 0
74+ },
75+ "enabled": {
76+ "name": "enabled",
77+ "type": "boolean",
78+ "primaryKey": false,
79+ "notNull": true,
80+ "default": true
81+ }
82+ },
83+ "indexes": {},
84+ "foreignKeys": {},
85+ "compositePrimaryKeys": {},
86+ "uniqueConstraints": {},
87+ "policies": {},
88+ "checkConstraints": {},
89+ "isRLSEnabled": false
90+ },
91+ "public.admin_sessions": {
92+ "name": "admin_sessions",
93+ "schema": "",
94+ "columns": {
95+ "id": {
96+ "name": "id",
97+ "type": "uuid",
98+ "primaryKey": true,
99+ "notNull": true,
100+ "default": "gen_random_uuid()"
101+ },
102+ "admin_id": {
103+ "name": "admin_id",
104+ "type": "uuid",
105+ "primaryKey": false,
106+ "notNull": true
107+ },
108+ "token_hash": {
109+ "name": "token_hash",
110+ "type": "text",
111+ "primaryKey": false,
112+ "notNull": true
113+ },
114+ "created_at": {
115+ "name": "created_at",
116+ "type": "timestamp with time zone",
117+ "primaryKey": false,
118+ "notNull": true,
119+ "default": "now()"
120+ },
121+ "expires_at": {
122+ "name": "expires_at",
123+ "type": "timestamp with time zone",
124+ "primaryKey": false,
125+ "notNull": true
126+ },
127+ "ip": {
128+ "name": "ip",
129+ "type": "varchar(64)",
130+ "primaryKey": false,
131+ "notNull": false
132+ }
133+ },
134+ "indexes": {
135+ "admin_sessions_token_idx": {
136+ "name": "admin_sessions_token_idx",
137+ "columns": [
138+ {
139+ "expression": "token_hash",
140+ "isExpression": false,
141+ "asc": true,
142+ "nulls": "last"
143+ }
144+ ],
145+ "isUnique": true,
146+ "concurrently": false,
147+ "method": "btree",
148+ "with": {}
149+ }
150+ },
151+ "foreignKeys": {
152+ "admin_sessions_admin_id_admin_users_id_fk": {
153+ "name": "admin_sessions_admin_id_admin_users_id_fk",
154+ "tableFrom": "admin_sessions",
155+ "tableTo": "admin_users",
156+ "columnsFrom": [
157+ "admin_id"
158+ ],
159+ "columnsTo": [
160+ "id"
161+ ],
162+ "onDelete": "cascade",
163+ "onUpdate": "no action"
164+ }
165+ },
166+ "compositePrimaryKeys": {},
167+ "uniqueConstraints": {},
168+ "policies": {},
169+ "checkConstraints": {},
170+ "isRLSEnabled": false
171+ },
172+ "public.admin_users": {
173+ "name": "admin_users",
174+ "schema": "",
175+ "columns": {
176+ "id": {
177+ "name": "id",
178+ "type": "uuid",
179+ "primaryKey": true,
180+ "notNull": true,
181+ "default": "gen_random_uuid()"
182+ },
183+ "username": {
184+ "name": "username",
185+ "type": "varchar(32)",
186+ "primaryKey": false,
187+ "notNull": true
188+ },
189+ "password_hash": {
190+ "name": "password_hash",
191+ "type": "text",
192+ "primaryKey": false,
193+ "notNull": true
194+ },
195+ "totp_secret": {
196+ "name": "totp_secret",
197+ "type": "text",
198+ "primaryKey": false,
199+ "notNull": true
200+ },
201+ "role": {
202+ "name": "role",
203+ "type": "varchar(16)",
204+ "primaryKey": false,
205+ "notNull": true,
206+ "default": "'admin'"
207+ },
208+ "created_at": {
209+ "name": "created_at",
210+ "type": "timestamp with time zone",
211+ "primaryKey": false,
212+ "notNull": true,
213+ "default": "now()"
214+ },
215+ "last_login_at": {
216+ "name": "last_login_at",
217+ "type": "timestamp with time zone",
218+ "primaryKey": false,
219+ "notNull": false
220+ },
221+ "disabled": {
222+ "name": "disabled",
223+ "type": "boolean",
224+ "primaryKey": false,
225+ "notNull": true,
226+ "default": false
227+ }
228+ },
229+ "indexes": {
230+ "admin_users_username_idx": {
231+ "name": "admin_users_username_idx",
232+ "columns": [
233+ {
234+ "expression": "username",
235+ "isExpression": false,
236+ "asc": true,
237+ "nulls": "last"
238+ }
239+ ],
240+ "isUnique": true,
241+ "concurrently": false,
242+ "method": "btree",
243+ "with": {}
244+ }
245+ },
246+ "foreignKeys": {},
247+ "compositePrimaryKeys": {},
248+ "uniqueConstraints": {},
249+ "policies": {},
250+ "checkConstraints": {},
251+ "isRLSEnabled": false
252+ },
253+ "public.arcade_sessions": {
254+ "name": "arcade_sessions",
255+ "schema": "",
256+ "columns": {
257+ "id": {
258+ "name": "id",
259+ "type": "uuid",
260+ "primaryKey": true,
261+ "notNull": true,
262+ "default": "gen_random_uuid()"
263+ },
264+ "round_id": {
265+ "name": "round_id",
266+ "type": "varchar(32)",
267+ "primaryKey": false,
268+ "notNull": true
269+ },
270+ "user_id": {
271+ "name": "user_id",
272+ "type": "uuid",
273+ "primaryKey": false,
274+ "notNull": true
275+ },
276+ "game_id": {
277+ "name": "game_id",
278+ "type": "uuid",
279+ "primaryKey": false,
280+ "notNull": true
281+ },
282+ "game_slug": {
283+ "name": "game_slug",
284+ "type": "varchar(64)",
285+ "primaryKey": false,
286+ "notNull": true
287+ },
288+ "game_version": {
289+ "name": "game_version",
290+ "type": "varchar(16)",
291+ "primaryKey": false,
292+ "notNull": true
293+ },
294+ "client_round_id": {
295+ "name": "client_round_id",
296+ "type": "uuid",
297+ "primaryKey": false,
298+ "notNull": true
299+ },
300+ "bet": {
301+ "name": "bet",
302+ "type": "bigint",
303+ "primaryKey": false,
304+ "notNull": true
305+ },
306+ "state": {
307+ "name": "state",
308+ "type": "jsonb",
309+ "primaryKey": false,
310+ "notNull": true
311+ },
312+ "status": {
313+ "name": "status",
314+ "type": "varchar(12)",
315+ "primaryKey": false,
316+ "notNull": true,
317+ "default": "'running'"
318+ },
319+ "win": {
320+ "name": "win",
321+ "type": "bigint",
322+ "primaryKey": false,
323+ "notNull": true,
324+ "default": 0
325+ },
326+ "started_at": {
327+ "name": "started_at",
328+ "type": "timestamp with time zone",
329+ "primaryKey": false,
330+ "notNull": true,
331+ "default": "now()"
332+ },
333+ "updated_at": {
334+ "name": "updated_at",
335+ "type": "timestamp with time zone",
336+ "primaryKey": false,
337+ "notNull": true,
338+ "default": "now()"
339+ },
340+ "settled_at": {
341+ "name": "settled_at",
342+ "type": "timestamp with time zone",
343+ "primaryKey": false,
344+ "notNull": false
345+ }
346+ },
347+ "indexes": {
348+ "arcade_sessions_round_id_idx": {
349+ "name": "arcade_sessions_round_id_idx",
350+ "columns": [
351+ {
352+ "expression": "round_id",
353+ "isExpression": false,
354+ "asc": true,
355+ "nulls": "last"
356+ }
357+ ],
358+ "isUnique": true,
359+ "concurrently": false,
360+ "method": "btree",
361+ "with": {}
362+ },
363+ "arcade_sessions_user_client_idx": {
364+ "name": "arcade_sessions_user_client_idx",
365+ "columns": [
366+ {
367+ "expression": "user_id",
368+ "isExpression": false,
369+ "asc": true,
370+ "nulls": "last"
371+ },
372+ {
373+ "expression": "client_round_id",
374+ "isExpression": false,
375+ "asc": true,
376+ "nulls": "last"
377+ }
378+ ],
379+ "isUnique": true,
380+ "concurrently": false,
381+ "method": "btree",
382+ "with": {}
383+ },
384+ "arcade_sessions_user_status_idx": {
385+ "name": "arcade_sessions_user_status_idx",
386+ "columns": [
387+ {
388+ "expression": "user_id",
389+ "isExpression": false,
390+ "asc": true,
391+ "nulls": "last"
392+ },
393+ {
394+ "expression": "status",
395+ "isExpression": false,
396+ "asc": true,
397+ "nulls": "last"
398+ }
399+ ],
400+ "isUnique": false,
401+ "concurrently": false,
402+ "method": "btree",
403+ "with": {}
404+ }
405+ },
406+ "foreignKeys": {
407+ "arcade_sessions_user_id_users_id_fk": {
408+ "name": "arcade_sessions_user_id_users_id_fk",
409+ "tableFrom": "arcade_sessions",
410+ "tableTo": "users",
411+ "columnsFrom": [
412+ "user_id"
413+ ],
414+ "columnsTo": [
415+ "id"
416+ ],
417+ "onDelete": "cascade",
418+ "onUpdate": "no action"
419+ },
420+ "arcade_sessions_game_id_games_id_fk": {
421+ "name": "arcade_sessions_game_id_games_id_fk",
422+ "tableFrom": "arcade_sessions",
423+ "tableTo": "games",
424+ "columnsFrom": [
425+ "game_id"
426+ ],
427+ "columnsTo": [
428+ "id"
429+ ],
430+ "onDelete": "no action",
431+ "onUpdate": "no action"
432+ }
433+ },
434+ "compositePrimaryKeys": {},
435+ "uniqueConstraints": {},
436+ "policies": {},
437+ "checkConstraints": {},
438+ "isRLSEnabled": false
439+ },
440+ "public.crash_rounds": {
441+ "name": "crash_rounds",
442+ "schema": "",
443+ "columns": {
444+ "id": {
445+ "name": "id",
446+ "type": "uuid",
447+ "primaryKey": true,
448+ "notNull": true,
449+ "default": "gen_random_uuid()"
450+ },
451+ "round_id": {
452+ "name": "round_id",
453+ "type": "varchar(32)",
454+ "primaryKey": false,
455+ "notNull": true
456+ },
457+ "user_id": {
458+ "name": "user_id",
459+ "type": "uuid",
460+ "primaryKey": false,
461+ "notNull": true
462+ },
463+ "game_id": {
464+ "name": "game_id",
465+ "type": "uuid",
466+ "primaryKey": false,
467+ "notNull": true
468+ },
469+ "game_slug": {
470+ "name": "game_slug",
471+ "type": "varchar(64)",
472+ "primaryKey": false,
473+ "notNull": true
474+ },
475+ "game_version": {
476+ "name": "game_version",
477+ "type": "varchar(16)",
478+ "primaryKey": false,
479+ "notNull": true
480+ },
481+ "client_round_id": {
482+ "name": "client_round_id",
483+ "type": "uuid",
484+ "primaryKey": false,
485+ "notNull": true
486+ },
487+ "bet": {
488+ "name": "bet",
489+ "type": "bigint",
490+ "primaryKey": false,
491+ "notNull": true
492+ },
493+ "crash_multiplier": {
494+ "name": "crash_multiplier",
495+ "type": "numeric(12, 2)",
496+ "primaryKey": false,
497+ "notNull": true
498+ },
499+ "crash_at_ms": {
500+ "name": "crash_at_ms",
501+ "type": "integer",
502+ "primaryKey": false,
503+ "notNull": true
504+ },
505+ "seed": {
506+ "name": "seed",
507+ "type": "text",
508+ "primaryKey": false,
509+ "notNull": true
510+ },
511+ "commitment": {
512+ "name": "commitment",
513+ "type": "varchar(64)",
514+ "primaryKey": false,
515+ "notNull": true
516+ },
517+ "events": {
518+ "name": "events",
519+ "type": "jsonb",
520+ "primaryKey": false,
521+ "notNull": true,
522+ "default": "'[]'::jsonb"
523+ },
524+ "auto_cashout": {
525+ "name": "auto_cashout",
526+ "type": "numeric(12, 2)",
527+ "primaryKey": false,
528+ "notNull": false
529+ },
530+ "status": {
531+ "name": "status",
532+ "type": "varchar(12)",
533+ "primaryKey": false,
534+ "notNull": true,
535+ "default": "'running'"
536+ },
537+ "cashout_multiplier": {
538+ "name": "cashout_multiplier",
539+ "type": "numeric(12, 2)",
540+ "primaryKey": false,
541+ "notNull": false
542+ },
543+ "win": {
544+ "name": "win",
545+ "type": "bigint",
546+ "primaryKey": false,
547+ "notNull": true,
548+ "default": 0
549+ },
550+ "started_at": {
551+ "name": "started_at",
552+ "type": "timestamp with time zone",
553+ "primaryKey": false,
554+ "notNull": true,
555+ "default": "now()"
556+ },
557+ "settled_at": {
558+ "name": "settled_at",
559+ "type": "timestamp with time zone",
560+ "primaryKey": false,
561+ "notNull": false
562+ }
563+ },
564+ "indexes": {
565+ "crash_rounds_round_id_idx": {
566+ "name": "crash_rounds_round_id_idx",
567+ "columns": [
568+ {
569+ "expression": "round_id",
570+ "isExpression": false,
571+ "asc": true,
572+ "nulls": "last"
573+ }
574+ ],
575+ "isUnique": true,
576+ "concurrently": false,
577+ "method": "btree",
578+ "with": {}
579+ },
580+ "crash_rounds_user_client_idx": {
581+ "name": "crash_rounds_user_client_idx",
582+ "columns": [
583+ {
584+ "expression": "user_id",
585+ "isExpression": false,
586+ "asc": true,
587+ "nulls": "last"
588+ },
589+ {
590+ "expression": "client_round_id",
591+ "isExpression": false,
592+ "asc": true,
593+ "nulls": "last"
594+ }
595+ ],
596+ "isUnique": true,
597+ "concurrently": false,
598+ "method": "btree",
599+ "with": {}
600+ },
601+ "crash_rounds_user_status_idx": {
602+ "name": "crash_rounds_user_status_idx",
603+ "columns": [
604+ {
605+ "expression": "user_id",
606+ "isExpression": false,
607+ "asc": true,
608+ "nulls": "last"
609+ },
610+ {
611+ "expression": "status",
612+ "isExpression": false,
613+ "asc": true,
614+ "nulls": "last"
615+ }
616+ ],
617+ "isUnique": false,
618+ "concurrently": false,
619+ "method": "btree",
620+ "with": {}
621+ }
622+ },
623+ "foreignKeys": {
624+ "crash_rounds_user_id_users_id_fk": {
625+ "name": "crash_rounds_user_id_users_id_fk",
626+ "tableFrom": "crash_rounds",
627+ "tableTo": "users",
628+ "columnsFrom": [
629+ "user_id"
630+ ],
631+ "columnsTo": [
632+ "id"
633+ ],
634+ "onDelete": "cascade",
635+ "onUpdate": "no action"
636+ },
637+ "crash_rounds_game_id_games_id_fk": {
638+ "name": "crash_rounds_game_id_games_id_fk",
639+ "tableFrom": "crash_rounds",
640+ "tableTo": "games",
641+ "columnsFrom": [
642+ "game_id"
643+ ],
644+ "columnsTo": [
645+ "id"
646+ ],
647+ "onDelete": "no action",
648+ "onUpdate": "no action"
649+ }
650+ },
651+ "compositePrimaryKeys": {},
652+ "uniqueConstraints": {},
653+ "policies": {},
654+ "checkConstraints": {},
655+ "isRLSEnabled": false
656+ },
657+ "public.credit_transactions": {
658+ "name": "credit_transactions",
659+ "schema": "",
660+ "columns": {
661+ "id": {
662+ "name": "id",
663+ "type": "uuid",
664+ "primaryKey": true,
665+ "notNull": true,
666+ "default": "gen_random_uuid()"
667+ },
668+ "user_id": {
669+ "name": "user_id",
670+ "type": "uuid",
671+ "primaryKey": false,
672+ "notNull": true
673+ },
674+ "type": {
675+ "name": "type",
676+ "type": "varchar(24)",
677+ "primaryKey": false,
678+ "notNull": true
679+ },
680+ "amount": {
681+ "name": "amount",
682+ "type": "bigint",
683+ "primaryKey": false,
684+ "notNull": true
685+ },
686+ "balance_after": {
687+ "name": "balance_after",
688+ "type": "bigint",
689+ "primaryKey": false,
690+ "notNull": true
691+ },
692+ "reference": {
693+ "name": "reference",
694+ "type": "text",
695+ "primaryKey": false,
696+ "notNull": false
697+ },
698+ "meta": {
699+ "name": "meta",
700+ "type": "jsonb",
701+ "primaryKey": false,
702+ "notNull": false
703+ },
704+ "created_at": {
705+ "name": "created_at",
706+ "type": "timestamp with time zone",
707+ "primaryKey": false,
708+ "notNull": true,
709+ "default": "now()"
710+ }
711+ },
712+ "indexes": {
713+ "ctx_user_created_idx": {
714+ "name": "ctx_user_created_idx",
715+ "columns": [
716+ {
717+ "expression": "user_id",
718+ "isExpression": false,
719+ "asc": true,
720+ "nulls": "last"
721+ },
722+ {
723+ "expression": "created_at",
724+ "isExpression": false,
725+ "asc": true,
726+ "nulls": "last"
727+ }
728+ ],
729+ "isUnique": false,
730+ "concurrently": false,
731+ "method": "btree",
732+ "with": {}
733+ },
734+ "ctx_type_idx": {
735+ "name": "ctx_type_idx",
736+ "columns": [
737+ {
738+ "expression": "type",
739+ "isExpression": false,
740+ "asc": true,
741+ "nulls": "last"
742+ }
743+ ],
744+ "isUnique": false,
745+ "concurrently": false,
746+ "method": "btree",
747+ "with": {}
748+ },
749+ "ctx_reference_idx": {
750+ "name": "ctx_reference_idx",
751+ "columns": [
752+ {
753+ "expression": "reference",
754+ "isExpression": false,
755+ "asc": true,
756+ "nulls": "last"
757+ }
758+ ],
759+ "isUnique": false,
760+ "concurrently": false,
761+ "method": "btree",
762+ "with": {}
763+ }
764+ },
765+ "foreignKeys": {
766+ "credit_transactions_user_id_users_id_fk": {
767+ "name": "credit_transactions_user_id_users_id_fk",
768+ "tableFrom": "credit_transactions",
769+ "tableTo": "users",
770+ "columnsFrom": [
771+ "user_id"
772+ ],
773+ "columnsTo": [
774+ "id"
775+ ],
776+ "onDelete": "cascade",
777+ "onUpdate": "no action"
778+ }
779+ },
780+ "compositePrimaryKeys": {},
781+ "uniqueConstraints": {},
782+ "policies": {},
783+ "checkConstraints": {},
784+ "isRLSEnabled": false
785+ },
786+ "public.daily_rewards": {
787+ "name": "daily_rewards",
788+ "schema": "",
789+ "columns": {
790+ "user_id": {
791+ "name": "user_id",
792+ "type": "uuid",
793+ "primaryKey": true,
794+ "notNull": true
795+ },
796+ "streak_day": {
797+ "name": "streak_day",
798+ "type": "integer",
799+ "primaryKey": false,
800+ "notNull": true,
801+ "default": 0
802+ },
803+ "last_claimed_at": {
804+ "name": "last_claimed_at",
805+ "type": "timestamp with time zone",
806+ "primaryKey": false,
807+ "notNull": false
808+ },
809+ "next_available_at": {
810+ "name": "next_available_at",
811+ "type": "timestamp with time zone",
812+ "primaryKey": false,
813+ "notNull": false
814+ },
815+ "total_claimed": {
816+ "name": "total_claimed",
817+ "type": "bigint",
818+ "primaryKey": false,
819+ "notNull": true,
820+ "default": 0
821+ },
822+ "claims": {
823+ "name": "claims",
824+ "type": "integer",
825+ "primaryKey": false,
826+ "notNull": true,
827+ "default": 0
828+ }
829+ },
830+ "indexes": {},
831+ "foreignKeys": {
832+ "daily_rewards_user_id_users_id_fk": {
833+ "name": "daily_rewards_user_id_users_id_fk",
834+ "tableFrom": "daily_rewards",
835+ "tableTo": "users",
836+ "columnsFrom": [
837+ "user_id"
838+ ],
839+ "columnsTo": [
840+ "id"
841+ ],
842+ "onDelete": "cascade",
843+ "onUpdate": "no action"
844+ }
845+ },
846+ "compositePrimaryKeys": {},
847+ "uniqueConstraints": {},
848+ "policies": {},
849+ "checkConstraints": {},
850+ "isRLSEnabled": false
851+ },
852+ "public.favorites": {
853+ "name": "favorites",
854+ "schema": "",
855+ "columns": {
856+ "user_id": {
857+ "name": "user_id",
858+ "type": "uuid",
859+ "primaryKey": false,
860+ "notNull": true
861+ },
862+ "game_id": {
863+ "name": "game_id",
864+ "type": "uuid",
865+ "primaryKey": false,
866+ "notNull": true
867+ },
868+ "created_at": {
869+ "name": "created_at",
870+ "type": "timestamp with time zone",
871+ "primaryKey": false,
872+ "notNull": true,
873+ "default": "now()"
874+ }
875+ },
876+ "indexes": {},
877+ "foreignKeys": {
878+ "favorites_user_id_users_id_fk": {
879+ "name": "favorites_user_id_users_id_fk",
880+ "tableFrom": "favorites",
881+ "tableTo": "users",
882+ "columnsFrom": [
883+ "user_id"
884+ ],
885+ "columnsTo": [
886+ "id"
887+ ],
888+ "onDelete": "cascade",
889+ "onUpdate": "no action"
890+ },
891+ "favorites_game_id_games_id_fk": {
892+ "name": "favorites_game_id_games_id_fk",
893+ "tableFrom": "favorites",
894+ "tableTo": "games",
895+ "columnsFrom": [
896+ "game_id"
897+ ],
898+ "columnsTo": [
899+ "id"
900+ ],
901+ "onDelete": "cascade",
902+ "onUpdate": "no action"
903+ }
904+ },
905+ "compositePrimaryKeys": {
906+ "favorites_user_id_game_id_pk": {
907+ "name": "favorites_user_id_game_id_pk",
908+ "columns": [
909+ "user_id",
910+ "game_id"
911+ ]
912+ }
913+ },
914+ "uniqueConstraints": {},
915+ "policies": {},
916+ "checkConstraints": {},
917+ "isRLSEnabled": false
918+ },
919+ "public.feature_flags": {
920+ "name": "feature_flags",
921+ "schema": "",
922+ "columns": {
923+ "key": {
924+ "name": "key",
925+ "type": "varchar(64)",
926+ "primaryKey": true,
927+ "notNull": true
928+ },
929+ "enabled": {
930+ "name": "enabled",
931+ "type": "boolean",
932+ "primaryKey": false,
933+ "notNull": true,
934+ "default": true
935+ },
936+ "description": {
937+ "name": "description",
938+ "type": "text",
939+ "primaryKey": false,
940+ "notNull": false
941+ },
942+ "updated_at": {
943+ "name": "updated_at",
944+ "type": "timestamp with time zone",
945+ "primaryKey": false,
946+ "notNull": true,
947+ "default": "now()"
948+ }
949+ },
950+ "indexes": {},
951+ "foreignKeys": {},
952+ "compositePrimaryKeys": {},
953+ "uniqueConstraints": {},
954+ "policies": {},
955+ "checkConstraints": {},
956+ "isRLSEnabled": false
957+ },
958+ "public.game_rounds": {
959+ "name": "game_rounds",
960+ "schema": "",
961+ "columns": {
962+ "id": {
963+ "name": "id",
964+ "type": "uuid",
965+ "primaryKey": true,
966+ "notNull": true,
967+ "default": "gen_random_uuid()"
968+ },
969+ "round_id": {
970+ "name": "round_id",
971+ "type": "varchar(32)",
972+ "primaryKey": false,
973+ "notNull": true
974+ },
975+ "user_id": {
976+ "name": "user_id",
977+ "type": "uuid",
978+ "primaryKey": false,
979+ "notNull": true
980+ },
981+ "game_id": {
982+ "name": "game_id",
983+ "type": "uuid",
984+ "primaryKey": false,
985+ "notNull": true
986+ },
987+ "game_slug": {
988+ "name": "game_slug",
989+ "type": "varchar(64)",
990+ "primaryKey": false,
991+ "notNull": true
992+ },
993+ "game_version": {
994+ "name": "game_version",
995+ "type": "varchar(16)",
996+ "primaryKey": false,
997+ "notNull": true
998+ },
999+ "client_round_id": {
1000+ "name": "client_round_id",
1001+ "type": "uuid",
1002+ "primaryKey": false,
1003+ "notNull": true
1004+ },
1005+ "bet": {
1006+ "name": "bet",
1007+ "type": "bigint",
1008+ "primaryKey": false,
1009+ "notNull": true
1010+ },
1011+ "win": {
1012+ "name": "win",
1013+ "type": "bigint",
1014+ "primaryKey": false,
1015+ "notNull": true
1016+ },
1017+ "multiplier": {
1018+ "name": "multiplier",
1019+ "type": "numeric(12, 4)",
1020+ "primaryKey": false,
1021+ "notNull": true
1022+ },
1023+ "balance_after": {
1024+ "name": "balance_after",
1025+ "type": "bigint",
1026+ "primaryKey": false,
1027+ "notNull": true
1028+ },
1029+ "result": {
1030+ "name": "result",
1031+ "type": "jsonb",
1032+ "primaryKey": false,
1033+ "notNull": true
1034+ },
1035+ "features": {
1036+ "name": "features",
1037+ "type": "text[]",
1038+ "primaryKey": false,
1039+ "notNull": true,
1040+ "default": "'{}'::text[]"
1041+ },
1042+ "free_spins": {
1043+ "name": "free_spins",
1044+ "type": "boolean",
1045+ "primaryKey": false,
1046+ "notNull": true,
1047+ "default": false
1048+ },
1049+ "bonus": {
1050+ "name": "bonus",
1051+ "type": "boolean",
1052+ "primaryKey": false,
1053+ "notNull": true,
1054+ "default": false
1055+ },
1056+ "jackpot_tier": {
1057+ "name": "jackpot_tier",
1058+ "type": "varchar(8)",
1059+ "primaryKey": false,
1060+ "notNull": false
1061+ },
1062+ "rng_reference": {
1063+ "name": "rng_reference",
1064+ "type": "text",
1065+ "primaryKey": false,
1066+ "notNull": true
1067+ },
1068+ "duration_ms": {
1069+ "name": "duration_ms",
1070+ "type": "integer",
1071+ "primaryKey": false,
1072+ "notNull": false
1073+ },
1074+ "created_at": {
1075+ "name": "created_at",
1076+ "type": "timestamp with time zone",
1077+ "primaryKey": false,
1078+ "notNull": true,
1079+ "default": "now()"
1080+ }
1081+ },
1082+ "indexes": {
1083+ "game_rounds_round_id_idx": {
1084+ "name": "game_rounds_round_id_idx",
1085+ "columns": [
1086+ {
1087+ "expression": "round_id",
1088+ "isExpression": false,
1089+ "asc": true,
1090+ "nulls": "last"
1091+ }
1092+ ],
1093+ "isUnique": true,
1094+ "concurrently": false,
1095+ "method": "btree",
1096+ "with": {}
1097+ },
1098+ "game_rounds_user_client_idx": {
1099+ "name": "game_rounds_user_client_idx",
1100+ "columns": [
1101+ {
1102+ "expression": "user_id",
1103+ "isExpression": false,
1104+ "asc": true,
1105+ "nulls": "last"
1106+ },
1107+ {
1108+ "expression": "client_round_id",
1109+ "isExpression": false,
1110+ "asc": true,
1111+ "nulls": "last"
1112+ }
1113+ ],
1114+ "isUnique": true,
1115+ "concurrently": false,
1116+ "method": "btree",
1117+ "with": {}
1118+ },
1119+ "game_rounds_user_created_idx": {
1120+ "name": "game_rounds_user_created_idx",
1121+ "columns": [
1122+ {
1123+ "expression": "user_id",
1124+ "isExpression": false,
1125+ "asc": true,
1126+ "nulls": "last"
1127+ },
1128+ {
1129+ "expression": "created_at",
1130+ "isExpression": false,
1131+ "asc": true,
1132+ "nulls": "last"
1133+ }
1134+ ],
1135+ "isUnique": false,
1136+ "concurrently": false,
1137+ "method": "btree",
1138+ "with": {}
1139+ },
1140+ "game_rounds_game_created_idx": {
1141+ "name": "game_rounds_game_created_idx",
1142+ "columns": [
1143+ {
1144+ "expression": "game_id",
1145+ "isExpression": false,
1146+ "asc": true,
1147+ "nulls": "last"
1148+ },
1149+ {
1150+ "expression": "created_at",
1151+ "isExpression": false,
1152+ "asc": true,
1153+ "nulls": "last"
1154+ }
1155+ ],
1156+ "isUnique": false,
1157+ "concurrently": false,
1158+ "method": "btree",
1159+ "with": {}
1160+ },
1161+ "game_rounds_created_idx": {
1162+ "name": "game_rounds_created_idx",
1163+ "columns": [
1164+ {
1165+ "expression": "created_at",
1166+ "isExpression": false,
1167+ "asc": true,
1168+ "nulls": "last"
1169+ }
1170+ ],
1171+ "isUnique": false,
1172+ "concurrently": false,
1173+ "method": "btree",
1174+ "with": {}
1175+ },
1176+ "game_rounds_win_idx": {
1177+ "name": "game_rounds_win_idx",
1178+ "columns": [
1179+ {
1180+ "expression": "win",
1181+ "isExpression": false,
1182+ "asc": true,
1183+ "nulls": "last"
1184+ }
1185+ ],
1186+ "isUnique": false,
1187+ "concurrently": false,
1188+ "method": "btree",
1189+ "with": {}
1190+ }
1191+ },
1192+ "foreignKeys": {
1193+ "game_rounds_user_id_users_id_fk": {
1194+ "name": "game_rounds_user_id_users_id_fk",
1195+ "tableFrom": "game_rounds",
1196+ "tableTo": "users",
1197+ "columnsFrom": [
1198+ "user_id"
1199+ ],
1200+ "columnsTo": [
1201+ "id"
1202+ ],
1203+ "onDelete": "cascade",
1204+ "onUpdate": "no action"
1205+ },
1206+ "game_rounds_game_id_games_id_fk": {
1207+ "name": "game_rounds_game_id_games_id_fk",
1208+ "tableFrom": "game_rounds",
1209+ "tableTo": "games",
1210+ "columnsFrom": [
1211+ "game_id"
1212+ ],
1213+ "columnsTo": [
1214+ "id"
1215+ ],
1216+ "onDelete": "no action",
1217+ "onUpdate": "no action"
1218+ }
1219+ },
1220+ "compositePrimaryKeys": {},
1221+ "uniqueConstraints": {},
1222+ "policies": {},
1223+ "checkConstraints": {},
1224+ "isRLSEnabled": false
1225+ },
1226+ "public.game_states": {
1227+ "name": "game_states",
1228+ "schema": "",
1229+ "columns": {
1230+ "user_id": {
1231+ "name": "user_id",
1232+ "type": "uuid",
1233+ "primaryKey": false,
1234+ "notNull": true
1235+ },
1236+ "game_id": {
1237+ "name": "game_id",
1238+ "type": "uuid",
1239+ "primaryKey": false,
1240+ "notNull": true
1241+ },
1242+ "state": {
1243+ "name": "state",
1244+ "type": "jsonb",
1245+ "primaryKey": false,
1246+ "notNull": true
1247+ },
1248+ "updated_at": {
1249+ "name": "updated_at",
1250+ "type": "timestamp with time zone",
1251+ "primaryKey": false,
1252+ "notNull": true,
1253+ "default": "now()"
1254+ }
1255+ },
1256+ "indexes": {},
1257+ "foreignKeys": {
1258+ "game_states_user_id_users_id_fk": {
1259+ "name": "game_states_user_id_users_id_fk",
1260+ "tableFrom": "game_states",
1261+ "tableTo": "users",
1262+ "columnsFrom": [
1263+ "user_id"
1264+ ],
1265+ "columnsTo": [
1266+ "id"
1267+ ],
1268+ "onDelete": "cascade",
1269+ "onUpdate": "no action"
1270+ },
1271+ "game_states_game_id_games_id_fk": {
1272+ "name": "game_states_game_id_games_id_fk",
1273+ "tableFrom": "game_states",
1274+ "tableTo": "games",
1275+ "columnsFrom": [
1276+ "game_id"
1277+ ],
1278+ "columnsTo": [
1279+ "id"
1280+ ],
1281+ "onDelete": "cascade",
1282+ "onUpdate": "no action"
1283+ }
1284+ },
1285+ "compositePrimaryKeys": {
1286+ "game_states_user_id_game_id_pk": {
1287+ "name": "game_states_user_id_game_id_pk",
1288+ "columns": [
1289+ "user_id",
1290+ "game_id"
1291+ ]
1292+ }
1293+ },
1294+ "uniqueConstraints": {},
1295+ "policies": {},
1296+ "checkConstraints": {},
1297+ "isRLSEnabled": false
1298+ },
1299+ "public.game_statistics": {
1300+ "name": "game_statistics",
1301+ "schema": "",
1302+ "columns": {
1303+ "game_id": {
1304+ "name": "game_id",
1305+ "type": "uuid",
1306+ "primaryKey": true,
1307+ "notNull": true
1308+ },
1309+ "launches": {
1310+ "name": "launches",
1311+ "type": "bigint",
1312+ "primaryKey": false,
1313+ "notNull": true,
1314+ "default": 0
1315+ },
1316+ "spins": {
1317+ "name": "spins",
1318+ "type": "bigint",
1319+ "primaryKey": false,
1320+ "notNull": true,
1321+ "default": 0
1322+ },
1323+ "wagered": {
1324+ "name": "wagered",
1325+ "type": "bigint",
1326+ "primaryKey": false,
1327+ "notNull": true,
1328+ "default": 0
1329+ },
1330+ "won": {
1331+ "name": "won",
1332+ "type": "bigint",
1333+ "primaryKey": false,
1334+ "notNull": true,
1335+ "default": 0
1336+ },
1337+ "wins": {
1338+ "name": "wins",
1339+ "type": "bigint",
1340+ "primaryKey": false,
1341+ "notNull": true,
1342+ "default": 0
1343+ },
1344+ "bonuses": {
1345+ "name": "bonuses",
1346+ "type": "bigint",
1347+ "primaryKey": false,
1348+ "notNull": true,
1349+ "default": 0
1350+ },
1351+ "free_spins": {
1352+ "name": "free_spins",
1353+ "type": "bigint",
1354+ "primaryKey": false,
1355+ "notNull": true,
1356+ "default": 0
1357+ },
1358+ "big_wins": {
1359+ "name": "big_wins",
1360+ "type": "bigint",
1361+ "primaryKey": false,
1362+ "notNull": true,
1363+ "default": 0
1364+ },
1365+ "max_win": {
1366+ "name": "max_win",
1367+ "type": "bigint",
1368+ "primaryKey": false,
1369+ "notNull": true,
1370+ "default": 0
1371+ },
1372+ "max_multiplier": {
1373+ "name": "max_multiplier",
1374+ "type": "numeric(12, 2)",
1375+ "primaryKey": false,
1376+ "notNull": true,
1377+ "default": "'0'"
1378+ },
1379+ "favorites": {
1380+ "name": "favorites",
1381+ "type": "integer",
1382+ "primaryKey": false,
1383+ "notNull": true,
1384+ "default": 0
1385+ },
1386+ "updated_at": {
1387+ "name": "updated_at",
1388+ "type": "timestamp with time zone",
1389+ "primaryKey": false,
1390+ "notNull": true,
1391+ "default": "now()"
1392+ }
1393+ },
1394+ "indexes": {},
1395+ "foreignKeys": {
1396+ "game_statistics_game_id_games_id_fk": {
1397+ "name": "game_statistics_game_id_games_id_fk",
1398+ "tableFrom": "game_statistics",
1399+ "tableTo": "games",
1400+ "columnsFrom": [
1401+ "game_id"
1402+ ],
1403+ "columnsTo": [
1404+ "id"
1405+ ],
1406+ "onDelete": "cascade",
1407+ "onUpdate": "no action"
1408+ }
1409+ },
1410+ "compositePrimaryKeys": {},
1411+ "uniqueConstraints": {},
1412+ "policies": {},
1413+ "checkConstraints": {},
1414+ "isRLSEnabled": false
1415+ },
1416+ "public.game_versions": {
1417+ "name": "game_versions",
1418+ "schema": "",
1419+ "columns": {
1420+ "id": {
1421+ "name": "id",
1422+ "type": "uuid",
1423+ "primaryKey": true,
1424+ "notNull": true,
1425+ "default": "gen_random_uuid()"
1426+ },
1427+ "game_id": {
1428+ "name": "game_id",
1429+ "type": "uuid",
1430+ "primaryKey": false,
1431+ "notNull": true
1432+ },
1433+ "version": {
1434+ "name": "version",
1435+ "type": "varchar(16)",
1436+ "primaryKey": false,
1437+ "notNull": true
1438+ },
1439+ "definition": {
1440+ "name": "definition",
1441+ "type": "jsonb",
1442+ "primaryKey": false,
1443+ "notNull": true
1444+ },
1445+ "definition_hash": {
1446+ "name": "definition_hash",
1447+ "type": "varchar(64)",
1448+ "primaryKey": false,
1449+ "notNull": true
1450+ },
1451+ "certification": {
1452+ "name": "certification",
1453+ "type": "jsonb",
1454+ "primaryKey": false,
1455+ "notNull": false
1456+ },
1457+ "status": {
1458+ "name": "status",
1459+ "type": "varchar(16)",
1460+ "primaryKey": false,
1461+ "notNull": true,
1462+ "default": "'draft'"
1463+ },
1464+ "created_at": {
1465+ "name": "created_at",
1466+ "type": "timestamp with time zone",
1467+ "primaryKey": false,
1468+ "notNull": true,
1469+ "default": "now()"
1470+ }
1471+ },
1472+ "indexes": {
1473+ "game_versions_game_version_idx": {
1474+ "name": "game_versions_game_version_idx",
1475+ "columns": [
1476+ {
1477+ "expression": "game_id",
1478+ "isExpression": false,
1479+ "asc": true,
1480+ "nulls": "last"
1481+ },
1482+ {
1483+ "expression": "version",
1484+ "isExpression": false,
1485+ "asc": true,
1486+ "nulls": "last"
1487+ }
1488+ ],
1489+ "isUnique": true,
1490+ "concurrently": false,
1491+ "method": "btree",
1492+ "with": {}
1493+ }
1494+ },
1495+ "foreignKeys": {
1496+ "game_versions_game_id_games_id_fk": {
1497+ "name": "game_versions_game_id_games_id_fk",
1498+ "tableFrom": "game_versions",
1499+ "tableTo": "games",
1500+ "columnsFrom": [
1501+ "game_id"
1502+ ],
1503+ "columnsTo": [
1504+ "id"
1505+ ],
1506+ "onDelete": "cascade",
1507+ "onUpdate": "no action"
1508+ }
1509+ },
1510+ "compositePrimaryKeys": {},
1511+ "uniqueConstraints": {},
1512+ "policies": {},
1513+ "checkConstraints": {},
1514+ "isRLSEnabled": false
1515+ },
1516+ "public.games": {
1517+ "name": "games",
1518+ "schema": "",
1519+ "columns": {
1520+ "id": {
1521+ "name": "id",
1522+ "type": "uuid",
1523+ "primaryKey": true,
1524+ "notNull": true,
1525+ "default": "gen_random_uuid()"
1526+ },
1527+ "slug": {
1528+ "name": "slug",
1529+ "type": "varchar(64)",
1530+ "primaryKey": false,
1531+ "notNull": true
1532+ },
1533+ "name": {
1534+ "name": "name",
1535+ "type": "varchar(80)",
1536+ "primaryKey": false,
1537+ "notNull": true
1538+ },
1539+ "version": {
1540+ "name": "version",
1541+ "type": "varchar(16)",
1542+ "primaryKey": false,
1543+ "notNull": true
1544+ },
1545+ "lifecycle": {
1546+ "name": "lifecycle",
1547+ "type": "varchar(16)",
1548+ "primaryKey": false,
1549+ "notNull": true,
1550+ "default": "'draft'"
1551+ },
1552+ "sort_order": {
1553+ "name": "sort_order",
1554+ "type": "integer",
1555+ "primaryKey": false,
1556+ "notNull": true,
1557+ "default": 0
1558+ },
1559+ "is_featured": {
1560+ "name": "is_featured",
1561+ "type": "boolean",
1562+ "primaryKey": false,
1563+ "notNull": true,
1564+ "default": false
1565+ },
1566+ "is_new": {
1567+ "name": "is_new",
1568+ "type": "boolean",
1569+ "primaryKey": false,
1570+ "notNull": true,
1571+ "default": true
1572+ },
1573+ "summary": {
1574+ "name": "summary",
1575+ "type": "jsonb",
1576+ "primaryKey": false,
1577+ "notNull": true
1578+ },
1579+ "created_at": {
1580+ "name": "created_at",
1581+ "type": "timestamp with time zone",
1582+ "primaryKey": false,
1583+ "notNull": true,
1584+ "default": "now()"
1585+ },
1586+ "published_at": {
1587+ "name": "published_at",
1588+ "type": "timestamp with time zone",
1589+ "primaryKey": false,
1590+ "notNull": false
1591+ },
1592+ "updated_at": {
1593+ "name": "updated_at",
1594+ "type": "timestamp with time zone",
1595+ "primaryKey": false,
1596+ "notNull": true,
1597+ "default": "now()"
1598+ }
1599+ },
1600+ "indexes": {
1601+ "games_slug_idx": {
1602+ "name": "games_slug_idx",
1603+ "columns": [
1604+ {
1605+ "expression": "slug",
1606+ "isExpression": false,
1607+ "asc": true,
1608+ "nulls": "last"
1609+ }
1610+ ],
1611+ "isUnique": true,
1612+ "concurrently": false,
1613+ "method": "btree",
1614+ "with": {}
1615+ }
1616+ },
1617+ "foreignKeys": {},
1618+ "compositePrimaryKeys": {},
1619+ "uniqueConstraints": {},
1620+ "policies": {},
1621+ "checkConstraints": {},
1622+ "isRLSEnabled": false
1623+ },
1624+ "public.leaderboards": {
1625+ "name": "leaderboards",
1626+ "schema": "",
1627+ "columns": {
1628+ "id": {
1629+ "name": "id",
1630+ "type": "uuid",
1631+ "primaryKey": true,
1632+ "notNull": true,
1633+ "default": "gen_random_uuid()"
1634+ },
1635+ "category": {
1636+ "name": "category",
1637+ "type": "varchar(32)",
1638+ "primaryKey": false,
1639+ "notNull": true
1640+ },
1641+ "period_key": {
1642+ "name": "period_key",
1643+ "type": "varchar(16)",
1644+ "primaryKey": false,
1645+ "notNull": true
1646+ },
1647+ "user_id": {
1648+ "name": "user_id",
1649+ "type": "uuid",
1650+ "primaryKey": false,
1651+ "notNull": true
1652+ },
1653+ "value": {
1654+ "name": "value",
1655+ "type": "numeric(18, 2)",
1656+ "primaryKey": false,
1657+ "notNull": true
1658+ },
1659+ "game_slug": {
1660+ "name": "game_slug",
1661+ "type": "varchar(64)",
1662+ "primaryKey": false,
1663+ "notNull": false
1664+ },
1665+ "round_id": {
1666+ "name": "round_id",
1667+ "type": "varchar(32)",
1668+ "primaryKey": false,
1669+ "notNull": false
1670+ },
1671+ "updated_at": {
1672+ "name": "updated_at",
1673+ "type": "timestamp with time zone",
1674+ "primaryKey": false,
1675+ "notNull": true,
1676+ "default": "now()"
1677+ }
1678+ },
1679+ "indexes": {
1680+ "leaderboards_unique_idx": {
1681+ "name": "leaderboards_unique_idx",
1682+ "columns": [
1683+ {
1684+ "expression": "category",
1685+ "isExpression": false,
1686+ "asc": true,
1687+ "nulls": "last"
1688+ },
1689+ {
1690+ "expression": "period_key",
1691+ "isExpression": false,
1692+ "asc": true,
1693+ "nulls": "last"
1694+ },
1695+ {
1696+ "expression": "user_id",
1697+ "isExpression": false,
1698+ "asc": true,
1699+ "nulls": "last"
1700+ }
1701+ ],
1702+ "isUnique": true,
1703+ "concurrently": false,
1704+ "method": "btree",
1705+ "with": {}
1706+ },
1707+ "leaderboards_rank_idx": {
1708+ "name": "leaderboards_rank_idx",
1709+ "columns": [
1710+ {
1711+ "expression": "category",
1712+ "isExpression": false,
1713+ "asc": true,
1714+ "nulls": "last"
1715+ },
1716+ {
1717+ "expression": "period_key",
1718+ "isExpression": false,
1719+ "asc": true,
1720+ "nulls": "last"
1721+ },
1722+ {
1723+ "expression": "value",
1724+ "isExpression": false,
1725+ "asc": true,
1726+ "nulls": "last"
1727+ }
1728+ ],
1729+ "isUnique": false,
1730+ "concurrently": false,
1731+ "method": "btree",
1732+ "with": {}
1733+ }
1734+ },
1735+ "foreignKeys": {
1736+ "leaderboards_user_id_users_id_fk": {
1737+ "name": "leaderboards_user_id_users_id_fk",
1738+ "tableFrom": "leaderboards",
1739+ "tableTo": "users",
1740+ "columnsFrom": [
1741+ "user_id"
1742+ ],
1743+ "columnsTo": [
1744+ "id"
1745+ ],
1746+ "onDelete": "cascade",
1747+ "onUpdate": "no action"
1748+ }
1749+ },
1750+ "compositePrimaryKeys": {},
1751+ "uniqueConstraints": {},
1752+ "policies": {},
1753+ "checkConstraints": {},
1754+ "isRLSEnabled": false
1755+ },
1756+ "public.missions": {
1757+ "name": "missions",
1758+ "schema": "",
1759+ "columns": {
1760+ "key": {
1761+ "name": "key",
1762+ "type": "varchar(48)",
1763+ "primaryKey": true,
1764+ "notNull": true
1765+ },
1766+ "name": {
1767+ "name": "name",
1768+ "type": "varchar(80)",
1769+ "primaryKey": false,
1770+ "notNull": true
1771+ },
1772+ "description": {
1773+ "name": "description",
1774+ "type": "text",
1775+ "primaryKey": false,
1776+ "notNull": true
1777+ },
1778+ "period": {
1779+ "name": "period",
1780+ "type": "varchar(8)",
1781+ "primaryKey": false,
1782+ "notNull": true
1783+ },
1784+ "metric": {
1785+ "name": "metric",
1786+ "type": "varchar(32)",
1787+ "primaryKey": false,
1788+ "notNull": true
1789+ },
1790+ "target": {
1791+ "name": "target",
1792+ "type": "bigint",
1793+ "primaryKey": false,
1794+ "notNull": true
1795+ },
1796+ "reward_credits": {
1797+ "name": "reward_credits",
1798+ "type": "integer",
1799+ "primaryKey": false,
1800+ "notNull": true,
1801+ "default": 0
1802+ },
1803+ "reward_xp": {
1804+ "name": "reward_xp",
1805+ "type": "integer",
1806+ "primaryKey": false,
1807+ "notNull": true,
1808+ "default": 0
1809+ },
1810+ "enabled": {
1811+ "name": "enabled",
1812+ "type": "boolean",
1813+ "primaryKey": false,
1814+ "notNull": true,
1815+ "default": true
1816+ },
1817+ "sort_order": {
1818+ "name": "sort_order",
1819+ "type": "integer",
1820+ "primaryKey": false,
1821+ "notNull": true,
1822+ "default": 0
1823+ }
1824+ },
1825+ "indexes": {},
1826+ "foreignKeys": {},
1827+ "compositePrimaryKeys": {},
1828+ "uniqueConstraints": {},
1829+ "policies": {},
1830+ "checkConstraints": {},
1831+ "isRLSEnabled": false
1832+ },
1833+ "public.platform_settings": {
1834+ "name": "platform_settings",
1835+ "schema": "",
1836+ "columns": {
1837+ "key": {
1838+ "name": "key",
1839+ "type": "varchar(64)",
1840+ "primaryKey": true,
1841+ "notNull": true
1842+ },
1843+ "value": {
1844+ "name": "value",
1845+ "type": "jsonb",
1846+ "primaryKey": false,
1847+ "notNull": true
1848+ },
1849+ "updated_at": {
1850+ "name": "updated_at",
1851+ "type": "timestamp with time zone",
1852+ "primaryKey": false,
1853+ "notNull": true,
1854+ "default": "now()"
1855+ }
1856+ },
1857+ "indexes": {},
1858+ "foreignKeys": {},
1859+ "compositePrimaryKeys": {},
1860+ "uniqueConstraints": {},
1861+ "policies": {},
1862+ "checkConstraints": {},
1863+ "isRLSEnabled": false
1864+ },
1865+ "public.player_levels": {
1866+ "name": "player_levels",
1867+ "schema": "",
1868+ "columns": {
1869+ "level": {
1870+ "name": "level",
1871+ "type": "integer",
1872+ "primaryKey": true,
1873+ "notNull": true
1874+ },
1875+ "xp_required": {
1876+ "name": "xp_required",
1877+ "type": "bigint",
1878+ "primaryKey": false,
1879+ "notNull": true
1880+ },
1881+ "xp_cumulative": {
1882+ "name": "xp_cumulative",
1883+ "type": "bigint",
1884+ "primaryKey": false,
1885+ "notNull": true
1886+ },
1887+ "reward_credits": {
1888+ "name": "reward_credits",
1889+ "type": "integer",
1890+ "primaryKey": false,
1891+ "notNull": true
1892+ },
1893+ "title": {
1894+ "name": "title",
1895+ "type": "varchar(48)",
1896+ "primaryKey": false,
1897+ "notNull": true
1898+ }
1899+ },
1900+ "indexes": {},
1901+ "foreignKeys": {},
1902+ "compositePrimaryKeys": {},
1903+ "uniqueConstraints": {},
1904+ "policies": {},
1905+ "checkConstraints": {},
1906+ "isRLSEnabled": false
1907+ },
1908+ "public.recovery_codes": {
1909+ "name": "recovery_codes",
1910+ "schema": "",
1911+ "columns": {
1912+ "user_id": {
1913+ "name": "user_id",
1914+ "type": "uuid",
1915+ "primaryKey": true,
1916+ "notNull": true
1917+ },
1918+ "code_hash": {
1919+ "name": "code_hash",
1920+ "type": "text",
1921+ "primaryKey": false,
1922+ "notNull": true
1923+ },
1924+ "created_at": {
1925+ "name": "created_at",
1926+ "type": "timestamp with time zone",
1927+ "primaryKey": false,
1928+ "notNull": true,
1929+ "default": "now()"
1930+ },
1931+ "rotated_at": {
1932+ "name": "rotated_at",
1933+ "type": "timestamp with time zone",
1934+ "primaryKey": false,
1935+ "notNull": false
1936+ },
1937+ "used_at": {
1938+ "name": "used_at",
1939+ "type": "timestamp with time zone",
1940+ "primaryKey": false,
1941+ "notNull": false
1942+ },
1943+ "use_count": {
1944+ "name": "use_count",
1945+ "type": "integer",
1946+ "primaryKey": false,
1947+ "notNull": true,
1948+ "default": 0
1949+ }
1950+ },
1951+ "indexes": {},
1952+ "foreignKeys": {
1953+ "recovery_codes_user_id_users_id_fk": {
1954+ "name": "recovery_codes_user_id_users_id_fk",
1955+ "tableFrom": "recovery_codes",
1956+ "tableTo": "users",
1957+ "columnsFrom": [
1958+ "user_id"
1959+ ],
1960+ "columnsTo": [
1961+ "id"
1962+ ],
1963+ "onDelete": "cascade",
1964+ "onUpdate": "no action"
1965+ }
1966+ },
1967+ "compositePrimaryKeys": {},
1968+ "uniqueConstraints": {},
1969+ "policies": {},
1970+ "checkConstraints": {},
1971+ "isRLSEnabled": false
1972+ },
1973+ "public.security_events": {
1974+ "name": "security_events",
1975+ "schema": "",
1976+ "columns": {
1977+ "id": {
1978+ "name": "id",
1979+ "type": "uuid",
1980+ "primaryKey": true,
1981+ "notNull": true,
1982+ "default": "gen_random_uuid()"
1983+ },
1984+ "user_id": {
1985+ "name": "user_id",
1986+ "type": "uuid",
1987+ "primaryKey": false,
1988+ "notNull": false
1989+ },
1990+ "admin_id": {
1991+ "name": "admin_id",
1992+ "type": "uuid",
1993+ "primaryKey": false,
1994+ "notNull": false
1995+ },
1996+ "type": {
1997+ "name": "type",
1998+ "type": "varchar(48)",
1999+ "primaryKey": false,
2000+ "notNull": true
2001+ },
2002+ "severity": {
2003+ "name": "severity",
2004+ "type": "varchar(8)",
2005+ "primaryKey": false,
2006+ "notNull": true,
2007+ "default": "'info'"
2008+ },
2009+ "ip": {
2010+ "name": "ip",
2011+ "type": "varchar(64)",
2012+ "primaryKey": false,
2013+ "notNull": false
2014+ },
2015+ "user_agent": {
2016+ "name": "user_agent",
2017+ "type": "text",
2018+ "primaryKey": false,
2019+ "notNull": false
2020+ },
2021+ "meta": {
2022+ "name": "meta",
2023+ "type": "jsonb",
2024+ "primaryKey": false,
2025+ "notNull": false
2026+ },
2027+ "created_at": {
2028+ "name": "created_at",
2029+ "type": "timestamp with time zone",
2030+ "primaryKey": false,
2031+ "notNull": true,
2032+ "default": "now()"
2033+ }
2034+ },
2035+ "indexes": {
2036+ "security_events_created_idx": {
2037+ "name": "security_events_created_idx",
2038+ "columns": [
2039+ {
2040+ "expression": "created_at",
2041+ "isExpression": false,
2042+ "asc": true,
2043+ "nulls": "last"
2044+ }
2045+ ],
2046+ "isUnique": false,
2047+ "concurrently": false,
2048+ "method": "btree",
2049+ "with": {}
2050+ },
2051+ "security_events_type_idx": {
2052+ "name": "security_events_type_idx",
2053+ "columns": [
2054+ {
2055+ "expression": "type",
2056+ "isExpression": false,
2057+ "asc": true,
2058+ "nulls": "last"
2059+ }
2060+ ],
2061+ "isUnique": false,
2062+ "concurrently": false,
2063+ "method": "btree",
2064+ "with": {}
2065+ },
2066+ "security_events_user_idx": {
2067+ "name": "security_events_user_idx",
2068+ "columns": [
2069+ {
2070+ "expression": "user_id",
2071+ "isExpression": false,
2072+ "asc": true,
2073+ "nulls": "last"
2074+ }
2075+ ],
2076+ "isUnique": false,
2077+ "concurrently": false,
2078+ "method": "btree",
2079+ "with": {}
2080+ }
2081+ },
2082+ "foreignKeys": {
2083+ "security_events_user_id_users_id_fk": {
2084+ "name": "security_events_user_id_users_id_fk",
2085+ "tableFrom": "security_events",
2086+ "tableTo": "users",
2087+ "columnsFrom": [
2088+ "user_id"
2089+ ],
2090+ "columnsTo": [
2091+ "id"
2092+ ],
2093+ "onDelete": "set null",
2094+ "onUpdate": "no action"
2095+ }
2096+ },
2097+ "compositePrimaryKeys": {},
2098+ "uniqueConstraints": {},
2099+ "policies": {},
2100+ "checkConstraints": {},
2101+ "isRLSEnabled": false
2102+ },
2103+ "public.sessions": {
2104+ "name": "sessions",
2105+ "schema": "",
2106+ "columns": {
2107+ "id": {
2108+ "name": "id",
2109+ "type": "uuid",
2110+ "primaryKey": true,
2111+ "notNull": true,
2112+ "default": "gen_random_uuid()"
2113+ },
2114+ "user_id": {
2115+ "name": "user_id",
2116+ "type": "uuid",
2117+ "primaryKey": false,
2118+ "notNull": true
2119+ },
2120+ "token_hash": {
2121+ "name": "token_hash",
2122+ "type": "text",
2123+ "primaryKey": false,
2124+ "notNull": true
2125+ },
2126+ "created_at": {
2127+ "name": "created_at",
2128+ "type": "timestamp with time zone",
2129+ "primaryKey": false,
2130+ "notNull": true,
2131+ "default": "now()"
2132+ },
2133+ "expires_at": {
2134+ "name": "expires_at",
2135+ "type": "timestamp with time zone",
2136+ "primaryKey": false,
2137+ "notNull": true
2138+ },
2139+ "last_seen_at": {
2140+ "name": "last_seen_at",
2141+ "type": "timestamp with time zone",
2142+ "primaryKey": false,
2143+ "notNull": true,
2144+ "default": "now()"
2145+ },
2146+ "user_agent": {
2147+ "name": "user_agent",
2148+ "type": "text",
2149+ "primaryKey": false,
2150+ "notNull": false
2151+ },
2152+ "ip": {
2153+ "name": "ip",
2154+ "type": "varchar(64)",
2155+ "primaryKey": false,
2156+ "notNull": false
2157+ }
2158+ },
2159+ "indexes": {
2160+ "sessions_token_hash_idx": {
2161+ "name": "sessions_token_hash_idx",
2162+ "columns": [
2163+ {
2164+ "expression": "token_hash",
2165+ "isExpression": false,
2166+ "asc": true,
2167+ "nulls": "last"
2168+ }
2169+ ],
2170+ "isUnique": true,
2171+ "concurrently": false,
2172+ "method": "btree",
2173+ "with": {}
2174+ },
2175+ "sessions_user_idx": {
2176+ "name": "sessions_user_idx",
2177+ "columns": [
2178+ {
2179+ "expression": "user_id",
2180+ "isExpression": false,
2181+ "asc": true,
2182+ "nulls": "last"
2183+ }
2184+ ],
2185+ "isUnique": false,
2186+ "concurrently": false,
2187+ "method": "btree",
2188+ "with": {}
2189+ }
2190+ },
2191+ "foreignKeys": {
2192+ "sessions_user_id_users_id_fk": {
2193+ "name": "sessions_user_id_users_id_fk",
2194+ "tableFrom": "sessions",
2195+ "tableTo": "users",
2196+ "columnsFrom": [
2197+ "user_id"
2198+ ],
2199+ "columnsTo": [
2200+ "id"
2201+ ],
2202+ "onDelete": "cascade",
2203+ "onUpdate": "no action"
2204+ }
2205+ },
2206+ "compositePrimaryKeys": {},
2207+ "uniqueConstraints": {},
2208+ "policies": {},
2209+ "checkConstraints": {},
2210+ "isRLSEnabled": false
2211+ },
2212+ "public.simulation_runs": {
2213+ "name": "simulation_runs",
2214+ "schema": "",
2215+ "columns": {
2216+ "id": {
2217+ "name": "id",
2218+ "type": "uuid",
2219+ "primaryKey": true,
2220+ "notNull": true,
2221+ "default": "gen_random_uuid()"
2222+ },
2223+ "game_slug": {
2224+ "name": "game_slug",
2225+ "type": "varchar(64)",
2226+ "primaryKey": false,
2227+ "notNull": true
2228+ },
2229+ "game_version": {
2230+ "name": "game_version",
2231+ "type": "varchar(16)",
2232+ "primaryKey": false,
2233+ "notNull": true
2234+ },
2235+ "spins": {
2236+ "name": "spins",
2237+ "type": "bigint",
2238+ "primaryKey": false,
2239+ "notNull": true
2240+ },
2241+ "status": {
2242+ "name": "status",
2243+ "type": "varchar(16)",
2244+ "primaryKey": false,
2245+ "notNull": true,
2246+ "default": "'running'"
2247+ },
2248+ "progress": {
2249+ "name": "progress",
2250+ "type": "bigint",
2251+ "primaryKey": false,
2252+ "notNull": true,
2253+ "default": 0
2254+ },
2255+ "result": {
2256+ "name": "result",
2257+ "type": "jsonb",
2258+ "primaryKey": false,
2259+ "notNull": false
2260+ },
2261+ "error": {
2262+ "name": "error",
2263+ "type": "text",
2264+ "primaryKey": false,
2265+ "notNull": false
2266+ },
2267+ "requested_by": {
2268+ "name": "requested_by",
2269+ "type": "uuid",
2270+ "primaryKey": false,
2271+ "notNull": false
2272+ },
2273+ "created_at": {
2274+ "name": "created_at",
2275+ "type": "timestamp with time zone",
2276+ "primaryKey": false,
2277+ "notNull": true,
2278+ "default": "now()"
2279+ },
2280+ "finished_at": {
2281+ "name": "finished_at",
2282+ "type": "timestamp with time zone",
2283+ "primaryKey": false,
2284+ "notNull": false
2285+ }
2286+ },
2287+ "indexes": {
2288+ "simulation_runs_game_idx": {
2289+ "name": "simulation_runs_game_idx",
2290+ "columns": [
2291+ {
2292+ "expression": "game_slug",
2293+ "isExpression": false,
2294+ "asc": true,
2295+ "nulls": "last"
2296+ },
2297+ {
2298+ "expression": "created_at",
2299+ "isExpression": false,
2300+ "asc": true,
2301+ "nulls": "last"
2302+ }
2303+ ],
2304+ "isUnique": false,
2305+ "concurrently": false,
2306+ "method": "btree",
2307+ "with": {}
2308+ }
2309+ },
2310+ "foreignKeys": {},
2311+ "compositePrimaryKeys": {},
2312+ "uniqueConstraints": {},
2313+ "policies": {},
2314+ "checkConstraints": {},
2315+ "isRLSEnabled": false
2316+ },
2317+ "public.user_achievements": {
2318+ "name": "user_achievements",
2319+ "schema": "",
2320+ "columns": {
2321+ "user_id": {
2322+ "name": "user_id",
2323+ "type": "uuid",
2324+ "primaryKey": false,
2325+ "notNull": true
2326+ },
2327+ "achievement_key": {
2328+ "name": "achievement_key",
2329+ "type": "varchar(48)",
2330+ "primaryKey": false,
2331+ "notNull": true
2332+ },
2333+ "unlocked_at": {
2334+ "name": "unlocked_at",
2335+ "type": "timestamp with time zone",
2336+ "primaryKey": false,
2337+ "notNull": true,
2338+ "default": "now()"
2339+ }
2340+ },
2341+ "indexes": {},
2342+ "foreignKeys": {
2343+ "user_achievements_user_id_users_id_fk": {
2344+ "name": "user_achievements_user_id_users_id_fk",
2345+ "tableFrom": "user_achievements",
2346+ "tableTo": "users",
2347+ "columnsFrom": [
2348+ "user_id"
2349+ ],
2350+ "columnsTo": [
2351+ "id"
2352+ ],
2353+ "onDelete": "cascade",
2354+ "onUpdate": "no action"
2355+ },
2356+ "user_achievements_achievement_key_achievements_key_fk": {
2357+ "name": "user_achievements_achievement_key_achievements_key_fk",
2358+ "tableFrom": "user_achievements",
2359+ "tableTo": "achievements",
2360+ "columnsFrom": [
2361+ "achievement_key"
2362+ ],
2363+ "columnsTo": [
2364+ "key"
2365+ ],
2366+ "onDelete": "cascade",
2367+ "onUpdate": "no action"
2368+ }
2369+ },
2370+ "compositePrimaryKeys": {
2371+ "user_achievements_user_id_achievement_key_pk": {
2372+ "name": "user_achievements_user_id_achievement_key_pk",
2373+ "columns": [
2374+ "user_id",
2375+ "achievement_key"
2376+ ]
2377+ }
2378+ },
2379+ "uniqueConstraints": {},
2380+ "policies": {},
2381+ "checkConstraints": {},
2382+ "isRLSEnabled": false
2383+ },
2384+ "public.user_game_stats": {
2385+ "name": "user_game_stats",
2386+ "schema": "",
2387+ "columns": {
2388+ "user_id": {
2389+ "name": "user_id",
2390+ "type": "uuid",
2391+ "primaryKey": false,
2392+ "notNull": true
2393+ },
2394+ "game_id": {
2395+ "name": "game_id",
2396+ "type": "uuid",
2397+ "primaryKey": false,
2398+ "notNull": true
2399+ },
2400+ "spins": {
2401+ "name": "spins",
2402+ "type": "bigint",
2403+ "primaryKey": false,
2404+ "notNull": true,
2405+ "default": 0
2406+ },
2407+ "wagered": {
2408+ "name": "wagered",
2409+ "type": "bigint",
2410+ "primaryKey": false,
2411+ "notNull": true,
2412+ "default": 0
2413+ },
2414+ "won": {
2415+ "name": "won",
2416+ "type": "bigint",
2417+ "primaryKey": false,
2418+ "notNull": true,
2419+ "default": 0
2420+ },
2421+ "bonuses": {
2422+ "name": "bonuses",
2423+ "type": "integer",
2424+ "primaryKey": false,
2425+ "notNull": true,
2426+ "default": 0
2427+ },
2428+ "biggest_win": {
2429+ "name": "biggest_win",
2430+ "type": "bigint",
2431+ "primaryKey": false,
2432+ "notNull": true,
2433+ "default": 0
2434+ },
2435+ "biggest_multiplier": {
2436+ "name": "biggest_multiplier",
2437+ "type": "numeric(12, 2)",
2438+ "primaryKey": false,
2439+ "notNull": true,
2440+ "default": "'0'"
2441+ },
2442+ "last_played_at": {
2443+ "name": "last_played_at",
2444+ "type": "timestamp with time zone",
2445+ "primaryKey": false,
2446+ "notNull": true,
2447+ "default": "now()"
2448+ },
2449+ "first_played_at": {
2450+ "name": "first_played_at",
2451+ "type": "timestamp with time zone",
2452+ "primaryKey": false,
2453+ "notNull": true,
2454+ "default": "now()"
2455+ }
2456+ },
2457+ "indexes": {
2458+ "ugs_user_last_idx": {
2459+ "name": "ugs_user_last_idx",
2460+ "columns": [
2461+ {
2462+ "expression": "user_id",
2463+ "isExpression": false,
2464+ "asc": true,
2465+ "nulls": "last"
2466+ },
2467+ {
2468+ "expression": "last_played_at",
2469+ "isExpression": false,
2470+ "asc": true,
2471+ "nulls": "last"
2472+ }
2473+ ],
2474+ "isUnique": false,
2475+ "concurrently": false,
2476+ "method": "btree",
2477+ "with": {}
2478+ }
2479+ },
2480+ "foreignKeys": {
2481+ "user_game_stats_user_id_users_id_fk": {
2482+ "name": "user_game_stats_user_id_users_id_fk",
2483+ "tableFrom": "user_game_stats",
2484+ "tableTo": "users",
2485+ "columnsFrom": [
2486+ "user_id"
2487+ ],
2488+ "columnsTo": [
2489+ "id"
2490+ ],
2491+ "onDelete": "cascade",
2492+ "onUpdate": "no action"
2493+ },
2494+ "user_game_stats_game_id_games_id_fk": {
2495+ "name": "user_game_stats_game_id_games_id_fk",
2496+ "tableFrom": "user_game_stats",
2497+ "tableTo": "games",
2498+ "columnsFrom": [
2499+ "game_id"
2500+ ],
2501+ "columnsTo": [
2502+ "id"
2503+ ],
2504+ "onDelete": "cascade",
2505+ "onUpdate": "no action"
2506+ }
2507+ },
2508+ "compositePrimaryKeys": {
2509+ "user_game_stats_user_id_game_id_pk": {
2510+ "name": "user_game_stats_user_id_game_id_pk",
2511+ "columns": [
2512+ "user_id",
2513+ "game_id"
2514+ ]
2515+ }
2516+ },
2517+ "uniqueConstraints": {},
2518+ "policies": {},
2519+ "checkConstraints": {},
2520+ "isRLSEnabled": false
2521+ },
2522+ "public.user_missions": {
2523+ "name": "user_missions",
2524+ "schema": "",
2525+ "columns": {
2526+ "id": {
2527+ "name": "id",
2528+ "type": "uuid",
2529+ "primaryKey": true,
2530+ "notNull": true,
2531+ "default": "gen_random_uuid()"
2532+ },
2533+ "user_id": {
2534+ "name": "user_id",
2535+ "type": "uuid",
2536+ "primaryKey": false,
2537+ "notNull": true
2538+ },
2539+ "mission_key": {
2540+ "name": "mission_key",
2541+ "type": "varchar(48)",
2542+ "primaryKey": false,
2543+ "notNull": true
2544+ },
2545+ "period_key": {
2546+ "name": "period_key",
2547+ "type": "varchar(16)",
2548+ "primaryKey": false,
2549+ "notNull": true
2550+ },
2551+ "progress": {
2552+ "name": "progress",
2553+ "type": "bigint",
2554+ "primaryKey": false,
2555+ "notNull": true,
2556+ "default": 0
2557+ },
2558+ "progress_set": {
2559+ "name": "progress_set",
2560+ "type": "text[]",
2561+ "primaryKey": false,
2562+ "notNull": true,
2563+ "default": "'{}'::text[]"
2564+ },
2565+ "completed_at": {
2566+ "name": "completed_at",
2567+ "type": "timestamp with time zone",
2568+ "primaryKey": false,
2569+ "notNull": false
2570+ },
2571+ "claimed_at": {
2572+ "name": "claimed_at",
2573+ "type": "timestamp with time zone",
2574+ "primaryKey": false,
2575+ "notNull": false
2576+ },
2577+ "expires_at": {
2578+ "name": "expires_at",
2579+ "type": "timestamp with time zone",
2580+ "primaryKey": false,
2581+ "notNull": true
2582+ }
2583+ },
2584+ "indexes": {
2585+ "user_missions_unique_idx": {
2586+ "name": "user_missions_unique_idx",
2587+ "columns": [
2588+ {
2589+ "expression": "user_id",
2590+ "isExpression": false,
2591+ "asc": true,
2592+ "nulls": "last"
2593+ },
2594+ {
2595+ "expression": "mission_key",
2596+ "isExpression": false,
2597+ "asc": true,
2598+ "nulls": "last"
2599+ },
2600+ {
2601+ "expression": "period_key",
2602+ "isExpression": false,
2603+ "asc": true,
2604+ "nulls": "last"
2605+ }
2606+ ],
2607+ "isUnique": true,
2608+ "concurrently": false,
2609+ "method": "btree",
2610+ "with": {}
2611+ },
2612+ "user_missions_user_idx": {
2613+ "name": "user_missions_user_idx",
2614+ "columns": [
2615+ {
2616+ "expression": "user_id",
2617+ "isExpression": false,
2618+ "asc": true,
2619+ "nulls": "last"
2620+ }
2621+ ],
2622+ "isUnique": false,
2623+ "concurrently": false,
2624+ "method": "btree",
2625+ "with": {}
2626+ }
2627+ },
2628+ "foreignKeys": {
2629+ "user_missions_user_id_users_id_fk": {
2630+ "name": "user_missions_user_id_users_id_fk",
2631+ "tableFrom": "user_missions",
2632+ "tableTo": "users",
2633+ "columnsFrom": [
2634+ "user_id"
2635+ ],
2636+ "columnsTo": [
2637+ "id"
2638+ ],
2639+ "onDelete": "cascade",
2640+ "onUpdate": "no action"
2641+ },
2642+ "user_missions_mission_key_missions_key_fk": {
2643+ "name": "user_missions_mission_key_missions_key_fk",
2644+ "tableFrom": "user_missions",
2645+ "tableTo": "missions",
2646+ "columnsFrom": [
2647+ "mission_key"
2648+ ],
2649+ "columnsTo": [
2650+ "key"
2651+ ],
2652+ "onDelete": "cascade",
2653+ "onUpdate": "no action"
2654+ }
2655+ },
2656+ "compositePrimaryKeys": {},
2657+ "uniqueConstraints": {},
2658+ "policies": {},
2659+ "checkConstraints": {},
2660+ "isRLSEnabled": false
2661+ },
2662+ "public.user_settings": {
2663+ "name": "user_settings",
2664+ "schema": "",
2665+ "columns": {
2666+ "user_id": {
2667+ "name": "user_id",
2668+ "type": "uuid",
2669+ "primaryKey": true,
2670+ "notNull": true
2671+ },
2672+ "sound_enabled": {
2673+ "name": "sound_enabled",
2674+ "type": "boolean",
2675+ "primaryKey": false,
2676+ "notNull": true,
2677+ "default": true
2678+ },
2679+ "music_volume": {
2680+ "name": "music_volume",
2681+ "type": "numeric(3, 2)",
2682+ "primaryKey": false,
2683+ "notNull": true,
2684+ "default": "'0.6'"
2685+ },
2686+ "effects_volume": {
2687+ "name": "effects_volume",
2688+ "type": "numeric(3, 2)",
2689+ "primaryKey": false,
2690+ "notNull": true,
2691+ "default": "'0.8'"
2692+ },
2693+ "master_volume": {
2694+ "name": "master_volume",
2695+ "type": "numeric(3, 2)",
2696+ "primaryKey": false,
2697+ "notNull": true,
2698+ "default": "'0.8'"
2699+ },
2700+ "reduce_motion": {
2701+ "name": "reduce_motion",
2702+ "type": "boolean",
2703+ "primaryKey": false,
2704+ "notNull": true,
2705+ "default": false
2706+ },
2707+ "animation_intensity": {
2708+ "name": "animation_intensity",
2709+ "type": "varchar(8)",
2710+ "primaryKey": false,
2711+ "notNull": true,
2712+ "default": "'high'"
2713+ },
2714+ "session_reminder_minutes": {
2715+ "name": "session_reminder_minutes",
2716+ "type": "integer",
2717+ "primaryKey": false,
2718+ "notNull": true,
2719+ "default": 60
2720+ },
2721+ "break_reminder": {
2722+ "name": "break_reminder",
2723+ "type": "boolean",
2724+ "primaryKey": false,
2725+ "notNull": true,
2726+ "default": true
2727+ },
2728+ "leaderboard_opt_in": {
2729+ "name": "leaderboard_opt_in",
2730+ "type": "boolean",
2731+ "primaryKey": false,
2732+ "notNull": true,
2733+ "default": true
2734+ },
2735+ "updated_at": {
2736+ "name": "updated_at",
2737+ "type": "timestamp with time zone",
2738+ "primaryKey": false,
2739+ "notNull": true,
2740+ "default": "now()"
2741+ }
2742+ },
2743+ "indexes": {},
2744+ "foreignKeys": {
2745+ "user_settings_user_id_users_id_fk": {
2746+ "name": "user_settings_user_id_users_id_fk",
2747+ "tableFrom": "user_settings",
2748+ "tableTo": "users",
2749+ "columnsFrom": [
2750+ "user_id"
2751+ ],
2752+ "columnsTo": [
2753+ "id"
2754+ ],
2755+ "onDelete": "cascade",
2756+ "onUpdate": "no action"
2757+ }
2758+ },
2759+ "compositePrimaryKeys": {},
2760+ "uniqueConstraints": {},
2761+ "policies": {},
2762+ "checkConstraints": {},
2763+ "isRLSEnabled": false
2764+ },
2765+ "public.users": {
2766+ "name": "users",
2767+ "schema": "",
2768+ "columns": {
2769+ "id": {
2770+ "name": "id",
2771+ "type": "uuid",
2772+ "primaryKey": true,
2773+ "notNull": true,
2774+ "default": "gen_random_uuid()"
2775+ },
2776+ "username": {
2777+ "name": "username",
2778+ "type": "varchar(24)",
2779+ "primaryKey": false,
2780+ "notNull": true
2781+ },
2782+ "username_normalized": {
2783+ "name": "username_normalized",
2784+ "type": "varchar(24)",
2785+ "primaryKey": false,
2786+ "notNull": true
2787+ },
2788+ "password_hash": {
2789+ "name": "password_hash",
2790+ "type": "text",
2791+ "primaryKey": false,
2792+ "notNull": true
2793+ },
2794+ "level": {
2795+ "name": "level",
2796+ "type": "integer",
2797+ "primaryKey": false,
2798+ "notNull": true,
2799+ "default": 1
2800+ },
2801+ "xp": {
2802+ "name": "xp",
2803+ "type": "bigint",
2804+ "primaryKey": false,
2805+ "notNull": true,
2806+ "default": 0
2807+ },
2808+ "status": {
2809+ "name": "status",
2810+ "type": "varchar(16)",
2811+ "primaryKey": false,
2812+ "notNull": true,
2813+ "default": "'active'"
2814+ },
2815+ "age_confirmed_at": {
2816+ "name": "age_confirmed_at",
2817+ "type": "timestamp with time zone",
2818+ "primaryKey": false,
2819+ "notNull": false
2820+ },
2821+ "created_at": {
2822+ "name": "created_at",
2823+ "type": "timestamp with time zone",
2824+ "primaryKey": false,
2825+ "notNull": true,
2826+ "default": "now()"
2827+ },
2828+ "last_login_at": {
2829+ "name": "last_login_at",
2830+ "type": "timestamp with time zone",
2831+ "primaryKey": false,
2832+ "notNull": false
2833+ },
2834+ "last_rescue_at": {
2835+ "name": "last_rescue_at",
2836+ "type": "timestamp with time zone",
2837+ "primaryKey": false,
2838+ "notNull": false
2839+ },
2840+ "total_spins": {
2841+ "name": "total_spins",
2842+ "type": "bigint",
2843+ "primaryKey": false,
2844+ "notNull": true,
2845+ "default": 0
2846+ },
2847+ "games_played": {
2848+ "name": "games_played",
2849+ "type": "integer",
2850+ "primaryKey": false,
2851+ "notNull": true,
2852+ "default": 0
2853+ },
2854+ "biggest_win": {
2855+ "name": "biggest_win",
2856+ "type": "bigint",
2857+ "primaryKey": false,
2858+ "notNull": true,
2859+ "default": 0
2860+ },
2861+ "biggest_multiplier": {
2862+ "name": "biggest_multiplier",
2863+ "type": "numeric(12, 2)",
2864+ "primaryKey": false,
2865+ "notNull": true,
2866+ "default": "'0'"
2867+ }
2868+ },
2869+ "indexes": {
2870+ "users_username_normalized_idx": {
2871+ "name": "users_username_normalized_idx",
2872+ "columns": [
2873+ {
2874+ "expression": "username_normalized",
2875+ "isExpression": false,
2876+ "asc": true,
2877+ "nulls": "last"
2878+ }
2879+ ],
2880+ "isUnique": true,
2881+ "concurrently": false,
2882+ "method": "btree",
2883+ "with": {}
2884+ },
2885+ "users_username_idx": {
2886+ "name": "users_username_idx",
2887+ "columns": [
2888+ {
2889+ "expression": "username",
2890+ "isExpression": false,
2891+ "asc": true,
2892+ "nulls": "last"
2893+ }
2894+ ],
2895+ "isUnique": true,
2896+ "concurrently": false,
2897+ "method": "btree",
2898+ "with": {}
2899+ }
2900+ },
2901+ "foreignKeys": {},
2902+ "compositePrimaryKeys": {},
2903+ "uniqueConstraints": {},
2904+ "policies": {},
2905+ "checkConstraints": {},
2906+ "isRLSEnabled": false
2907+ },
2908+ "public.wallets": {
2909+ "name": "wallets",
2910+ "schema": "",
2911+ "columns": {
2912+ "user_id": {
2913+ "name": "user_id",
2914+ "type": "uuid",
2915+ "primaryKey": true,
2916+ "notNull": true
2917+ },
2918+ "balance": {
2919+ "name": "balance",
2920+ "type": "bigint",
2921+ "primaryKey": false,
2922+ "notNull": true,
2923+ "default": 0
2924+ },
2925+ "lifetime_wagered": {
2926+ "name": "lifetime_wagered",
2927+ "type": "bigint",
2928+ "primaryKey": false,
2929+ "notNull": true,
2930+ "default": 0
2931+ },
2932+ "lifetime_won": {
2933+ "name": "lifetime_won",
2934+ "type": "bigint",
2935+ "primaryKey": false,
2936+ "notNull": true,
2937+ "default": 0
2938+ },
2939+ "lifetime_granted": {
2940+ "name": "lifetime_granted",
2941+ "type": "bigint",
2942+ "primaryKey": false,
2943+ "notNull": true,
2944+ "default": 0
2945+ },
2946+ "updated_at": {
2947+ "name": "updated_at",
2948+ "type": "timestamp with time zone",
2949+ "primaryKey": false,
2950+ "notNull": true,
2951+ "default": "now()"
2952+ }
2953+ },
2954+ "indexes": {},
2955+ "foreignKeys": {
2956+ "wallets_user_id_users_id_fk": {
2957+ "name": "wallets_user_id_users_id_fk",
2958+ "tableFrom": "wallets",
2959+ "tableTo": "users",
2960+ "columnsFrom": [
2961+ "user_id"
2962+ ],
2963+ "columnsTo": [
2964+ "id"
2965+ ],
2966+ "onDelete": "cascade",
2967+ "onUpdate": "no action"
2968+ }
2969+ },
2970+ "compositePrimaryKeys": {},
2971+ "uniqueConstraints": {},
2972+ "policies": {},
2973+ "checkConstraints": {},
2974+ "isRLSEnabled": false
2975+ }
2976+ },
2977+ "enums": {},
2978+ "schemas": {},
2979+ "sequences": {},
2980+ "roles": {},
2981+ "policies": {},
2982+ "views": {},
2983+ "_meta": {
2984+ "columns": {},
2985+ "schemas": {},
2986+ "tables": {}
2987+ }
2988+}
\ No newline at end of file
modified packages/database/drizzle/meta/_journal.json +7 −0
@@ -15,6 +15,13 @@
15 "when": 1788836033256, 15 "when": 1788836033256,
16 "tag": "0001_lumpy_hulk", 16 "tag": "0001_lumpy_hulk",
17 "breakpoints": true 17 "breakpoints": true
18+ },
19+ {
20+ "idx": 2,
21+ "version": "7",
22+ "when": 1788837686777,
23+ "tag": "0002_nosy_giant_man",
24+ "breakpoints": true
18 } 25 }
19 ] 26 ]
20 } 27 }
\ No newline at end of file
modified packages/database/src/schema.ts +26 −0
@@ -206,6 +206,32 @@ export const crashRounds = pgTable(
206 ], 206 ],
207 ); 207 );
208 208
209+/** Ladder-game sessions (The Vault, Escape 99): step-by-step rounds with cash-out. Settled rounds also go to game_rounds. */
210+export const arcadeSessions = pgTable(
211+ "arcade_sessions",
212+ {
213+ id: uuid().primaryKey().defaultRandom(),
214+ roundId: varchar({ length: 32 }).notNull(),
215+ userId: uuid().notNull().references(() => users.id, { onDelete: "cascade" }),
216+ gameId: uuid().notNull().references(() => games.id),
217+ gameSlug: varchar({ length: 64 }).notNull(),
218+ gameVersion: varchar({ length: 16 }).notNull(),
219+ clientRoundId: uuid().notNull(),
220+ bet: bigint({ mode: "number" }).notNull(),
221+ state: jsonb().$type<Record<string, unknown>>().notNull(),
222+ status: varchar({ length: 12 }).notNull().default("running"), // running | cashed | busted | completed
223+ win: bigint({ mode: "number" }).notNull().default(0),
224+ startedAt: now(),
225+ updatedAt: now(),
226+ settledAt: ts(),
227+ },
228+ (t) => [
229+ uniqueIndex("arcade_sessions_round_id_idx").on(t.roundId),
230+ uniqueIndex("arcade_sessions_user_client_idx").on(t.userId, t.clientRoundId),
231+ index("arcade_sessions_user_status_idx").on(t.userId, t.status),
232+ ],
233+);
234+
209 export const gameStatistics = pgTable("game_statistics", { 235 export const gameStatistics = pgTable("game_statistics", {
210 gameId: uuid().primaryKey().references(() => games.id, { onDelete: "cascade" }), 236 gameId: uuid().primaryKey().references(() => games.id, { onDelete: "cascade" }),
211 launches: bigint({ mode: "number" }).notNull().default(0), 237 launches: bigint({ mode: "number" }).notNull().default(0),
modified packages/database/src/sync-games.ts +46 −2
@@ -2,8 +2,8 @@ import fs from "node:fs";
2 import path from "node:path"; 2 import path from "node:path";
3 import { createHash } from "node:crypto"; 3 import { createHash } from "node:crypto";
4 import { createRequire } from "node:module"; 4 import { createRequire } from "node:module";
5−import { GAMES, FEATURED_ORDER, CRASH_GAMES } from "@spinza/games"; 5+import { GAMES, FEATURED_ORDER, CRASH_GAMES, ARCADE_GAMES } from "@spinza/games";
6−import { validateDefinition, type CertificationReport, type GameDefinition, type CrashGameDefinition } from "@spinza/game-core"; 6+import { validateDefinition, type CertificationReport, type GameDefinition, type CrashGameDefinition, type ArcadeGameDefinition } from "@spinza/game-core";
7 import { eq, sql } from "drizzle-orm"; 7 import { eq, sql } from "drizzle-orm";
8 import type { Db } from "./index"; 8 import type { Db } from "./index";
9 import { gameStatistics, gameVersions, games } from "./schema"; 9 import { gameStatistics, gameVersions, games } from "./schema";
@@ -45,6 +45,28 @@ export function crashSummary(def: CrashGameDefinition) {
45 }; 45 };
46 } 46 }
47 47
48+export function arcadeSummary(def: ArcadeGameDefinition) {
49+ return {
50+ kind: "arcade",
51+ category: "originals",
52+ mode: def.mode,
53+ tagline: def.tagline,
54+ description: def.description,
55+ theme: def.theme,
56+ volatility: def.volatility,
57+ grid: { reels: 0, rows: 0 },
58+ minBet: def.minBet,
59+ maxBet: def.maxBet,
60+ maxMultiplier: def.maxMultiplier,
61+ features: def.featureNames,
62+ tags: def.tags,
63+ palette: def.presentation.palette,
64+ presentation: { frame: "glass", backdrop: def.presentation.scene, particles: "energy", ambience: def.presentation.ambience, scene: def.presentation.scene, verb: def.presentation.verb },
65+ isJackpot: def.slug === "the-vault",
66+ rtp: def.rtp,
67+ };
68+}
69+
48 export function publicSummary(def: GameDefinition) { 70 export function publicSummary(def: GameDefinition) {
49 return { 71 return {
50 kind: "slot", 72 kind: "slot",
@@ -168,5 +190,27 @@ export async function syncGames(db: Db, log: (s: string) => void = () => {}): Pr
168 reports.push({ slug: def.slug, version: def.version, lifecycle, certified }); 190 reports.push({ slug: def.slug, version: def.version, lifecycle, certified });
169 log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (crash) → ${lifecycle}`); 191 log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (crash) → ${lifecycle}`);
170 } 192 }
193+ for (const def of ARCADE_GAMES) {
194+ order++;
195+ const cert = loadCertification(def.slug);
196+ const certified = !!cert && cert.status === "PASS" && cert.version === def.version;
197+ const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) });
198+ let lifecycle = certified ? "published" : "simulation";
199+ if (existing?.lifecycle === "disabled") lifecycle = "disabled";
200+ const summary = { ...arcadeSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null };
201+ const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true;
202+ const [row] = await db
203+ .insert(games)
204+ .values({ slug: def.slug, name: def.name, version: def.version, lifecycle, sortOrder: order, isFeatured: false, isNew, summary, publishedAt: certified ? new Date() : null })
205+ .onConflictDoUpdate({ target: games.slug, set: { name: def.name, version: def.version, lifecycle, sortOrder: order, isNew, summary, publishedAt: certified ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date() } })
206+ .returning();
207+ await db
208+ .insert(gameVersions)
209+ .values({ gameId: row.id, version: def.version, definition: def as unknown as Record<string, unknown>, definitionHash: createHash("sha256").update(JSON.stringify(def)).digest("hex"), certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" })
210+ .onConflictDoUpdate({ target: [gameVersions.gameId, gameVersions.version], set: { definition: def as unknown as Record<string, unknown>, certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" } });
211+ await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing();
212+ reports.push({ slug: def.slug, version: def.version, lifecycle, certified });
213+ log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (arcade) → ${lifecycle}`);
214+ }
171 return reports; 215 return reports;
172 } 216 }
added packages/game-core/src/arcade/dropzone.ts +143 −0
@@ -0,0 +1,143 @@
1+import type { Rng } from "../rng";
2+import type { ArcadeGameDefinition, ArcadeOutcome } from "./types";
3+
4+/**
5+ * DROPZONE — a capsule falls through a tower of pegs (binomial random walk),
6+ * passing gates (×2 boosts) and portals (lane swaps), into a row of buckets.
7+ * Deep Drop: the tower extends and the capsule keeps falling into a riskier
8+ * second bucket row whose values multiply the first (0× included).
9+ * The player picks a risk profile (bucket table) and a start lane.
10+ */
11+
12+export interface DropzoneConfig {
13+ rows: number;
14+ lanes: number; // = rows + 1 buckets
15+ /** Bucket multipliers per risk profile, symmetric, length = lanes. */
16+ buckets: Record<"low" | "medium" | "high", number[]>;
17+ gateChancePerRow: number;
18+ gateValues: number[];
19+ portalChancePerRow: number;
20+ deepDropChance: number;
21+ deepRows: number;
22+ deepBuckets: number[];
23+}
24+
25+export interface DropzoneInput {
26+ risk: "low" | "medium" | "high";
27+ /** Start lane 0..lanes-1 (the capsule is released above this bucket column). */
28+ lane: number;
29+}
30+
31+export interface DropStep {
32+ row: number;
33+ /** Horizontal position after this row (in half-lane units, 0 = far left). */
34+ x: number;
35+ event?: { type: "gate"; value: number } | { type: "portal"; to: number };
36+}
37+
38+/** Exact end-bucket distribution for a start lane (binomial walk in half-lane units with edge clamping). */
39+export function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] {
40+ const width = cfg.lanes * 2;
41+ let dist = new Array(width).fill(0);
42+ dist[startLane * 2 + 1] = 1;
43+ for (let row = 0; row < cfg.rows; row++) {
44+ const next = new Array(width).fill(0);
45+ for (let x = 0; x < width; x++) {
46+ if (!dist[x]) continue;
47+ const l = Math.max(0, x - 1);
48+ const r = Math.min(width - 1, x + 1);
49+ next[l] += dist[x] / 2;
50+ next[r] += dist[x] / 2;
51+ }
52+ dist = next;
53+ }
54+ const buckets = new Array(cfg.lanes).fill(0);
55+ for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x];
56+ return buckets;
57+}
58+
59+/** Per-lane normalisation so every start lane has the same expected base value (no "edge lane" exploit). */
60+export function laneNormalizer(cfg: DropzoneConfig, risk: DropzoneInput["risk"], startLane: number): number {
61+ const table = cfg.buckets[risk];
62+ const center = Math.floor(cfg.lanes / 2);
63+ const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0);
64+ return ev(center) / ev(startLane);
65+}
66+
67+/** Bucket values as the player will see them for a given lane/risk (already normalised and RTP-scaled). */
68+export function displayedBuckets(def: ArcadeGameDefinition, risk: DropzoneInput["risk"], startLane: number): number[] {
69+ const cfg = def.config as unknown as DropzoneConfig;
70+ const norm = laneNormalizer(cfg, risk, startLane);
71+ return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100);
72+}
73+
74+export function resolveDropzone(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: Partial<DropzoneInput>): ArcadeOutcome {
75+ const cfg = def.config as unknown as DropzoneConfig;
76+ const risk: DropzoneInput["risk"] = inputRaw.risk === "low" || inputRaw.risk === "high" ? inputRaw.risk : "medium";
77+ const lanes = cfg.lanes;
78+ const startLane = Math.max(0, Math.min(lanes - 1, Math.round(inputRaw.lane ?? Math.floor(lanes / 2))));
79+ const norm = laneNormalizer(cfg, risk, startLane);
80+ const table = cfg.buckets[risk].map((v) => v * norm);
81+ const features: string[] = [];
82+ const steps: DropStep[] = [];
83+ // Position in "half-lane" units: bucket k spans [2k, 2k+2). Start centred above the chosen lane.
84+ let x = startLane * 2 + 1;
85+ let gateMult = 1;
86+ for (let row = 0; row < cfg.rows; row++) {
87+ x += rng.chance(0.5) ? 1 : -1;
88+ x = Math.max(0, Math.min(lanes * 2 - 1, x));
89+ const step: DropStep = { row, x };
90+ if (rng.chance(cfg.gateChancePerRow)) {
91+ const v = cfg.gateValues[rng.int(cfg.gateValues.length)];
92+ gateMult *= v;
93+ step.event = { type: "gate", value: v };
94+ features.push("Gate");
95+ } else if (rng.chance(cfg.portalChancePerRow)) {
96+ const hop = (1 + rng.int(3)) * 2 * (rng.chance(0.5) ? 1 : -1);
97+ const to = Math.max(0, Math.min(lanes * 2 - 1, x + hop));
98+ step.event = { type: "portal", to };
99+ x = to;
100+ features.push("Portal");
101+ }
102+ steps.push(step);
103+ }
104+ const bucket = Math.min(lanes - 1, Math.floor(x / 2));
105+ const base = table[bucket] * gateMult;
106+ let deep: { steps: DropStep[]; bucket: number; value: number } | null = null;
107+ let total = base;
108+ if (rng.chance(cfg.deepDropChance)) {
109+ features.push("Deep Drop");
110+ const dsteps: DropStep[] = [];
111+ let dx = bucket * 2 + 1;
112+ for (let row = 0; row < cfg.deepRows; row++) {
113+ dx += rng.chance(0.5) ? 1 : -1;
114+ dx = Math.max(0, Math.min(cfg.deepBuckets.length * 2 - 1, dx));
115+ dsteps.push({ row, x: dx });
116+ }
117+ const db = Math.min(cfg.deepBuckets.length - 1, Math.floor(dx / 2));
118+ deep = { steps: dsteps, bucket: db, value: cfg.deepBuckets[db] };
119+ total = base * cfg.deepBuckets[db];
120+ }
121+ const scaled = total * def.payScale;
122+ const capped = scaled > def.maxMultiplier;
123+ const multiplier = Math.min(def.maxMultiplier, scaled);
124+ const totalWin = Math.round(bet * multiplier);
125+ return {
126+ game: def.slug,
127+ version: def.version,
128+ bet,
129+ totalWin,
130+ multiplier: bet ? totalWin / bet : 0,
131+ capped,
132+ features: [...new Set(features)],
133+ steps,
134+ summary: { risk, startLane, bucket, bucketValue: Math.round(table[bucket] * def.payScale * 100) / 100, gateMult, deep, table: table.map((v) => Math.round(v * def.payScale * 100) / 100) },
135+ };
136+}
137+
138+/** Random-input driver for simulation. */
139+export function randomDropzoneInput(def: ArcadeGameDefinition, rng: Rng): DropzoneInput {
140+ const cfg = def.config as unknown as DropzoneConfig;
141+ const risks: DropzoneInput["risk"][] = ["low", "medium", "high"];
142+ return { risk: risks[rng.int(3)], lane: rng.int(cfg.lanes) };
143+}
added packages/game-core/src/arcade/gridbreak.ts +166 −0
@@ -0,0 +1,166 @@
1+import type { Rng } from "../rng";
2+import type { ArcadeGameDefinition, ArcadeOutcome } from "./types";
3+
4+/**
5+ * GRID//BREAK — an 8×8 grid of energy blocks. The player fires a wave down a
6+ * column; every cluster (≥3 connected blocks of one colour) the wave touches
7+ * detonates, blocks fall, new ones drop in, and any new cluster detonates in a
8+ * chain. Bombs clear 3×3, line blocks clear a row, ×2 blocks double the step.
9+ */
10+
11+export interface GridbreakConfig {
12+ size: number;
13+ colors: number;
14+ /** Value per block by colour index, in bet units (× total bet / size²·k). */
15+ values: number[];
16+ weights: number[];
17+ specialChance: { bomb: number; line: number; x2: number };
18+ chainLadder: number[];
19+ minCluster: number;
20+ maxChains: number;
21+}
22+
23+export type Cell = { c: number; s?: "bomb" | "line" | "x2" };
24+
25+export interface GridStep {
26+ grid: Cell[][]; // [col][row], row 0 = top
27+ destroyed: [number, number][];
28+ specials: { type: string; at: [number, number] }[];
29+ chain: number;
30+ multiplier: number;
31+ stepValue: number;
32+ win: number;
33+}
34+
35+function drawCell(cfg: GridbreakConfig, rng: Rng): Cell {
36+ const c = rng.weighted(cfg.weights);
37+ const roll = rng.float();
38+ if (roll < cfg.specialChance.bomb) return { c, s: "bomb" };
39+ if (roll < cfg.specialChance.bomb + cfg.specialChance.line) return { c, s: "line" };
40+ if (roll < cfg.specialChance.bomb + cfg.specialChance.line + cfg.specialChance.x2) return { c, s: "x2" };
41+ return { c };
42+}
43+
44+function clusters(grid: Cell[][], size: number, minCluster: number): [number, number][][] {
45+ const seen = new Set<string>();
46+ const out: [number, number][][] = [];
47+ for (let x = 0; x < size; x++)
48+ for (let y = 0; y < size; y++) {
49+ const k = `${x}:${y}`;
50+ if (seen.has(k)) continue;
51+ const color = grid[x][y].c;
52+ const stack: [number, number][] = [[x, y]];
53+ const group: [number, number][] = [];
54+ seen.add(k);
55+ while (stack.length) {
56+ const [cx, cy] = stack.pop()!;
57+ group.push([cx, cy]);
58+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
59+ const nx = cx + dx;
60+ const ny = cy + dy;
61+ if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue;
62+ const nk = `${nx}:${ny}`;
63+ if (seen.has(nk) || grid[nx][ny].c !== color) continue;
64+ seen.add(nk);
65+ stack.push([nx, ny]);
66+ }
67+ }
68+ if (group.length >= minCluster) out.push(group);
69+ }
70+ return out;
71+}
72+
73+export function resolveGridbreak(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: { column?: number }): ArcadeOutcome {
74+ const cfg = def.config as unknown as GridbreakConfig;
75+ const size = cfg.size;
76+ const column = Math.max(0, Math.min(size - 1, Math.round(inputRaw.column ?? Math.floor(size / 2))));
77+ const grid: Cell[][] = Array.from({ length: size }, () => Array.from({ length: size }, () => drawCell(cfg, rng)));
78+ const steps: GridStep[] = [];
79+ const features: string[] = [];
80+ let chain = 0;
81+ let totalUnits = 0;
82+ const unit = 1 / (size * size); // one block of value 1 ≈ 1/64 of the bet
83+ let firstWave = true;
84+ while (chain < cfg.maxChains) {
85+ const all = clusters(grid, size, cfg.minCluster);
86+ // The first wave only detonates clusters touching the fired column; chains detonate everything.
87+ const hit = firstWave ? all.filter((g) => g.some(([x]) => x === column)) : all;
88+ if (hit.length === 0) break;
89+ const destroyed = new Set<string>();
90+ const specials: GridStep["specials"] = [];
91+ let stepMult = 1;
92+ const push = (x: number, y: number) => {
93+ if (x >= 0 && y >= 0 && x < size && y < size) destroyed.add(`${x}:${y}`);
94+ };
95+ for (const g of hit) for (const [x, y] of g) push(x, y);
96+ // Specials inside destroyed blocks trigger.
97+ for (const k of [...destroyed]) {
98+ const [x, y] = k.split(":").map(Number);
99+ const cell = grid[x][y];
100+ if (cell.s === "bomb") {
101+ specials.push({ type: "bomb", at: [x, y] });
102+ for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) push(x + dx, y + dy);
103+ features.push("Bomb");
104+ } else if (cell.s === "line") {
105+ specials.push({ type: "line", at: [x, y] });
106+ for (let xx = 0; xx < size; xx++) push(xx, y);
107+ features.push("Line clear");
108+ } else if (cell.s === "x2") {
109+ specials.push({ type: "x2", at: [x, y] });
110+ stepMult *= 2;
111+ features.push("×2 block");
112+ }
113+ }
114+ const chainMult = cfg.chainLadder[Math.min(chain, cfg.chainLadder.length - 1)];
115+ let value = 0;
116+ const destroyedList: [number, number][] = [];
117+ for (const k of destroyed) {
118+ const [x, y] = k.split(":").map(Number);
119+ value += cfg.values[grid[x][y].c] * unit;
120+ destroyedList.push([x, y]);
121+ }
122+ const stepUnits = value * stepMult * chainMult;
123+ totalUnits += stepUnits;
124+ steps.push({
125+ grid: grid.map((c) => c.map((cell) => ({ ...cell }))),
126+ destroyed: destroyedList,
127+ specials,
128+ chain,
129+ multiplier: stepMult * chainMult,
130+ stepValue: Math.round(stepUnits * def.payScale * 10000) / 10000,
131+ win: Math.round(bet * stepUnits * def.payScale),
132+ });
133+ // Gravity + refill.
134+ for (let x = 0; x < size; x++) {
135+ const kept: Cell[] = [];
136+ for (let y = 0; y < size; y++) if (!destroyed.has(`${x}:${y}`)) kept.push(grid[x][y]);
137+ const fresh: Cell[] = [];
138+ while (fresh.length + kept.length < size) fresh.push(drawCell(cfg, rng));
139+ grid[x] = [...fresh, ...kept];
140+ }
141+ chain++;
142+ firstWave = false;
143+ if (chain > 1) features.push("Chain reaction");
144+ }
145+ // Final grid for presentation.
146+ steps.push({ grid: grid.map((c) => c.map((cell) => ({ ...cell }))), destroyed: [], specials: [], chain, multiplier: 1, stepValue: 0, win: 0 });
147+ const scaled = totalUnits * def.payScale;
148+ const capped = scaled > def.maxMultiplier;
149+ const multiplier = Math.min(def.maxMultiplier, scaled);
150+ const totalWin = Math.round(bet * multiplier);
151+ return {
152+ game: def.slug,
153+ version: def.version,
154+ bet,
155+ totalWin,
156+ multiplier: bet ? totalWin / bet : 0,
157+ capped,
158+ features: [...new Set(features)],
159+ steps,
160+ summary: { column, chains: chain, blocksDestroyed: steps.reduce((a, s) => a + s.destroyed.length, 0) },
161+ };
162+}
163+
164+export function randomGridbreakInput(def: ArcadeGameDefinition, rng: Rng): { column: number } {
165+ return { column: rng.int((def.config as unknown as GridbreakConfig).size) };
166+}
added packages/game-core/src/arcade/index.ts +185 −0
@@ -0,0 +1,185 @@
1+import type { Rng } from "../rng";
2+import { CryptoRng } from "../rng";
3+import { DEFAULT_CERTIFICATION_RULES, type CertificationReport, type CertificationRules } from "../validation";
4+import type { ArcadeGameDefinition, ArcadeOutcome } from "./types";
5+import { resolveDropzone, randomDropzoneInput } from "./dropzone";
6+import { resolveGridbreak, randomGridbreakInput } from "./gridbreak";
7+import { resolveOrbit, randomOrbitInput } from "./orbit";
8+import { simulateLadderRound } from "./ladder";
9+import type { CrashSimulationResult } from "../crash";
10+
11+export * from "./types";
12+export * from "./ladder";
13+export * from "./dropzone";
14+export * from "./gridbreak";
15+export * from "./orbit";
16+
17+/* ------------------------------------------------------------ dispatch */
18+
19+export function resolveInstant(def: ArcadeGameDefinition, bet: number, input: Record<string, unknown>, rng: Rng = new CryptoRng()): ArcadeOutcome {
20+ switch (def.presentation.scene) {
21+ case "dropzone":
22+ return resolveDropzone(def, bet, rng, input as never);
23+ case "gridbreak":
24+ return resolveGridbreak(def, bet, rng, input as never);
25+ case "orbit":
26+ return resolveOrbit(def, bet, rng, input as never);
27+ default:
28+ throw new Error(`${def.slug} is not an instant game`);
29+ }
30+}
31+
32+export function randomInput(def: ArcadeGameDefinition, rng: Rng): Record<string, unknown> {
33+ switch (def.presentation.scene) {
34+ case "dropzone":
35+ return randomDropzoneInput(def, rng) as unknown as Record<string, unknown>;
36+ case "gridbreak":
37+ return randomGridbreakInput(def, rng);
38+ case "orbit":
39+ return randomOrbitInput(def, rng);
40+ default:
41+ return {};
42+ }
43+}
44+
45+/* ---------------------------------------------------------- simulation */
46+
47+export type ArcadeSimulationResult = CrashSimulationResult;
48+
49+export function simulateArcade(def: ArcadeGameDefinition, opts: { spins: number; bet?: number; rng?: Rng; payScale?: number }): ArcadeSimulationResult {
50+ const rng = opts.rng ?? new CryptoRng();
51+ const bet = opts.bet ?? 100;
52+ const d = opts.payScale !== undefined ? { ...def, payScale: opts.payScale } : def;
53+ const n = opts.spins;
54+ const started = Date.now();
55+ let returned = 0;
56+ let hits = 0;
57+ let sum = 0;
58+ let sumSq = 0;
59+ let maxWin = 0;
60+ let capped = 0;
61+ const buckets = [
62+ { label: "0×", min: 0, max: 0 },
63+ { label: "0–1×", min: 0.000001, max: 1 },
64+ { label: "1–2×", min: 1, max: 2 },
65+ { label: "2–5×", min: 2, max: 5 },
66+ { label: "5–10×", min: 5, max: 10 },
67+ { label: "10–20×", min: 10, max: 20 },
68+ { label: "20–50×", min: 20, max: 50 },
69+ { label: "50–100×", min: 50, max: 100 },
70+ { label: "100–500×", min: 100, max: 500 },
71+ { label: "500×+", min: 500, max: Infinity },
72+ ];
73+ const counts = new Array(buckets.length).fill(0);
74+ const feature: Record<string, number> = {};
75+ const convergence: { spins: number; rtp: number }[] = [];
76+ const every = Math.max(1, Math.floor(n / 40));
77+ const sample: number[] = [];
78+ for (let i = 0; i < n; i++) {
79+ let win: number;
80+ if (d.mode === "ladder") win = simulateLadderRound(d, bet, rng);
81+ else {
82+ const out = resolveInstant(d, bet, randomInput(d, rng), rng);
83+ win = out.totalWin;
84+ if (out.capped) capped++;
85+ for (const f of out.features) feature[f] = (feature[f] ?? 0) + 1;
86+ }
87+ returned += win;
88+ if (win > 0) hits++;
89+ const m = win / bet;
90+ sum += m;
91+ sumSq += m * m;
92+ if (win > maxWin) maxWin = win;
93+ for (let b = 0; b < buckets.length; b++) if ((buckets[b].max === 0 && m === 0) || (buckets[b].max !== 0 && m >= buckets[b].min && m < buckets[b].max)) {
94+ counts[b]++;
95+ break;
96+ }
97+ if (sample.length < 100_000) sample.push(m);
98+ if ((i + 1) % every === 0 || i === n - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) });
99+ }
100+ const mean = sum / n;
101+ const variance = Math.max(0, sumSq / n - mean * mean);
102+ sample.sort((a, b) => a - b);
103+ const observedRtp = returned / (n * bet);
104+ return {
105+ game: d.slug,
106+ version: d.version,
107+ spins: n,
108+ bet,
109+ configuredRtp: d.rtp,
110+ observedRtp,
111+ deviation: observedRtp - d.rtp,
112+ hitRate: hits / n,
113+ bonusRate: 0,
114+ freeSpinRate: 0,
115+ jackpotRate: 0,
116+ wagered: n * bet,
117+ returned,
118+ averageWin: returned / n,
119+ medianWin: (sample[Math.floor(sample.length / 2)] ?? 0) * bet,
120+ maxWin,
121+ maxWinMultiplier: maxWin / bet,
122+ stdDev: Math.sqrt(variance),
123+ distribution: buckets.map((b, i) => ({ ...b, count: counts[i], share: counts[i] / n })),
124+ convergence,
125+ featureCounts: feature,
126+ cappedRounds: capped,
127+ durationMs: Date.now() - started,
128+ };
129+}
130+
131+/** Find the payScale bringing an instant game to its target RTP (ladders are analytic). */
132+export function calibrateArcade(def: ArcadeGameDefinition, spins = 400_000, iterations = 4, log?: (s: string) => void): { payScale: number; result: ArcadeSimulationResult } {
133+ let payScale = 1;
134+ let result = simulateArcade(def, { spins: Math.max(100_000, Math.floor(spins / 4)), payScale });
135+ log?.(` iter 0: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`);
136+ for (let i = 1; i <= iterations; i++) {
137+ const se = result.stdDev / Math.sqrt(result.spins);
138+ if (i > 1 && Math.abs(result.deviation) < Math.max(0.001, se / 2)) break;
139+ payScale = Math.min(10, Math.max(0.01, payScale * (1 + (def.rtp / result.observedRtp - 1) * 0.9)));
140+ result = simulateArcade(def, { spins, payScale });
141+ log?.(` iter ${i}: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`);
142+ }
143+ return { payScale: Number(payScale.toFixed(4)), result };
144+}
145+
146+export function certifyArcade(def: ArcadeGameDefinition, sim: ArcadeSimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport {
147+ const se = sim.stdDev / Math.sqrt(sim.spins);
148+ const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * se));
149+ const checks = [
150+ { name: "definition", pass: def.rtp >= 0.94 && def.rtp <= 0.98 && def.maxMultiplier >= 10 && def.payScale > 0, detail: `rtp ${def.rtp}, cap ${def.maxMultiplier}×, payScale ${def.payScale}` },
151+ { name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} rounds` },
152+ { name: "rtp-deviation", pass: Math.abs(sim.deviation) <= tolerance, detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}%)` },
153+ { name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` },
154+ { name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` },
155+ { name: "cap-share", pass: sim.cappedRounds / sim.spins <= rules.maxCappedShare, detail: `${sim.cappedRounds} capped rounds` },
156+ ];
157+ return {
158+ game: def.slug,
159+ name: def.name,
160+ version: def.version,
161+ spins: sim.spins,
162+ configuredRtp: def.rtp,
163+ observedRtp: sim.observedRtp,
164+ deviation: sim.deviation,
165+ hitRate: sim.hitRate,
166+ bonusRate: 0,
167+ freeSpinRate: 0,
168+ maxWinMultiplier: sim.maxWinMultiplier,
169+ stdDev: sim.stdDev,
170+ status: checks.every((c) => c.pass) ? "PASS" : "FAIL",
171+ checks,
172+ certifiedAt: new Date().toISOString(),
173+ rules,
174+ distribution: sim.distribution,
175+ convergence: sim.convergence,
176+ featureCounts: sim.featureCounts,
177+ durationMs: sim.durationMs,
178+ };
179+}
180+
181+type ArcadeInput = Omit<ArcadeGameDefinition, "kind" | "featureNames" | "tags" | "minBet" | "maxBet" | "payScale"> & Partial<Pick<ArcadeGameDefinition, "featureNames" | "tags" | "minBet" | "maxBet" | "payScale">>;
182+
183+export function defineArcadeGame(input: ArcadeInput): ArcadeGameDefinition {
184+ return { kind: "arcade", minBet: 10, maxBet: 1000, payScale: 1, tags: ["original", "beyond-slots"], featureNames: [], ...input };
185+}
added packages/game-core/src/arcade/ladder.ts +272 −0
@@ -0,0 +1,272 @@
1+import type { Rng } from "../rng";
2+import type { ArcadeGameDefinition, LadderAction, LadderEvent, LadderOffer, LadderState } from "./types";
3+
4+/* ---------------------------------------------------------------- helpers */
5+
6+const r2 = (x: number) => Math.floor(x * 100) / 100;
7+
8+function fail(msg: string): never {
9+ throw new Error(msg);
10+}
11+
12+/** Next multiplier for a step with survival `p` from multiplier `m` (EV-neutral relative to the round RTP). */
13+export function stepMultiplier(m: number, p: number): number {
14+ return m / p;
15+}
16+
17+/* ------------------------------------------------------------- THE VAULT */
18+
19+export interface VaultConfig {
20+ /** Survival per layer (5 layers). */
21+ layers: number[];
22+ /** Digits revealed per layer (sub-steps, cosmetic — the layer's survival is split evenly). */
23+ digitsPerLayer: number;
24+ layerNames: string[];
25+}
26+
27+export function vaultStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState {
28+ const cfg = def.config as unknown as VaultConfig;
29+ const state: LadderState = {
30+ game: def.slug,
31+ version: def.version,
32+ bet,
33+ stage: 0,
34+ current: def.rtp, // the round RTP is applied once, up front: securing after step k returns rtp·Π(1/p_i)
35+ status: "running",
36+ win: 0,
37+ offers: [],
38+ log: [{ stage: 0, kind: "start", label: "The vault hums. Five layers between you and the core.", multiplierAfter: 0, outcome: "info" }],
39+ extra: { digits: [] as number[], layersOpen: 0 },
40+ };
41+ // Mandatory first layer.
42+ return vaultAdvance(def, state, rng, { type: "continue" }, true);
43+}
44+
45+function vaultOffers(def: ArcadeGameDefinition, state: LadderState): LadderOffer[] {
46+ const cfg = def.config as unknown as VaultConfig;
47+ if (state.stage >= cfg.layers.length) return [];
48+ const p = cfg.layers[state.stage];
49+ return [
50+ {
51+ id: "open",
52+ label: `Open layer ${state.stage + 1} — ${cfg.layerNames[state.stage]}`,
53+ description: `${Math.round(p * 100)}% chance the lock yields`,
54+ survival: p,
55+ next: r2(stepMultiplier(state.current, p)),
56+ advance: 1,
57+ kind: "layer",
58+ },
59+ ];
60+}
61+
62+export function vaultAdvance(def: ArcadeGameDefinition, s: LadderState, rng: Rng, action: LadderAction, first = false): LadderState {
63+ const cfg = def.config as unknown as VaultConfig;
64+ const state: LadderState = structuredClone(s);
65+ if (state.status !== "running") return state;
66+ if (action.type === "cashout") {
67+ if (first || state.stage === 0) fail("cannot secure before the first layer");
68+ state.status = "cashed";
69+ state.win = Math.round(state.bet * state.current);
70+ state.log.push({ stage: state.stage, kind: "secure", label: `Secured ${r2(state.current)}×`, multiplierAfter: r2(state.current), outcome: "cash" });
71+ state.offers = [];
72+ return state;
73+ }
74+ const p = cfg.layers[state.stage];
75+ if (p === undefined) fail("vault already open");
76+ // Reveal digits one by one; the layer's survival is split evenly across its digits.
77+ const perDigit = Math.pow(p, 1 / cfg.digitsPerLayer);
78+ const digits: number[] = [];
79+ let bust = false;
80+ for (let i = 0; i < cfg.digitsPerLayer; i++) {
81+ if (!rng.chance(perDigit)) {
82+ bust = true;
83+ break;
84+ }
85+ digits.push(rng.int(10));
86+ }
87+ const layerName = cfg.layerNames[state.stage];
88+ if (bust) {
89+ state.status = "busted";
90+ state.win = 0;
91+ state.log.push({ stage: state.stage + 1, kind: "alarm", label: `Alarm — ${layerName} sealed shut`, detail: `${digits.length}/${cfg.digitsPerLayer} digits matched`, multiplierAfter: 0, outcome: "bust", data: { digits } });
92+ state.offers = [];
93+ state.current = 0;
94+ return state;
95+ }
96+ state.current = stepMultiplier(state.current, p);
97+ state.stage += 1;
98+ (state.extra.digits as number[]).push(...digits);
99+ state.extra.layersOpen = state.stage;
100+ state.log.push({ stage: state.stage, kind: "layer", label: `${layerName} opened`, detail: digits.join(" "), multiplierAfter: r2(state.current), outcome: "ok", data: { digits } });
101+ if (state.stage >= cfg.layers.length) {
102+ state.status = "completed";
103+ state.current = Math.min(state.current, def.maxMultiplier);
104+ state.win = Math.round(state.bet * state.current);
105+ state.log.push({ stage: state.stage, kind: "jackpot", label: "The core is open — fictional jackpot", multiplierAfter: r2(state.current), outcome: "cash" });
106+ state.offers = [];
107+ return state;
108+ }
109+ state.offers = vaultOffers(def, state);
110+ return state;
111+}
112+
113+/* ------------------------------------------------------------- ESCAPE 99 */
114+
115+export interface EscapeConfig {
116+ floors: number;
117+ /** Survival by floor band: [uptoFloor, p][] */
118+ bands: [number, number][];
119+ checkpoints: number[];
120+ rooms: { kind: string; label: string; weight: number; detail: string }[];
121+ /** Chance a floor offers two paths (safe vs risky). */
122+ forkChance: number;
123+ /** Chance a floor is a portal that jumps 3 floors in one step. */
124+ portalChance: number;
125+}
126+
127+function floorSurvival(cfg: EscapeConfig, floor: number): number {
128+ for (const [upto, p] of cfg.bands) if (floor <= upto) return p;
129+ return cfg.bands[cfg.bands.length - 1][1];
130+}
131+
132+export function escapeStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState {
133+ const state: LadderState = {
134+ game: def.slug,
135+ version: def.version,
136+ bet,
137+ stage: 0,
138+ current: def.rtp,
139+ status: "running",
140+ win: 0,
141+ offers: [],
142+ log: [{ stage: 0, kind: "start", label: "Floor 1 of 99. The tower wakes up.", multiplierAfter: 0, outcome: "info" }],
143+ extra: { checkpointsHit: [] as number[] },
144+ };
145+ return escapeAdvance(def, state, rng, { type: "continue" }, true);
146+}
147+
148+function escapeOffers(def: ArcadeGameDefinition, state: LadderState, rng: Rng): LadderOffer[] {
149+ const cfg = def.config as unknown as EscapeConfig;
150+ const floor = state.stage + 1;
151+ if (floor > cfg.floors) return [];
152+ const p = floorSurvival(cfg, floor);
153+ const room = cfg.rooms[rng.weighted(cfg.rooms.map((r) => r.weight))];
154+ if (rng.chance(cfg.forkChance) && floor < cfg.floors - 2) {
155+ const safe = Math.min(0.98, p + 0.06);
156+ const risky = Math.max(0.5, p - 0.18);
157+ return [
158+ { id: "safe", label: `Left corridor — ${room.label}`, description: `${Math.round(safe * 100)}% safe`, survival: safe, next: r2(stepMultiplier(state.current, safe)), advance: 1, kind: "fork-safe" },
159+ { id: "risky", label: `Right corridor — ${room.label}`, description: `${Math.round(risky * 100)}% safe, bigger jump`, survival: risky, next: r2(stepMultiplier(state.current, risky)), advance: 1, kind: "fork-risky" },
160+ ];
161+ }
162+ if (rng.chance(cfg.portalChance) && floor <= cfg.floors - 3) {
163+ const p3 = floorSurvival(cfg, floor) * floorSurvival(cfg, floor + 1) * floorSurvival(cfg, floor + 2);
164+ return [{ id: "portal", label: "Portal — skip three floors", description: `${Math.round(p3 * 100)}% to arrive intact`, survival: p3, next: r2(stepMultiplier(state.current, p3)), advance: 3, kind: "portal" }];
165+ }
166+ return [{ id: room.kind, label: `Floor ${floor} — ${room.label}`, description: `${Math.round(p * 100)}% safe · ${room.detail}`, survival: p, next: r2(stepMultiplier(state.current, p)), advance: 1, kind: room.kind }];
167+}
168+
169+export function escapeAdvance(def: ArcadeGameDefinition, s: LadderState, rng: Rng, action: LadderAction, first = false): LadderState {
170+ const cfg = def.config as unknown as EscapeConfig;
171+ const state: LadderState = structuredClone(s);
172+ if (state.status !== "running") return state;
173+ if (action.type === "cashout") {
174+ if (first || state.stage === 0) fail("cannot cash out before the first floor");
175+ state.status = "cashed";
176+ state.win = Math.round(state.bet * state.current);
177+ state.log.push({ stage: state.stage, kind: "exit", label: `Exited at floor ${state.stage} with ${r2(state.current)}×`, multiplierAfter: r2(state.current), outcome: "cash" });
178+ state.offers = [];
179+ return state;
180+ }
181+ if (state.offers.length === 0) state.offers = first ? [{ id: "start", label: "Floor 1 — Lobby", description: "", survival: floorSurvival(cfg, 1), next: r2(stepMultiplier(state.current, floorSurvival(cfg, 1))), advance: 1, kind: "room" }] : escapeOffers(def, state, rng);
182+ const offer = state.offers.find((o) => o.id === (action.offerId ?? state.offers[0].id)) ?? state.offers[0];
183+ const survived = rng.chance(offer.survival);
184+ const floorReached = state.stage + offer.advance;
185+ if (!survived) {
186+ state.status = "busted";
187+ state.current = 0;
188+ state.win = 0;
189+ state.log.push({ stage: floorReached, kind: offer.kind, label: bustLabel(offer.kind), detail: offer.label, multiplierAfter: 0, outcome: "bust" });
190+ state.offers = [];
191+ return state;
192+ }
193+ state.current = Math.min(def.maxMultiplier, stepMultiplier(state.current, offer.survival));
194+ state.stage = floorReached;
195+ const cp = cfg.checkpoints.includes(state.stage);
196+ if (cp) (state.extra.checkpointsHit as number[]).push(state.stage);
197+ state.log.push({ stage: state.stage, kind: offer.kind, label: offer.label, detail: cp ? "Checkpoint reached" : undefined, multiplierAfter: r2(state.current), outcome: "ok", data: { checkpoint: cp } });
198+ if (state.stage >= cfg.floors) {
199+ state.status = "completed";
200+ state.win = Math.round(state.bet * state.current);
201+ state.log.push({ stage: state.stage, kind: "summit", label: "Floor 99 — the tower opens to the sky", multiplierAfter: r2(state.current), outcome: "cash" });
202+ state.offers = [];
203+ return state;
204+ }
205+ state.offers = escapeOffers(def, state, rng);
206+ return state;
207+}
208+
209+function bustLabel(kind: string): string {
210+ return (
211+ {
212+ enemy: "Caught by the guardian",
213+ trap: "Trap triggered",
214+ chest: "The chest was a mimic",
215+ portal: "Lost in the portal",
216+ "fork-safe": "The corridor collapsed",
217+ "fork-risky": "The corridor collapsed",
218+ multiplier: "The rune backfired",
219+ room: "The door locked behind you",
220+ }[kind] ?? "Run over"
221+ );
222+}
223+
224+/* ---------------------------------------------------------------- driver */
225+
226+export function ladderStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState {
227+ if (def.slug === "the-vault") return vaultStart(def, bet, rng);
228+ if (def.slug === "escape-99") return escapeStart(def, bet, rng);
229+ fail(`unknown ladder game ${def.slug}`);
230+}
231+
232+export function ladderAdvance(def: ArcadeGameDefinition, state: LadderState, rng: Rng, action: LadderAction): LadderState {
233+ if (def.slug === "the-vault") return vaultAdvance(def, state, rng, action);
234+ if (def.slug === "escape-99") return escapeAdvance(def, state, rng, action);
235+ fail(`unknown ladder game ${def.slug}`);
236+}
237+
238+/** Public projection: never leaks anything beyond what the player may see. */
239+export function ladderView(state: LadderState) {
240+ return {
241+ game: state.game,
242+ version: state.version,
243+ bet: state.bet,
244+ stage: state.stage,
245+ current: r2(state.current),
246+ status: state.status,
247+ win: state.win,
248+ offers: state.offers,
249+ log: state.log,
250+ extra: state.extra,
251+ canCashout: state.status === "running" && state.stage > 0,
252+ };
253+}
254+
255+/* ------------------------------------------------------------- simulation */
256+
257+/** Mixed strategy population: each simulated player stops at a random target stage (or never). */
258+export function simulateLadderRound(def: ArcadeGameDefinition, bet: number, rng: Rng): number {
259+ let state = ladderStart(def, bet, rng);
260+ const maxStage = def.slug === "the-vault" ? 5 : 99;
261+ const target = 1 + rng.int(maxStage); // stop after `target` stages
262+ const preferRisky = rng.chance(0.5);
263+ while (state.status === "running") {
264+ if (state.stage >= target) {
265+ state = ladderAdvance(def, state, rng, { type: "cashout" });
266+ break;
267+ }
268+ const offer = state.offers.length > 1 ? (preferRisky ? state.offers[1] : state.offers[0]) : state.offers[0];
269+ state = ladderAdvance(def, state, rng, { type: "continue", offerId: offer?.id });
270+ }
271+ return state.win;
272+}
added packages/game-core/src/arcade/orbit.ts +121 −0
@@ -0,0 +1,121 @@
1+import type { Rng } from "../rng";
2+import type { ArcadeGameDefinition, ArcadeOutcome } from "./types";
3+
4+/**
5+ * ORBIT — objects circle a core on concentric orbits. The player fires an
6+ * impulse at an angle; on each orbit the impulse can hit an object (angular
7+ * proximity), collecting its value, deflecting, or spawning a new orbit.
8+ * Supernova collapses every orbit and turns the remaining objects into
9+ * multipliers of the collected total.
10+ */
11+
12+export interface OrbitConfig {
13+ orbits: number;
14+ objectsPerOrbit: [number, number];
15+ /** Angular half-width of the impulse in degrees (hit tolerance). */
16+ beamHalfWidth: number;
17+ objectTypes: { id: string; label: string; value: number; weight: number; deflect?: number; spawn?: boolean }[];
18+ supernovaChance: number;
19+ maxOrbits: number;
20+}
21+
22+export interface OrbitObject {
23+ id: string;
24+ type: string;
25+ angle: number;
26+ value: number;
27+}
28+
29+export interface OrbitStep {
30+ orbit: number;
31+ angle: number;
32+ hit: OrbitObject | null;
33+ deflectedTo?: number;
34+ spawned?: boolean;
35+ collected: number;
36+}
37+
38+function angleDiff(a: number, b: number): number {
39+ const d = Math.abs(((a - b) % 360 + 540) % 360 - 180);
40+ return d;
41+}
42+
43+export function resolveOrbit(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: { angle?: number }): ArcadeOutcome {
44+ const cfg = def.config as unknown as OrbitConfig;
45+ let angle = ((inputRaw.angle ?? rng.int(360)) % 360 + 360) % 360;
46+ const orbits: OrbitObject[][] = [];
47+ let seq = 0;
48+ const makeOrbit = (): OrbitObject[] => {
49+ const n = cfg.objectsPerOrbit[0] + rng.int(cfg.objectsPerOrbit[1] - cfg.objectsPerOrbit[0] + 1);
50+ const objs: OrbitObject[] = [];
51+ for (let i = 0; i < n; i++) {
52+ const t = cfg.objectTypes[rng.weighted(cfg.objectTypes.map((o) => o.weight))];
53+ objs.push({ id: `o${seq++}`, type: t.id, angle: rng.float() * 360, value: t.value });
54+ }
55+ return objs;
56+ };
57+ for (let i = 0; i < cfg.orbits; i++) orbits.push(makeOrbit());
58+ const steps: OrbitStep[] = [];
59+ const features: string[] = [];
60+ let collected = 0;
61+ let i = 0;
62+ while (i < orbits.length && i < cfg.maxOrbits) {
63+ const objs = orbits[i];
64+ let hit: OrbitObject | null = null;
65+ let best = Infinity;
66+ for (const o of objs) {
67+ const d = angleDiff(o.angle, angle);
68+ if (d <= cfg.beamHalfWidth && d < best) {
69+ best = d;
70+ hit = o;
71+ }
72+ }
73+ const step: OrbitStep = { orbit: i, angle, hit, collected };
74+ if (hit) {
75+ const t = cfg.objectTypes.find((o) => o.id === hit!.type)!;
76+ collected += hit.value;
77+ step.collected = collected;
78+ if (t.deflect) {
79+ angle = (angle + (rng.float() - 0.5) * 2 * t.deflect + 360) % 360;
80+ step.deflectedTo = angle;
81+ features.push("Deflection");
82+ }
83+ if (t.spawn && orbits.length < cfg.maxOrbits) {
84+ orbits.push(makeOrbit());
85+ step.spawned = true;
86+ features.push("New orbit");
87+ }
88+ features.push(t.label);
89+ }
90+ steps.push(step);
91+ i++;
92+ }
93+ let supernova: { remaining: number; multiplier: number } | null = null;
94+ const hitIds = new Set(steps.filter((s) => s.hit).map((s) => s.hit!.id));
95+ if (collected > 0 && rng.chance(cfg.supernovaChance)) {
96+ const remaining = orbits.flat().filter((o) => !hitIds.has(o.id)).length;
97+ const mult = 1 + remaining * 0.5;
98+ supernova = { remaining, multiplier: mult };
99+ collected *= mult;
100+ features.push("Supernova");
101+ }
102+ const scaled = collected * def.payScale;
103+ const capped = scaled > def.maxMultiplier;
104+ const multiplier = Math.min(def.maxMultiplier, scaled);
105+ const totalWin = Math.round(bet * multiplier);
106+ return {
107+ game: def.slug,
108+ version: def.version,
109+ bet,
110+ totalWin,
111+ multiplier: bet ? totalWin / bet : 0,
112+ capped,
113+ features: [...new Set(features)],
114+ steps,
115+ summary: { angle: inputRaw.angle ?? null, orbits: orbits.map((o) => o.map((x) => ({ ...x, value: Math.round(x.value * def.payScale * 100) / 100 }))), supernova, hits: hitIds.size },
116+ };
117+}
118+
119+export function randomOrbitInput(_def: ArcadeGameDefinition, rng: Rng): { angle: number } {
120+ return { angle: rng.int(360) };
121+}
added packages/game-core/src/arcade/types.ts +106 −0
@@ -0,0 +1,106 @@
1+/**
2+ * Spinza Originals — "Beyond Slots" arcade engine.
3+ *
4+ * Two families share the transport:
5+ * - instant games (Dropzone, Grid//Break, Orbit): one input → one fully
6+ * resolved outcome the client animates;
7+ * - ladder games (The Vault, Escape 99): a session advances step by step; the
8+ * player may secure the current multiplier or push on. Ladder multipliers
9+ * follow m_k = rtp · Π(1/p_i) so the expected return is `rtp` for ANY
10+ * stopping strategy, exactly like the crash games.
11+ *
12+ * All randomness comes from the server `Rng`; the browser never resolves.
13+ */
14+
15+export type ArcadeMode = "instant" | "ladder";
16+
17+export interface ArcadePresentation {
18+ scene: "dropzone" | "gridbreak" | "vault" | "orbit" | "escape";
19+ palette: { primary: string; secondary: string; glow: string; bg: string; surface: string };
20+ ambience: string;
21+ /** Main action label(s). */
22+ verb: string;
23+ secondaryVerb?: string;
24+}
25+
26+export interface ArcadeGameDefinition {
27+ kind: "arcade";
28+ slug: string;
29+ name: string;
30+ version: string;
31+ tagline: string;
32+ description: string;
33+ theme: string;
34+ tags: string[];
35+ mode: ArcadeMode;
36+ rtp: number;
37+ minBet: number;
38+ maxBet: number;
39+ maxMultiplier: number;
40+ volatility: "low" | "medium" | "high" | "extreme";
41+ /** RTP tuning factor for instant games (set by calibration). Ladders are analytic and ignore it. */
42+ payScale: number;
43+ presentation: ArcadePresentation;
44+ rules: string[];
45+ featureNames: string[];
46+ /** Game-specific configuration (typed per game module). */
47+ config: Record<string, unknown>;
48+}
49+
50+/** Fully resolved instant outcome; `steps` is game-specific presentation data. */
51+export interface ArcadeOutcome {
52+ game: string;
53+ version: string;
54+ bet: number;
55+ totalWin: number;
56+ multiplier: number;
57+ capped: boolean;
58+ features: string[];
59+ steps: unknown[];
60+ summary: Record<string, unknown>;
61+}
62+
63+/** Ladder session state persisted between actions. */
64+export interface LadderState {
65+ game: string;
66+ version: string;
67+ bet: number;
68+ /** Stage index reached (0 = start, before the mandatory first step). */
69+ stage: number;
70+ /** Multiplier the player can secure right now. */
71+ current: number;
72+ status: "running" | "cashed" | "busted" | "completed";
73+ win: number;
74+ /** Options offered for the next step (one or two paths). */
75+ offers: LadderOffer[];
76+ /** Presentation log of everything that happened. */
77+ log: LadderEvent[];
78+ /** Game-specific extra state (e.g. digits revealed). */
79+ extra: Record<string, unknown>;
80+}
81+
82+export interface LadderOffer {
83+ id: string;
84+ label: string;
85+ description: string;
86+ /** Survival probability of this step (shown to the player as risk %). */
87+ survival: number;
88+ /** Multiplier reached if the step succeeds. */
89+ next: number;
90+ /** Number of stages this step advances (portals skip). */
91+ advance: number;
92+ kind: string;
93+}
94+
95+export interface LadderEvent {
96+ stage: number;
97+ kind: string;
98+ label: string;
99+ detail?: string;
100+ multiplierAfter: number;
101+ outcome: "ok" | "bust" | "cash" | "info";
102+ /** Presentation payload (e.g. revealed digits, room type). */
103+ data?: Record<string, unknown>;
104+}
105+
106+export type LadderAction = { type: "continue"; offerId?: string } | { type: "cashout" };
modified packages/game-core/src/client.ts +5 −0
@@ -10,3 +10,8 @@ export { waysCount } from "./define";
10 export { LINES_10, LINES_20, LINES_25 } from "./evaluate"; 10 export { LINES_10, LINES_20, LINES_25 } from "./evaluate";
11 export type { CrashGameDefinition, CrashEvent, CrashCurve, CrashScene, CrashMilestone } from "./crash/curve"; 11 export type { CrashGameDefinition, CrashEvent, CrashCurve, CrashScene, CrashMilestone } from "./crash/curve";
12 export { multiplierAt, effectiveTime, timeForMultiplier, floorAt } from "./crash/curve"; 12 export { multiplierAt, effectiveTime, timeForMultiplier, floorAt } from "./crash/curve";
13+export type { ArcadeGameDefinition, ArcadeOutcome, ArcadePresentation, LadderState, LadderOffer, LadderEvent, LadderAction } from "./arcade/types";
14+export type { DropStep, DropzoneConfig, DropzoneInput } from "./arcade/dropzone";
15+export type { GridStep, GridbreakConfig, Cell as GridCell } from "./arcade/gridbreak";
16+export type { OrbitStep, OrbitObject, OrbitConfig } from "./arcade/orbit";
17+export type { VaultConfig, EscapeConfig } from "./arcade/ladder";
modified packages/game-core/src/index.ts +1 −0
@@ -9,3 +9,4 @@ export * from "./simulation";
9 export * from "./validation"; 9 export * from "./validation";
10 export * from "./define"; 10 export * from "./define";
11 export * from "./crash"; 11 export * from "./crash";
12+export * from "./arcade";
12 13
Menu

Theme