SPB Git

spb/spinza

Public

Risk Games: 10 provably-fair cash-out games (crash engine, API with auto cash-out + lazy settlement, canvas scenes, lobby category), shared settleRound

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

12 changed files +1,935 −137

modified apps/api/src/app.ts +2 −0
@@ -10,6 +10,7 @@ import { gameRoutes } from "./routes/games";
10 import { rewardRoutes } from "./routes/rewards"; 10 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 { refreshSettings } from "./lib/settings"; 14 import { refreshSettings } from "./lib/settings";
14 15
15 const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"]; 16 const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"];
@@ -69,6 +70,7 @@ export async function buildApp(): Promise<FastifyInstance> {
69 await app.register(userRoutes); 70 await app.register(userRoutes);
70 await app.register(gameRoutes); 71 await app.register(gameRoutes);
71 await app.register(rewardRoutes); 72 await app.register(rewardRoutes);
73+ await app.register(crashRoutes);
72 await app.register(adminRoutes); 74 await app.register(adminRoutes);
73 75
74 return app; 76 return app;
added apps/api/src/routes/crash.ts +235 −0
@@ -0,0 +1,235 @@
1+import type { FastifyInstance } from "fastify";
2+import { and, crashRounds, db, desc, eq, gameRounds, games, sql } from "@spinza/database";
3+import { multiplierAt, setupCrashRound, type CrashEvent, type CrashGameDefinition } from "@spinza/game-core";
4+import { CRASH_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 CrashRow = typeof crashRounds.$inferSelect;
17+
18+/** Public view of a round (never leaks seed/crash while running). */
19+function view(def: CrashGameDefinition, r: CrashRow, serverNow: number) {
20+ const running = r.status === "running";
21+ return {
22+ roundId: r.roundId,
23+ game: r.gameSlug,
24+ version: r.gameVersion,
25+ bet: r.bet,
26+ status: r.status,
27+ startedAt: r.startedAt.toISOString(),
28+ serverNow,
29+ elapsedMs: serverNow - r.startedAt.getTime(),
30+ commitment: r.commitment,
31+ events: r.events as CrashEvent[],
32+ autoCashout: r.autoCashout ? Number(r.autoCashout) : null,
33+ curve: def.curve,
34+ maxMultiplier: def.maxMultiplier,
35+ crashMultiplier: running ? null : Number(r.crashMultiplier),
36+ seed: running ? null : r.seed,
37+ cashoutMultiplier: r.cashoutMultiplier ? Number(r.cashoutMultiplier) : null,
38+ win: running ? null : r.win,
39+ };
40+}
41+
42+/** Settle a running round that has crashed or reached its auto cash-out. Returns the updated row. */
43+async function autoSettle(def: CrashGameDefinition, row: CrashRow, now: number): Promise<CrashRow> {
44+ if (row.status !== "running") return row;
45+ const elapsed = now - row.startedAt.getTime();
46+ const crash = Number(row.crashMultiplier);
47+ const auto = row.autoCashout ? Number(row.autoCashout) : null;
48+ const current = multiplierAt(def, row.events as CrashEvent[], elapsed);
49+ // Auto cash-out fires the instant the curve reaches the target, provided the crash is strictly later.
50+ if (auto && current >= auto && crash > auto) return settle(def, row, auto, "cashed");
51+ if (elapsed >= row.crashAtMs) return settle(def, row, null, "crashed");
52+ return row;
53+}
54+
55+async function settle(def: CrashGameDefinition, row: CrashRow, cashout: number | null, status: "cashed" | "crashed"): Promise<CrashRow> {
56+ const win = cashout ? Math.round(row.bet * cashout) : 0;
57+ const multiplier = cashout ?? 0;
58+ try {
59+ return await db.transaction(async (tx) => {
60+ // Re-check under lock (concurrent cashout vs poll).
61+ const fresh = await tx.execute(sql`select status from crash_rounds where id = ${row.id} for update`);
62+ if ((fresh.rows[0] as { status: string } | undefined)?.status !== "running") {
63+ const again = await tx.query.crashRounds.findFirst({ where: eq(crashRounds.id, row.id) });
64+ return again ?? row;
65+ }
66+ const wallet = await lockWallet(tx, row.userId);
67+ if (win > 0) await applyCredit(tx, wallet, "WIN", win, row.roundId, { game: row.gameSlug, multiplier });
68+ const settled = await settleRound(tx, wallet, {
69+ roundId: row.roundId,
70+ gameId: row.gameId,
71+ gameSlug: row.gameSlug,
72+ gameVersion: row.gameVersion,
73+ clientRoundId: row.clientRoundId,
74+ bet: row.bet,
75+ win,
76+ multiplier,
77+ result: { kind: "crash", crashMultiplier: Number(row.crashMultiplier), cashoutMultiplier: cashout, status, events: row.events, commitment: row.commitment, seed: row.seed, autoCashout: row.autoCashout ? Number(row.autoCashout) : null },
78+ features: [status === "cashed" ? "Cash out" : "Crash", ...((row.events as CrashEvent[]).map((e) => e.label))],
79+ freeSpins: false,
80+ bonus: false,
81+ jackpotTier: null,
82+ rngReference: `crash:${row.commitment.slice(0, 16)}`,
83+ durationMs: Date.now() - row.startedAt.getTime(),
84+ });
85+ await saveWallet(tx, wallet);
86+ const [updated] = await tx
87+ .update(crashRounds)
88+ .set({ status, cashoutMultiplier: cashout ? cashout.toFixed(2) : null, win, settledAt: new Date() })
89+ .where(eq(crashRounds.id, row.id))
90+ .returning();
91+ // Attach progression to the row for the response (not persisted here).
92+ (updated as CrashRow & { progression?: typeof settled }).progression = settled;
93+ redis().zadd("live:players", Date.now(), row.userId).catch(() => {});
94+ return updated;
95+ });
96+ } catch (e) {
97+ if (pgCode(e) === PG_UNIQUE_VIOLATION) {
98+ const again = await db.query.crashRounds.findFirst({ where: eq(crashRounds.id, row.id) });
99+ if (again) return again;
100+ }
101+ throw e;
102+ }
103+}
104+
105+export async function crashRoutes(app: FastifyInstance) {
106+ /** Recent crash points for the strip at the top of the game (public). */
107+ app.get("/api/crash/:slug/history", async (req) => {
108+ const { slug } = req.params as { slug: string };
109+ if (!CRASH_BY_SLUG.has(slug)) throw errors.notFound();
110+ const rows = await db.execute(sql`select (result->>'crashMultiplier')::numeric as crash, created_at from game_rounds where game_slug = ${slug} order by created_at desc limit 24`);
111+ return { history: (rows.rows as { crash: string; created_at: Date }[]).map((r) => ({ crash: Number(r.crash), at: r.created_at })) };
112+ });
113+
114+ /** Player's own running round for this game (resume after reload). */
115+ app.get("/api/crash/:slug/current", async (req) => {
116+ const user = requireUser(req);
117+ const { slug } = req.params as { slug: string };
118+ const def = CRASH_BY_SLUG.get(slug);
119+ if (!def) throw errors.notFound();
120+ let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.gameSlug, slug), eq(crashRounds.status, "running")), orderBy: desc(crashRounds.startedAt) });
121+ if (!row) return { round: null };
122+ row = await autoSettle(def, row, Date.now());
123+ return { round: view(def, row, Date.now()) };
124+ });
125+
126+ app.post("/api/crash/:slug/start", async (req) => {
127+ const user = requireUser(req);
128+ const { slug } = req.params as { slug: string };
129+ const def = CRASH_BY_SLUG.get(slug);
130+ if (!def) throw errors.notFound("Unknown game");
131+ const m = maintenance();
132+ if (m.enabled) throw errors.maintenance(m.message);
133+ if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable.");
134+ const retry = await rateLimit(`crash:${user.id}`, 120, 60);
135+ if (retry) throw errors.rateLimited(retry);
136+ const body = z
137+ .object({ bet: z.number().int(), clientRoundId: z.string().uuid(), autoCashout: z.number().min(1.01).max(def.maxMultiplier).optional().nullable() })
138+ .parse(req.body);
139+ if (!(BET_LEVELS as readonly number[]).includes(body.bet) || body.bet < def.minBet || body.bet > def.maxBet) throw errors.badRequest("Invalid bet for this game.");
140+ const game = await db.query.games.findFirst({ where: eq(games.slug, slug) });
141+ if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");
142+
143+ // Idempotency.
144+ const existing = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.clientRoundId, body.clientRoundId)) });
145+ if (existing) return { round: view(def, await autoSettle(def, existing, Date.now()), Date.now()), balance: null, replayed: true };
146+
147+ // Settle any abandoned running round first (its crash time has passed or it is still live → keep it live).
148+ const running = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.status, "running")) });
149+ if (running) {
150+ const r = await autoSettle(CRASH_BY_SLUG.get(running.gameSlug) ?? def, running, Date.now());
151+ if (r.status === "running") throw errors.conflict("ROUND_IN_PROGRESS", "You already have a round in progress.");
152+ }
153+
154+ const setup = setupCrashRound(def);
155+ const roundId = newRoundId();
156+ try {
157+ const result = await db.transaction(async (tx) => {
158+ const wallet = await lockWallet(tx, user.id);
159+ if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet);
160+ await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug });
161+ await saveWallet(tx, wallet);
162+ const [row] = await tx
163+ .insert(crashRounds)
164+ .values({
165+ roundId,
166+ userId: user.id,
167+ gameId: game.id,
168+ gameSlug: slug,
169+ gameVersion: def.version,
170+ clientRoundId: body.clientRoundId,
171+ bet: body.bet,
172+ crashMultiplier: setup.crashMultiplier.toFixed(2),
173+ crashAtMs: Math.max(0, Math.round(setup.crashAtMs)),
174+ seed: setup.seed,
175+ commitment: setup.commitment,
176+ events: setup.events,
177+ autoCashout: body.autoCashout ? body.autoCashout.toFixed(2) : null,
178+ })
179+ .returning();
180+ return { row, balance: wallet.balance };
181+ });
182+ redis().zadd("live:players", Date.now(), user.id).catch(() => {});
183+ return { round: view(def, result.row, Date.now()), balance: result.balance };
184+ } catch (e) {
185+ if (pgCode(e) === PG_UNIQUE_VIOLATION) {
186+ const again = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.clientRoundId, body.clientRoundId)) });
187+ if (again) return { round: view(def, again, Date.now()), balance: null, replayed: true };
188+ }
189+ throw e;
190+ }
191+ });
192+
193+ app.post("/api/crash/:slug/cashout", async (req) => {
194+ const user = requireUser(req);
195+ const { slug } = req.params as { slug: string };
196+ const def = CRASH_BY_SLUG.get(slug);
197+ if (!def) throw errors.notFound();
198+ const body = z.object({ roundId: z.string(), claimedMultiplier: z.number().min(1).optional() }).parse(req.body);
199+ const now = Date.now();
200+ let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.roundId, body.roundId), eq(crashRounds.userId, user.id)) });
201+ if (!row) throw errors.notFound("Round not found");
202+ row = await autoSettle(def, row, now);
203+ if (row.status === "running") {
204+ const elapsed = now - row.startedAt.getTime();
205+ const server = multiplierAt(def, row.events as CrashEvent[], elapsed);
206+ // Honour the player's displayed multiplier when it is not ahead of the server clock (network latency works in the player's favour).
207+ const claimed = body.claimedMultiplier ? Math.floor(body.claimedMultiplier * 100) / 100 : server;
208+ const cashout = Math.max(1, Math.min(server, claimed));
209+ const crash = Number(row.crashMultiplier);
210+ row = cashout < crash && elapsed < row.crashAtMs ? await settle(def, row, cashout, "cashed") : await settle(def, row, null, "crashed");
211+ }
212+ const prog = (row as CrashRow & { progression?: { xp: unknown; unlocked: unknown } }).progression;
213+ const [bal] = await db.execute(sql`select balance from wallets where user_id = ${user.id}`).then((r) => r.rows as { balance: number }[]);
214+ return { round: view(def, row, Date.now()), balance: Number(bal.balance), winClass: classifyWin(row.cashoutMultiplier ? Number(row.cashoutMultiplier) : 0), progression: prog ?? null };
215+ });
216+
217+ app.get("/api/crash/:slug/rounds/:roundId", async (req) => {
218+ const user = requireUser(req);
219+ const { slug, roundId } = req.params as { slug: string; roundId: string };
220+ const def = CRASH_BY_SLUG.get(slug);
221+ if (!def) throw errors.notFound();
222+ let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.roundId, roundId), eq(crashRounds.userId, user.id)) });
223+ if (!row) throw errors.notFound("Round not found");
224+ row = await autoSettle(def, row, Date.now());
225+ const prog = (row as CrashRow & { progression?: unknown }).progression;
226+ let balance: number | null = null;
227+ if (row.status !== "running") {
228+ const [bal] = await db.execute(sql`select balance from wallets where user_id = ${user.id}`).then((r) => r.rows as { balance: number }[]);
229+ balance = Number(bal.balance);
230+ }
231+ return { round: view(def, row, Date.now()), balance, progression: prog ?? null };
232+ });
233+
234+ void gameRounds;
235+}
modified apps/api/src/routes/games.ts +21 −2
@@ -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 } from "@spinza/games"; 3+import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_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";
@@ -32,6 +32,8 @@ function toCard(g: GameRow, extra: { popularity?: number; favorite?: boolean; la
32 ? { frame: (s.presentation as GameCard["presentation"])!.frame, backdrop: (s.presentation as GameCard["presentation"])!.backdrop, particles: (s.presentation as GameCard["presentation"])!.particles, ambience: (s.presentation as GameCard["presentation"])!.ambience } 32 ? { frame: (s.presentation as GameCard["presentation"])!.frame, backdrop: (s.presentation as GameCard["presentation"])!.backdrop, particles: (s.presentation as GameCard["presentation"])!.particles, ambience: (s.presentation as GameCard["presentation"])!.ambience }
33 : undefined, 33 : undefined,
34 lifecycle: g.lifecycle as GameCard["lifecycle"], 34 lifecycle: g.lifecycle as GameCard["lifecycle"],
35+ kind: ((s.kind as string) ?? "slot") as GameCard["kind"],
36+ category: ((s.category as string) ?? "slots") as GameCard["category"],
35 isNew: g.isNew, 37 isNew: g.isNew,
36 isFeatured: g.isFeatured, 38 isFeatured: g.isFeatured,
37 isJackpot: !!s.isJackpot, 39 isJackpot: !!s.isJackpot,
@@ -71,8 +73,25 @@ export async function gameRoutes(app: FastifyInstance) {
71 app.get("/api/games/:slug", async (req) => { 73 app.get("/api/games/:slug", async (req) => {
72 const { slug } = req.params as { slug: string }; 74 const { slug } = req.params as { slug: string };
73 const g = await db.query.games.findFirst({ where: eq(games.slug, slug) }); 75 const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });
76+ if (!g || g.lifecycle !== "published" || !flag(`game.${slug}.enabled`)) throw errors.notFound("Game not found");
77+ const crashDef = CRASH_BY_SLUG.get(slug);
78+ if (crashDef) {
79+ const s = g.summary as Record<string, unknown>;
80+ let favorite = false;
81+ if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) }));
82+ const info: GameInfo = {
83+ ...toCard(g, { favorite }),
84+ rtp: crashDef.rtp,
85+ hitFrequency: ((s.certification as { hitRate?: number } | null)?.hitRate) ?? null,
86+ description: crashDef.description,
87+ rules: crashDef.rules,
88+ paytable: [],
89+ certification: (s.certification as GameInfo["certification"] | null) ?? null,
90+ };
91+ return { game: info, definition: crashDef };
92+ }
74 const def = getGame(slug); 93 const def = getGame(slug);
75− if (!g || !def || g.lifecycle !== "published" || !flag(`game.${slug}.enabled`)) throw errors.notFound("Game not found"); 94+ if (!def) throw errors.notFound("Game not found");
76 const s = g.summary as Record<string, unknown>; 95 const s = g.summary as Record<string, unknown>;
77 const cert = (s.certification as GameInfo["certification"] | null) ?? null; 96 const cert = (s.certification as GameInfo["certification"] | null) ?? null;
78 let favorite = false; 97 let favorite = false;
added apps/api/src/services/settle.ts +161 −0
@@ -0,0 +1,161 @@
1+import { and, dailyRewards, eq, gameRounds, gameStates, gameStatistics, sql, userGameStats, userSettings, users, type Tx } from "@spinza/database";
2+import { classifyWin, type SpinResponse } from "@spinza/shared";
3+import type { LockedWallet } from "./wallet";
4+import { advanceMissions, awardXp, checkAchievements, spinXp, upsertLeaderboards, type UserCounters } from "./progression";
5+
6+export interface SettleArgs {
7+ roundId: string;
8+ gameId: string;
9+ gameSlug: string;
10+ gameVersion: string;
11+ clientRoundId: string;
12+ bet: number;
13+ win: number;
14+ multiplier: number;
15+ result: Record<string, unknown>;
16+ features: string[];
17+ freeSpins: boolean;
18+ bonus: boolean;
19+ jackpotTier: string | null;
20+ rngReference: string;
21+ durationMs: number;
22+ /** Persistent per-game state (slots); omitted for crash/arcade games. */
23+ stateAfter?: Record<string, unknown>;
24+}
25+
26+export interface SettleResult {
27+ xp: SpinResponse["xp"];
28+ unlocked: SpinResponse["unlocked"];
29+ winClass: SpinResponse["winClass"];
30+}
31+
32+/**
33+ * Shared bookkeeping for any settled round (slot spin, crash cash-out, arcade
34+ * round): round row, per-user/per-game stats, global stats, XP + level rewards,
35+ * achievements, missions, leaderboards. Must run inside the transaction that
36+ * holds the wallet lock, AFTER the bet/win ledger entries have been applied.
37+ */
38+export async function settleRound(tx: Tx, wallet: LockedWallet, a: SettleArgs): Promise<SettleResult> {
39+ const userId = wallet.userId;
40+ const [u] = await tx
41+ .select({ xp: users.xp, level: users.level, totalSpins: users.totalSpins, gamesPlayed: users.gamesPlayed, biggestWin: users.biggestWin, biggestMultiplier: users.biggestMultiplier })
42+ .from(users)
43+ .where(eq(users.id, userId))
44+ .for("update");
45+
46+ const ugsExisting = await tx.query.userGameStats.findFirst({ where: and(eq(userGameStats.userId, userId), eq(userGameStats.gameId, a.gameId)), columns: { spins: true } });
47+ const newGame = !ugsExisting;
48+ const bonus = a.bonus || a.freeSpins;
49+ const cls = classifyWin(a.multiplier);
50+ const isBigWin = cls !== "none" && cls !== "regular" && cls !== "win";
51+ const facts = { gameSlug: a.gameSlug, bet: a.bet, win: a.win, multiplier: a.multiplier, bonus, jackpot: !!a.jackpotTier, newGame };
52+
53+ const totalSpins = u.totalSpins + 1;
54+ const gamesPlayed = u.gamesPlayed + (newGame ? 1 : 0);
55+ const biggestWin = Math.max(u.biggestWin, a.win);
56+ const biggestMultiplier = Math.max(Number(u.biggestMultiplier), a.multiplier);
57+
58+ const agg = await tx.execute(sql`select coalesce(sum(bonuses),0)::bigint as bonuses from user_game_stats where user_id = ${userId}`);
59+ const bonusesBefore = Number((agg.rows[0] as { bonuses: number }).bonuses);
60+ const bigAgg = await tx.execute(sql`
61+ select count(*) filter (where multiplier >= 20)::bigint as big_wins,
62+ count(*) filter (where win > 0)::bigint as wins,
63+ count(*) filter (where jackpot_tier is not null)::bigint as jackpots
64+ from game_rounds where user_id = ${userId}`);
65+ const ba = bigAgg.rows[0] as { big_wins: number; wins: number; jackpots: number };
66+ const streak = await tx.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, userId), columns: { streakDay: true } });
67+
68+ await tx.insert(gameRounds).values({
69+ roundId: a.roundId,
70+ userId,
71+ gameId: a.gameId,
72+ gameSlug: a.gameSlug,
73+ gameVersion: a.gameVersion,
74+ clientRoundId: a.clientRoundId,
75+ bet: a.bet,
76+ win: a.win,
77+ multiplier: a.multiplier.toFixed(4),
78+ balanceAfter: wallet.balance,
79+ result: a.result,
80+ features: a.features,
81+ freeSpins: a.freeSpins,
82+ bonus: a.bonus,
83+ jackpotTier: a.jackpotTier,
84+ rngReference: a.rngReference,
85+ durationMs: a.durationMs,
86+ });
87+
88+ if (a.stateAfter) {
89+ await tx
90+ .insert(gameStates)
91+ .values({ userId, gameId: a.gameId, state: a.stateAfter })
92+ .onConflictDoUpdate({ target: [gameStates.userId, gameStates.gameId], set: { state: a.stateAfter, updatedAt: new Date() } });
93+ }
94+
95+ await tx
96+ .insert(userGameStats)
97+ .values({ userId, gameId: a.gameId, spins: 1, wagered: a.bet, won: a.win, bonuses: bonus ? 1 : 0, biggestWin: a.win, biggestMultiplier: a.multiplier.toFixed(2) })
98+ .onConflictDoUpdate({
99+ target: [userGameStats.userId, userGameStats.gameId],
100+ set: {
101+ spins: sql`${userGameStats.spins} + 1`,
102+ wagered: sql`${userGameStats.wagered} + ${a.bet}`,
103+ won: sql`${userGameStats.won} + ${a.win}`,
104+ bonuses: sql`${userGameStats.bonuses} + ${bonus ? 1 : 0}`,
105+ biggestWin: sql`greatest(${userGameStats.biggestWin}, ${a.win})`,
106+ biggestMultiplier: sql`greatest(${userGameStats.biggestMultiplier}, ${a.multiplier.toFixed(2)}::numeric)`,
107+ lastPlayedAt: new Date(),
108+ },
109+ });
110+
111+ await tx
112+ .update(gameStatistics)
113+ .set({
114+ spins: sql`${gameStatistics.spins} + 1`,
115+ wagered: sql`${gameStatistics.wagered} + ${a.bet}`,
116+ won: sql`${gameStatistics.won} + ${a.win}`,
117+ wins: sql`${gameStatistics.wins} + ${a.win > 0 ? 1 : 0}`,
118+ bonuses: sql`${gameStatistics.bonuses} + ${a.bonus ? 1 : 0}`,
119+ freeSpins: sql`${gameStatistics.freeSpins} + ${a.freeSpins ? 1 : 0}`,
120+ bigWins: sql`${gameStatistics.bigWins} + ${isBigWin ? 1 : 0}`,
121+ maxWin: sql`greatest(${gameStatistics.maxWin}, ${a.win})`,
122+ maxMultiplier: sql`greatest(${gameStatistics.maxMultiplier}, ${a.multiplier.toFixed(2)}::numeric)`,
123+ updatedAt: new Date(),
124+ })
125+ .where(eq(gameStatistics.gameId, a.gameId));
126+
127+ let xpGain = spinXp(facts);
128+ const counters: UserCounters = {
129+ totalSpins,
130+ gamesPlayed,
131+ biggestWin,
132+ biggestMultiplier,
133+ level: u.level,
134+ xp: u.xp,
135+ bonuses: bonusesBefore + (bonus ? 1 : 0),
136+ bigWins: Number(ba.big_wins) + (isBigWin ? 1 : 0),
137+ wins: Number(ba.wins) + (a.win > 0 ? 1 : 0),
138+ jackpots: Number(ba.jackpots) + (a.jackpotTier ? 1 : 0),
139+ dailyStreak: streak?.streakDay ?? 0,
140+ };
141+ const ach = await checkAchievements(tx, wallet, counters);
142+ const mis = await advanceMissions(tx, wallet, facts);
143+ xpGain += ach.xp + mis.xp;
144+ const lvl = await awardXp(tx, wallet, { xp: u.xp, level: u.level }, xpGain, a.roundId);
145+ if (lvl.leveledUp) {
146+ counters.level = lvl.level;
147+ const ach2 = await checkAchievements(tx, wallet, counters);
148+ ach.unlocked.push(...ach2.unlocked);
149+ }
150+
151+ await tx.update(users).set({ totalSpins, gamesPlayed, biggestWin, biggestMultiplier: biggestMultiplier.toFixed(2) }).where(eq(users.id, userId));
152+
153+ const settings = await tx.query.userSettings.findFirst({ where: eq(userSettings.userId, userId), columns: { leaderboardOptIn: true } });
154+ await upsertLeaderboards(tx, userId, facts, a.roundId, { totalSpins, level: lvl.level }, settings?.leaderboardOptIn ?? true);
155+
156+ return {
157+ xp: { gained: xpGain, total: lvl.xp, level: lvl.level, leveledUp: lvl.leveledUp, levelReward: lvl.reward },
158+ unlocked: { achievements: ach.unlocked, missions: mis.completed },
159+ winClass: cls,
160+ };
161+}
modified apps/api/src/services/spin.ts +8 −130
@@ -1,4 +1,4 @@
1−import { and, db, eq, gameRounds, gameStates, gameStatistics, games, sql, userGameStats, userSettings, users, dailyRewards } from "@spinza/database"; 1+import { and, db, eq, gameRounds, games, sql, users } from "@spinza/database";
2 import { runSpin, type PlayerGameState, type SpinOutcome } from "@spinza/game-core"; 2 import { runSpin, type PlayerGameState, type SpinOutcome } from "@spinza/game-core";
3 import { getGame } from "@spinza/games"; 3 import { getGame } from "@spinza/games";
4 import { BET_LEVELS, classifyWin, type SpinResponse } from "@spinza/shared"; 4 import { BET_LEVELS, classifyWin, type SpinResponse } from "@spinza/shared";
@@ -6,7 +6,7 @@ import { errors } from "../lib/errors";
6 import { newRoundId } from "../lib/crypto"; 6 import { newRoundId } from "../lib/crypto";
7 import { flag, maintenance } from "../lib/settings"; 7 import { flag, maintenance } from "../lib/settings";
8 import { applyCredit, lockWallet, saveWallet } from "./wallet"; 8 import { applyCredit, lockWallet, saveWallet } from "./wallet";
9−import { advanceMissions, awardXp, checkAchievements, spinXp, upsertLeaderboards, type UserCounters } from "./progression"; 9+import { settleRound } from "./settle";
10 import { redis } from "../lib/redis"; 10 import { redis } from "../lib/redis";
11 import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg"; 11 import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";
12 12
@@ -74,57 +74,15 @@ export async function spin(input: SpinInput): Promise<SpinResponse> {
74 await applyCredit(tx, wallet, "BET", -input.bet, roundId, { game: def.slug }); 74 await applyCredit(tx, wallet, "BET", -input.bet, roundId, { game: def.slug });
75 if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: def.slug, multiplier: outcome.multiplier }); 75 if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: def.slug, multiplier: outcome.multiplier });
76 76
77− // User counters. 77+ const settled = await settleRound(tx, wallet, {
78− const [u] = await tx
79− .select({
80− xp: users.xp,
81− level: users.level,
82− totalSpins: users.totalSpins,
83− gamesPlayed: users.gamesPlayed,
84− biggestWin: users.biggestWin,
85− biggestMultiplier: users.biggestMultiplier,
86− })
87− .from(users)
88− .where(eq(users.id, input.userId))
89− .for("update");
90−
91− const ugsExisting = await tx.query.userGameStats.findFirst({ where: and(eq(userGameStats.userId, input.userId), eq(userGameStats.gameId, game.id)) });
92− const newGame = !ugsExisting;
93− const bonus = outcome.bonusTriggered || outcome.freeSpinsTriggered;
94− const cls = classifyWin(outcome.multiplier);
95− const isBigWin = cls !== "none" && cls !== "regular" && cls !== "win";
96−
97− const facts = { gameSlug: def.slug, bet: input.bet, win: outcome.totalWin, multiplier: outcome.multiplier, bonus, jackpot: !!outcome.jackpot, newGame };
98−
99− const totalSpins = u.totalSpins + 1;
100− const gamesPlayed = u.gamesPlayed + (newGame ? 1 : 0);
101− const biggestWin = Math.max(u.biggestWin, outcome.totalWin);
102− const biggestMultiplier = Math.max(Number(u.biggestMultiplier), outcome.multiplier);
103−
104− // Aggregate counters for achievements (bonuses / big wins / wins / jackpots from ledger-independent stats).
105− const agg = await tx.execute(sql`
106− select coalesce(sum(bonuses),0)::bigint as bonuses from user_game_stats where user_id = ${input.userId}`);
107− const bonusesBefore = Number((agg.rows[0] as { bonuses: number }).bonuses);
108− const bigAgg = await tx.execute(sql`
109− select count(*) filter (where multiplier >= 20)::bigint as big_wins,
110− count(*) filter (where win > 0)::bigint as wins,
111− count(*) filter (where jackpot_tier is not null)::bigint as jackpots
112− from game_rounds where user_id = ${input.userId}`);
113− const ba = bigAgg.rows[0] as { big_wins: number; wins: number; jackpots: number };
114− const streak = await tx.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, input.userId), columns: { streakDay: true } });
115−
116− // Round row (unique on user+clientRoundId → duplicate protection).
117− await tx.insert(gameRounds).values({
118 roundId, 78 roundId,
119− userId: input.userId,
120 gameId: game.id, 79 gameId: game.id,
121 gameSlug: def.slug, 80 gameSlug: def.slug,
122 gameVersion: def.version, 81 gameVersion: def.version,
123 clientRoundId: input.clientRoundId, 82 clientRoundId: input.clientRoundId,
124 bet: input.bet, 83 bet: input.bet,
125 win: outcome.totalWin, 84 win: outcome.totalWin,
126− multiplier: outcome.multiplier.toFixed(4), 85+ multiplier: outcome.multiplier,
127− balanceAfter: wallet.balance,
128 result: storableResult(outcome), 86 result: storableResult(outcome),
129 features: outcome.features, 87 features: outcome.features,
130 freeSpins: outcome.freeSpinsTriggered, 88 freeSpins: outcome.freeSpinsTriggered,
@@ -132,89 +90,9 @@ export async function spin(input: SpinInput): Promise<SpinResponse> {
132 jackpotTier: outcome.jackpot?.tier ?? null, 90 jackpotTier: outcome.jackpot?.tier ?? null,
133 rngReference: outcome.rngReference, 91 rngReference: outcome.rngReference,
134 durationMs: Date.now() - started, 92 durationMs: Date.now() - started,
93+ stateAfter: outcome.stateAfter as unknown as Record<string, unknown>,
135 }); 94 });
136− 95+ const cls = settled.winClass;
137− // Persistent game state.
138− await tx
139− .insert(gameStates)
140− .values({ userId: input.userId, gameId: game.id, state: outcome.stateAfter as unknown as Record<string, unknown> })
141− .onConflictDoUpdate({ target: [gameStates.userId, gameStates.gameId], set: { state: outcome.stateAfter as unknown as Record<string, unknown>, updatedAt: new Date() } });
142−
143− // Per-user per-game stats.
144− await tx
145− .insert(userGameStats)
146− .values({
147− userId: input.userId,
148− gameId: game.id,
149− spins: 1,
150− wagered: input.bet,
151− won: outcome.totalWin,
152− bonuses: bonus ? 1 : 0,
153− biggestWin: outcome.totalWin,
154− biggestMultiplier: outcome.multiplier.toFixed(2),
155− })
156− .onConflictDoUpdate({
157− target: [userGameStats.userId, userGameStats.gameId],
158− set: {
159− spins: sql`${userGameStats.spins} + 1`,
160− wagered: sql`${userGameStats.wagered} + ${input.bet}`,
161− won: sql`${userGameStats.won} + ${outcome.totalWin}`,
162− bonuses: sql`${userGameStats.bonuses} + ${bonus ? 1 : 0}`,
163− biggestWin: sql`greatest(${userGameStats.biggestWin}, ${outcome.totalWin})`,
164− biggestMultiplier: sql`greatest(${userGameStats.biggestMultiplier}, ${outcome.multiplier.toFixed(2)}::numeric)`,
165− lastPlayedAt: new Date(),
166− },
167− });
168−
169− // Global game statistics.
170− await tx
171− .update(gameStatistics)
172− .set({
173− spins: sql`${gameStatistics.spins} + 1`,
174− wagered: sql`${gameStatistics.wagered} + ${input.bet}`,
175− won: sql`${gameStatistics.won} + ${outcome.totalWin}`,
176− wins: sql`${gameStatistics.wins} + ${outcome.totalWin > 0 ? 1 : 0}`,
177− bonuses: sql`${gameStatistics.bonuses} + ${outcome.bonusTriggered ? 1 : 0}`,
178− freeSpins: sql`${gameStatistics.freeSpins} + ${outcome.freeSpinsTriggered ? 1 : 0}`,
179− bigWins: sql`${gameStatistics.bigWins} + ${isBigWin ? 1 : 0}`,
180− maxWin: sql`greatest(${gameStatistics.maxWin}, ${outcome.totalWin})`,
181− maxMultiplier: sql`greatest(${gameStatistics.maxMultiplier}, ${outcome.multiplier.toFixed(2)}::numeric)`,
182− updatedAt: new Date(),
183− })
184− .where(eq(gameStatistics.gameId, game.id));
185−
186− // XP, achievements, missions.
187− let xpGain = spinXp(facts);
188− const counters: UserCounters = {
189− totalSpins,
190− gamesPlayed,
191− biggestWin,
192− biggestMultiplier,
193− level: u.level,
194− xp: u.xp,
195− bonuses: bonusesBefore + (bonus ? 1 : 0),
196− bigWins: Number(ba.big_wins) + (isBigWin ? 1 : 0),
197− wins: Number(ba.wins) + (outcome.totalWin > 0 ? 1 : 0),
198− jackpots: Number(ba.jackpots) + (outcome.jackpot ? 1 : 0),
199− dailyStreak: streak?.streakDay ?? 0,
200− };
201− const ach = await checkAchievements(tx, wallet, counters);
202− const mis = await advanceMissions(tx, wallet, facts);
203− xpGain += ach.xp + mis.xp;
204− const lvl = await awardXp(tx, wallet, { xp: u.xp, level: u.level }, xpGain, roundId);
205− if (lvl.leveledUp) {
206− counters.level = lvl.level;
207− const ach2 = await checkAchievements(tx, wallet, counters);
208− ach.unlocked.push(...ach2.unlocked);
209− }
210−
211− await tx
212− .update(users)
213− .set({ totalSpins, gamesPlayed, biggestWin, biggestMultiplier: biggestMultiplier.toFixed(2) })
214− .where(eq(users.id, input.userId));
215−
216− const settings = await tx.query.userSettings.findFirst({ where: eq(userSettings.userId, input.userId), columns: { leaderboardOptIn: true } });
217− await upsertLeaderboards(tx, input.userId, facts, roundId, { totalSpins, level: lvl.level }, settings?.leaderboardOptIn ?? true);
218 96
219 // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards. 97 // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards.
220 await saveWallet(tx, wallet); 98 await saveWallet(tx, wallet);
@@ -238,9 +116,9 @@ export async function spin(input: SpinInput): Promise<SpinResponse> {
238 multiplier: outcome.multiplier, 116 multiplier: outcome.multiplier,
239 winClass: cls, 117 winClass: cls,
240 balance: wallet.balance, 118 balance: wallet.balance,
241− xp: { gained: xpGain, total: lvl.xp, level: lvl.level, leveledUp: lvl.leveledUp, levelReward: lvl.reward }, 119+ xp: settled.xp,
242 result: storableResult(outcome), 120 result: storableResult(outcome),
243− unlocked: { achievements: ach.unlocked, missions: mis.completed }, 121+ unlocked: settled.unlocked,
244 } satisfies SpinResponse; 122 } satisfies SpinResponse;
245 }); 123 });
246 } catch (e) { 124 } catch (e) {
added apps/api/test/crash.test.ts +99 −0
@@ -0,0 +1,99 @@
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 { verifyCommitment } from "@spinza/game-core";
6+import { buildApp } from "../src/app";
7+import { closeRedis } from "../src/lib/redis";
8+import type { FastifyInstance } from "fastify";
9+
10+let app: FastifyInstance;
11+let cookie = "";
12+let userId = "";
13+const username = `c_${randomUUID().slice(0, 8)}`;
14+const origin = "http://localhost:8230";
15+const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
16+
17+beforeAll(async () => {
18+ process.env.NODE_ENV = "test";
19+ app = await buildApp();
20+ await app.ready();
21+ await syncGames(db);
22+ 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 } });
24+ cookie = res.cookies.find((c) => c.name === "spinza_session")!.value;
25+ userId = res.json().user.id;
26+});
27+
28+afterAll(async () => {
29+ if (userId) await db.delete(users).where(eq(users.id, userId));
30+ await app.close();
31+ await closeDb();
32+ await closeRedis();
33+});
34+
35+const headers = () => ({ origin, cookie: `spinza_session=${cookie}` });
36+
37+describe("crash games", () => {
38+ it("starts a round (bet debited, commitment given, crash hidden) and cashes out at the displayed multiplier", async () => {
39+ const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 100, clientRoundId: randomUUID() } });
40+ expect(start.statusCode).toBe(200);
41+ const r = start.json().round;
42+ expect(r.status).toBe("running");
43+ expect(r.crashMultiplier).toBeNull();
44+ expect(r.seed).toBeNull();
45+ expect(r.commitment).toHaveLength(64);
46+ expect(start.json().balance).toBe(9900);
47+ await wait(150);
48+ const out = await app.inject({ method: "POST", url: `/api/crash/skyfall/cashout`, headers: headers(), payload: { roundId: r.roundId, claimedMultiplier: 1.0 } });
49+ expect(out.statusCode).toBe(200);
50+ const settled = out.json().round;
51+ expect(["cashed", "crashed"]).toContain(settled.status);
52+ expect(settled.crashMultiplier).toBeGreaterThanOrEqual(1);
53+ expect(verifyCommitment(settled.seed, settled.crashMultiplier, settled.commitment)).toBe(true);
54+ if (settled.status === "cashed") {
55+ expect(settled.cashoutMultiplier).toBe(1);
56+ expect(settled.win).toBe(100);
57+ expect(out.json().balance).toBe(10000);
58+ }
59+ const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));
60+ expect(rounds).toHaveLength(1);
61+ const ledger = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));
62+ expect(ledger.filter((t) => t.type === "BET")).toHaveLength(1);
63+ });
64+
65+ it("refuses a cash-out above the server multiplier and never pays past the crash", async () => {
66+ const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 50, clientRoundId: randomUUID() } });
67+ const r = start.json().round;
68+ const out = await app.inject({ method: "POST", url: `/api/crash/skyfall/cashout`, headers: headers(), payload: { roundId: r.roundId, claimedMultiplier: 9999 } });
69+ const s = out.json().round;
70+ if (s.status === "cashed") {
71+ expect(s.cashoutMultiplier).toBeLessThan(s.crashMultiplier);
72+ expect(s.cashoutMultiplier).toBeLessThanOrEqual(1.05); // ~0 s elapsed → server multiplier ≈ 1.00
73+ } else expect(s.win).toBe(0);
74+ });
75+
76+ it("start is idempotent and only one round may run at a time", async () => {
77+ const id = randomUUID();
78+ const a = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: id } });
79+ const b = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: id } });
80+ expect(b.json().round.roundId).toBe(a.json().round.roundId);
81+ expect(b.json().replayed).toBe(true);
82+ const c = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } });
83+ expect([409, 200]).toContain(c.statusCode); // 200 only if the first round already crashed (instant crash)
84+ await app.inject({ method: "POST", url: `/api/crash/elevator-999/cashout`, headers: headers(), payload: { roundId: a.json().round.roundId } });
85+ const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId));
86+ const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) });
87+ expect(Number(total)).toBe(w!.balance);
88+ });
89+
90+ it("auto cash-out settles at the target when reached", async () => {
91+ const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID(), autoCashout: 1.01 } });
92+ const r = start.json().round;
93+ await wait(400); // curve reaches 1.01 in ~130 ms (k = 0.075)
94+ const st = await app.inject({ method: "GET", url: `/api/crash/skyfall/rounds/${r.roundId}`, headers: headers() });
95+ const s = st.json().round;
96+ expect(["cashed", "crashed"]).toContain(s.status);
97+ if (s.status === "cashed") expect(s.cashoutMultiplier).toBe(1.01);
98+ });
99+});
modified apps/web/src/app/games/[slug]/page.tsx +5 −2
@@ -2,8 +2,10 @@ 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 { ClientDefinition } from "@/components/game/types"; 6 import type { ClientDefinition } from "@/components/game/types";
6 import { GameClient } from "@/components/game/game-client"; 7 import { GameClient } from "@/components/game/game-client";
8+import { CrashClient } from "@/components/crash/crash-client";
7 9
8 type Params = { params: Promise<{ slug: string }> }; 10 type Params = { params: Promise<{ slug: string }> };
9 11
@@ -16,7 +18,8 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
16 18
17 export default async function GamePage({ params }: Params) { 19 export default async function GamePage({ params }: Params) {
18 const { slug } = await params; 20 const { slug } = await params;
19− const data = await apiServer<{ game: GameInfo; definition: ClientDefinition }>(`/api/games/${slug}`); 21+ const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition }>(`/api/games/${slug}`);
20 if (!data) notFound(); 22 if (!data) notFound();
21− return <GameClient game={data.game} definition={data.definition} />; 23+ if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />;
24+ return <GameClient game={data.game} definition={data.definition as ClientDefinition} />;
22 } 25 }
added apps/web/src/components/crash/crash-client.tsx +598 −0
@@ -0,0 +1,598 @@
1+"use client";
2+
3+import { useCallback, useEffect, useMemo, useRef, useState } 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, History, Info, Minus, Plus, ShieldCheck, Volume2, VolumeX } from "lucide-react";
8+import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared";
9+import { floorAt, multiplierAt, type CrashEvent, type CrashGameDefinition } from "@spinza/game-core/client";
10+import { api, ApiClientError } from "@/lib/api";
11+import { toast, useSession } from "@/lib/store";
12+import { cn } from "@/lib/utils";
13+import { Button, Credits, Sheet, Tabs } from "@/components/ui";
14+import { SpinzaMark } from "@/components/brand/logo";
15+import { getSound } from "@/components/game/sound";
16+import { SCENES, progressFor } from "./scenes";
17+
18+interface RoundView {
19+ roundId: string;
20+ game: string;
21+ version: string;
22+ bet: number;
23+ status: "running" | "cashed" | "crashed";
24+ startedAt: string;
25+ serverNow: number;
26+ elapsedMs: number;
27+ commitment: string;
28+ events: CrashEvent[];
29+ autoCashout: number | null;
30+ crashMultiplier: number | null;
31+ seed: string | null;
32+ cashoutMultiplier: number | null;
33+ win: number | null;
34+}
35+
36+interface Progression {
37+ xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number };
38+ unlocked: { achievements: string[]; missions: string[] };
39+}
40+
41+type Phase = "idle" | "starting" | "running" | "cashed" | "crashed";
42+
43+const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
44+
45+export function CrashClient({ game, definition }: { game: GameInfo; definition: CrashGameDefinition }) {
46+ const router = useRouter();
47+ const { status, wallet, settings, setBalance, setUserXp } = useSession();
48+ const canvasRef = useRef<HTMLCanvasElement>(null);
49+ const soundRef = useRef(getSound());
50+ const [bet, setBet] = useState(100);
51+ const [autoOn, setAutoOn] = useState(false);
52+ const [autoTarget, setAutoTarget] = useState("2.00");
53+ const [phase, setPhase] = useState<Phase>("idle");
54+ const [round, setRound] = useState<RoundView | null>(null);
55+ const [display, setDisplay] = useState(1);
56+ const [history, setHistory] = useState<{ crash: number; at: string }[]>([]);
57+ const [lastResult, setLastResult] = useState<{ status: "cashed" | "crashed"; multiplier: number; win: number; crash: number } | null>(null);
58+ const [error, setError] = useState<string | null>(null);
59+ const [infoSheet, setInfoSheet] = useState(false);
60+ const [historySheet, setHistorySheet] = useState(false);
61+ const [fairSheet, setFairSheet] = useState(false);
62+ const [favorite, setFavorite] = useState(!!game.favorite);
63+ const [activeEvent, setActiveEvent] = useState<string | null>(null);
64+ const [milestone, setMilestone] = useState<string | null>(null);
65+ const offsetRef = useRef(0); // serverNow - clientNow
66+ const phaseRef = useRef<Phase>("idle");
67+ const phaseAtRef = useRef(0);
68+ const roundRef = useRef<RoundView | null>(null);
69+ const displayRef = useRef(1);
70+ const cashingRef = useRef(false);
71+ const shownMilestones = useRef(new Set<number>());
72+ const soundOn = settings?.soundEnabled ?? true;
73+ const palette = definition.presentation.palette;
74+ const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]);
75+ const balance = wallet?.balance ?? 0;
76+
77+ const setPhaseBoth = useCallback((p: Phase) => {
78+ phaseRef.current = p;
79+ phaseAtRef.current = performance.now();
80+ setPhase(p);
81+ }, []);
82+
83+ useEffect(() => {
84+ if (status === "guest") router.replace(`/login?next=/games/${game.slug}`);
85+ }, [status, router, game.slug]);
86+
87+ const adoptRound = useCallback((r: RoundView) => {
88+ offsetRef.current = r.serverNow - Date.now();
89+ roundRef.current = r;
90+ setRound(r);
91+ shownMilestones.current.clear();
92+ setLastResult(null);
93+ setPhaseBoth("running");
94+ }, [setPhaseBoth]);
95+
96+ const finish = useCallback((r: RoundView, bal: number | null, prog: Progression | null) => {
97+ if (phaseRef.current !== "running") return;
98+ roundRef.current = r;
99+ setRound(r);
100+ const cashed = r.status === "cashed";
101+ setPhaseBoth(cashed ? "cashed" : "crashed");
102+ const m = cashed ? (r.cashoutMultiplier ?? 0) : (r.crashMultiplier ?? 0);
103+ displayRef.current = m;
104+ setDisplay(m);
105+ setLastResult({ status: cashed ? "cashed" : "crashed", multiplier: m, win: r.win ?? 0, crash: r.crashMultiplier ?? 0 });
106+ if (bal !== null) setBalance(bal);
107+ if (cashed) {
108+ const cls = classifyWin(m);
109+ if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") soundRef.current.bigWin();
110+ else soundRef.current.win(2);
111+ } else soundRef.current.error();
112+ if (prog) {
113+ setUserXp(prog.xp.total, prog.xp.level);
114+ if (prog.xp.leveledUp) toast({ title: `Level ${prog.xp.level} reached`, description: `+${formatSC(prog.xp.levelReward)} level reward`, tone: "credit" });
115+ for (const a of prog.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" });
116+ }
117+ setHistory((h) => [{ crash: r.crashMultiplier ?? 0, at: new Date().toISOString() }, ...h].slice(0, 24));
118+ cashingRef.current = false;
119+ }, [setBalance, setUserXp, setPhaseBoth]);
120+
121+
122+ // Load history + resume a running round.
123+ useEffect(() => {
124+ if (status !== "authenticated") return;
125+ api<{ history: { crash: number; at: string }[] }>(`/api/crash/${game.slug}/history`).then((r) => setHistory(r.history)).catch(() => {});
126+ api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {});
127+ api<{ round: RoundView | null }>(`/api/crash/${game.slug}/current`)
128+ .then((r) => {
129+ if (r.round && r.round.status === "running") adoptRound(r.round);
130+ })
131+ .catch(() => {});
132+ // eslint-disable-next-line react-hooks/exhaustive-deps
133+ }, [status, game.slug]);
134+
135+ useEffect(() => {
136+ const s = soundRef.current;
137+ s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 });
138+ s.setAmbience(definition.presentation.ambience);
139+ return () => s.destroy();
140+ }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience]);
141+
142+
143+ /* ------------------------------------------------------- animation loop */
144+ useEffect(() => {
145+ const canvas = canvasRef.current;
146+ if (!canvas) return;
147+ const ctx = canvas.getContext("2d");
148+ if (!ctx) return;
149+ let raf = 0;
150+ let lastTick = 0;
151+ const start = performance.now();
152+ const loop = () => {
153+ const dpr = Math.min(2, window.devicePixelRatio || 1);
154+ const rect = canvas.getBoundingClientRect();
155+ if (canvas.width !== Math.round(rect.width * dpr) || canvas.height !== Math.round(rect.height * dpr)) {
156+ canvas.width = Math.round(rect.width * dpr);
157+ canvas.height = Math.round(rect.height * dpr);
158+ }
159+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
160+ const r = roundRef.current;
161+ const ph = phaseRef.current;
162+ let m = displayRef.current;
163+ let t = (performance.now() - start) / 1000;
164+ let event: string | null = null;
165+ if (r && ph === "running") {
166+ const elapsed = Date.now() + offsetRef.current - new Date(r.startedAt).getTime();
167+ m = multiplierAt(definition, r.events, elapsed);
168+ t = elapsed / 1000;
169+ const ev = r.events.find((e) => elapsed >= e.startMs && elapsed < e.endMs);
170+ event = ev?.label ?? null;
171+ if (m !== displayRef.current) {
172+ displayRef.current = m;
173+ setDisplay(m);
174+ if (performance.now() - lastTick > 90) {
175+ lastTick = performance.now();
176+ soundRef.current.tick();
177+ }
178+ }
179+ for (const ms of definition.milestones) {
180+ if (m >= ms.multiplier && !shownMilestones.current.has(ms.multiplier)) {
181+ shownMilestones.current.add(ms.multiplier);
182+ setMilestone(ms.label);
183+ setTimeout(() => setMilestone(null), 1800);
184+ }
185+ }
186+ } else if (r && (ph === "cashed" || ph === "crashed")) {
187+ m = ph === "cashed" ? (r.cashoutMultiplier ?? displayRef.current) : (r.crashMultiplier ?? displayRef.current);
188+ t = r.status !== "running" ? (r.elapsedMs ?? 0) / 1000 : t;
189+ }
190+ setActiveEvent((prev) => (prev === event ? prev : event));
191+ const painter = SCENES[definition.presentation.scene];
192+ ctx.save();
193+ painter({
194+ ctx,
195+ w: rect.width,
196+ h: rect.height,
197+ p: progressFor(m),
198+ t,
199+ multiplier: m,
200+ phase: ph === "starting" || ph === "idle" ? "idle" : ph,
201+ phaseT: (performance.now() - phaseAtRef.current) / 1000,
202+ palette,
203+ event,
204+ floor: floorAt(definition, m),
205+ reduceMotion: settings?.reduceMotion ?? false,
206+ });
207+ ctx.restore();
208+ raf = requestAnimationFrame(loop);
209+ };
210+ raf = requestAnimationFrame(loop);
211+ return () => cancelAnimationFrame(raf);
212+ }, [definition, palette, settings?.reduceMotion]);
213+
214+ /* --------------------------------------------------------------- polling */
215+ const roundId = round?.roundId;
216+ useEffect(() => {
217+ if (phase !== "running" || !roundId) return;
218+ let alive = true;
219+ const poll = async () => {
220+ while (alive && phaseRef.current === "running") {
221+ try {
222+ const r = await api<{ round: RoundView; balance: number | null; progression: Progression | null }>(`/api/crash/${game.slug}/rounds/${roundId}`);
223+ offsetRef.current = r.round.serverNow - Date.now();
224+ if (r.round.status !== "running") {
225+ finish(r.round, r.balance, r.progression);
226+ break;
227+ }
228+ } catch {
229+ /* keep polling */
230+ }
231+ await wait(350);
232+ }
233+ };
234+ void poll();
235+ return () => {
236+ alive = false;
237+ };
238+ }, [phase, roundId, finish, game.slug]);
239+
240+ /* ---------------------------------------------------------------- actions */
241+ const start = useCallback(async () => {
242+ if (phaseRef.current === "running" || phaseRef.current === "starting") return;
243+ if (balance < bet) {
244+ setError("Not enough Spinza Credits for this bet.");
245+ soundRef.current.error();
246+ return;
247+ }
248+ setError(null);
249+ soundRef.current.unlock();
250+ soundRef.current.spinStart();
251+ setPhaseBoth("starting");
252+ setLastResult(null);
253+ displayRef.current = 1;
254+ setDisplay(1);
255+ const auto = autoOn ? Number(autoTarget) : null;
256+ try {
257+ const r = await api<{ round: RoundView; balance: number | null }>(`/api/crash/${game.slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID(), autoCashout: auto && auto >= 1.01 ? Math.round(auto * 100) / 100 : undefined } });
258+ if (r.balance !== null) setBalance(r.balance);
259+ adoptRound(r.round);
260+ } catch (e) {
261+ setPhaseBoth("idle");
262+ if (e instanceof ApiClientError) {
263+ if (e.status === 401) router.replace(`/login?next=/games/${game.slug}`);
264+ else if (e.code === "ROUND_IN_PROGRESS") api<{ round: RoundView | null }>(`/api/crash/${game.slug}/current`).then((c) => c.round && adoptRound(c.round)).catch(() => {});
265+ else setError(e.message);
266+ } else setError("Connection lost. Try again.");
267+ soundRef.current.error();
268+ }
269+ }, [balance, bet, autoOn, autoTarget, game.slug, router, setBalance, adoptRound, setPhaseBoth]);
270+
271+ const cashout = useCallback(async () => {
272+ const r = roundRef.current;
273+ if (!r || phaseRef.current !== "running" || cashingRef.current) return;
274+ cashingRef.current = true;
275+ soundRef.current.click();
276+ try {
277+ const res = await api<{ round: RoundView; balance: number; progression: Progression | null }>(`/api/crash/${game.slug}/cashout`, { json: { roundId: r.roundId, claimedMultiplier: displayRef.current } });
278+ finish(res.round, res.balance, res.progression);
279+ } catch {
280+ cashingRef.current = false;
281+ }
282+ }, [game.slug, finish]);
283+
284+ useEffect(() => {
285+ const onKey = (e: KeyboardEvent) => {
286+ if (e.code === "Space" && !e.repeat && !infoSheet && !historySheet && !fairSheet) {
287+ e.preventDefault();
288+ if (phaseRef.current === "running") void cashout();
289+ else void start();
290+ }
291+ };
292+ window.addEventListener("keydown", onKey);
293+ return () => window.removeEventListener("keydown", onKey);
294+ }, [start, cashout, infoSheet, historySheet, fairSheet]);
295+
296+ const toggleFavorite = async () => {
297+ setFavorite((f) => !f);
298+ try {
299+ const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });
300+ setFavorite(r.favorite);
301+ } catch {
302+ setFavorite((f) => !f);
303+ }
304+ };
305+
306+ if (status === "guest") return null;
307+ const running = phase === "running";
308+ const betIndex = bets.indexOf(bet);
309+ const unitValue = definition.curve.type === "steps" ? floorAt(definition, display) : Math.round((display - 1) * definition.presentation.unitScale);
310+ const potential = Math.round(bet * display);
311+ const cls = lastResult?.status === "cashed" ? classifyWin(lastResult.multiplier) : "none";
312+ const winLabel = WIN_CLASSES.find((c) => c.id === cls)?.label;
313+
314+ return (
315+ <div className="fixed inset-0 flex flex-col" style={{ background: palette.bg }}>
316+ {/* Top bar */}
317+ <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>
318+ <div className="flex items-center gap-2">
319+ <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby">
320+ <ArrowLeft className="h-5 w-5" />
321+ </Link>
322+ <div className="leading-tight">
323+ <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>
324+ <div className="text-[11px] text-fg-3">Risk game · cash out anytime</div>
325+ </div>
326+ </div>
327+ <div className="flex items-center gap-1">
328+ <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite">
329+ <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />
330+ </button>
331+ <button onClick={() => setHistorySheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="History">
332+ <History className="h-5 w-5" />
333+ </button>
334+ <button onClick={() => setFairSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Provably fair">
335+ <ShieldCheck className="h-5 w-5" />
336+ </button>
337+ <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">
338+ <Info className="h-5 w-5" />
339+ </button>
340+ <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">
341+ {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
342+ </button>
343+ </div>
344+ </div>
345+
346+ {/* History strip */}
347+ <div className="flex gap-1.5 overflow-x-auto px-3 pb-2 scrollbar-none">
348+ {history.length === 0 ? <span className="text-[11px] text-fg-4">No rounds yet — be the first.</span> : null}
349+ {history.map((h, i) => (
350+ <span key={i} className={cn("shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold tabular", h.crash < 1.5 ? "bg-danger/15 text-danger" : h.crash < 3 ? "bg-white/10 text-fg-2" : h.crash < 10 ? "bg-success/15 text-success" : "bg-accent-soft text-accent-2")}>
351+ {formatMultiplier(h.crash)}
352+ </span>
353+ ))}
354+ </div>
355+
356+ {/* Scene */}
357+ <div className="relative flex-1 min-h-0">
358+ <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
359+ {/* Multiplier */}
360+ <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-start pt-[9%]">
361+ <AnimatePresence mode="wait">
362+ {phase === "idle" || phase === "starting" ? (
363+ <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="text-center">
364+ {lastResult ? (
365+ <div className={cn("mb-3 rounded-full px-4 py-1.5 text-sm font-semibold", lastResult.status === "cashed" ? "bg-success/15 text-success" : "bg-danger/15 text-danger")}>
366+ {lastResult.status === "cashed" ? `Cashed out at ${formatMultiplier(lastResult.multiplier)} · +${formatSC(lastResult.win)}` : `Crashed at ${formatMultiplier(lastResult.crash)}`}
367+ </div>
368+ ) : null}
369+ <div className="text-[clamp(56px,14vw,120px)] font-extrabold leading-none tabular tracking-tight text-white/90" style={{ textShadow: `0 0 40px ${palette.glow}66` }}>
370+ {phase === "starting" ? "…" : "1.00×"}
371+ </div>
372+ <div className="mt-2 text-sm text-fg-2">{phase === "starting" ? "Starting round" : `Press ${definition.presentation.verb} before it ends`}</div>
373+ </motion.div>
374+ ) : (
375+ <motion.div key="live" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="text-center">
376+ <div
377+ className={cn("text-[clamp(64px,16vw,140px)] font-extrabold leading-none tabular tracking-tight", phase === "crashed" ? "text-danger" : phase === "cashed" ? "text-credit" : "text-white")}
378+ style={{ textShadow: phase === "running" ? `0 0 ${20 + progressFor(display) * 60}px ${palette.glow}` : undefined }}
379+ >
380+ {formatMultiplier(display)}
381+ </div>
382+ <div className="mt-2 text-sm text-fg-2 tabular">
383+ {definition.curve.type === "steps" ? `Floor ${unitValue}` : `${Math.abs(unitValue).toLocaleString("en-US")} ${definition.presentation.unit}`} · {running ? `${formatSC(potential)} on the line` : ""}
384+ </div>
385+ {phase === "cashed" && lastResult ? (
386+ <motion.div initial={{ y: 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="mt-4">
387+ {winLabel ? <div className="text-2xl font-extrabold uppercase tracking-tight shimmer-text">{winLabel}</div> : null}
388+ <div className="text-xl font-bold text-credit tabular">+{formatSC(lastResult.win)}</div>
389+ <div className="text-[12px] text-fg-3">The round would have ended at {formatMultiplier(lastResult.crash)}</div>
390+ </motion.div>
391+ ) : null}
392+ {phase === "crashed" ? (
393+ <motion.div initial={{ scale: 0.6 }} animate={{ scale: 1 }} className="mt-4 text-2xl font-extrabold uppercase tracking-tight text-danger">
394+ {crashWord(definition.presentation.scene)}
395+ </motion.div>
396+ ) : null}
397+ </motion.div>
398+ )}
399+ </AnimatePresence>
400+ <AnimatePresence>
401+ {activeEvent && running ? (
402+ <motion.div key={activeEvent} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute top-4 rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: `${palette.secondary}33`, color: palette.secondary }}>
403+ {activeEvent}
404+ </motion.div>
405+ ) : null}
406+ {milestone ? (
407+ <motion.div key={milestone} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute bottom-6 rounded-full bg-black/50 px-3 py-1 text-[12px] font-semibold text-fg-2">
408+ {milestone}
409+ </motion.div>
410+ ) : null}
411+ </AnimatePresence>
412+ </div>
413+ <AnimatePresence>
414+ {error ? (
415+ <motion.div 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">
416+ <div className="text-fg-2">{error}</div>
417+ <div className="mt-2 flex justify-center gap-2">
418+ <Button size="sm" variant="secondary" onClick={() => setError(null)}>
419+ Dismiss
420+ </Button>
421+ <Button size="sm" variant="accent" href="/rewards">
422+ Get rewards
423+ </Button>
424+ </div>
425+ </motion.div>
426+ ) : null}
427+ </AnimatePresence>
428+ </div>
429+
430+ {/* Controls */}
431+ <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` }}>
432+ <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3">
433+ <div className="flex flex-col gap-2">
434+ <div>
435+ <div className="eyebrow">Balance</div>
436+ <Credits amount={balance} size="md" />
437+ </div>
438+ <div className="flex items-center gap-1">
439+ <button disabled={running || 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">
440+ <Minus className="h-4 w-4" />
441+ </button>
442+ <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2">
443+ <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>
444+ <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>
445+ </div>
446+ <button disabled={running || 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">
447+ <Plus className="h-4 w-4" />
448+ </button>
449+ </div>
450+ </div>
451+
452+ <div className="flex flex-col items-center gap-2">
453+ {running ? (
454+ <button
455+ onClick={() => void cashout()}
456+ className="relative grid h-[92px] w-[92px] place-items-center rounded-full text-center text-[13px] font-extrabold leading-tight tracking-wide text-[#1a1406] transition-transform active:scale-95 focus-ring"
457+ style={{ background: `linear-gradient(180deg, #f3e2ad, #c9a961 60%, #9a7b3a)`, boxShadow: `0 0 0 6px rgba(201,169,97,0.25), 0 0 60px -10px rgba(201,169,97,0.9)` }}
458+ aria-label={definition.presentation.verb}
459+ >
460+ <span className="px-2">{definition.presentation.verb}</span>
461+ </button>
462+ ) : (
463+ <button
464+ onClick={() => void start()}
465+ disabled={phase === "starting"}
466+ className="relative grid h-[92px] w-[92px] place-items-center rounded-full text-center text-[15px] font-extrabold tracking-wide transition-transform active:scale-95 focus-ring disabled:opacity-70"
467+ style={{ background: `linear-gradient(180deg, ${palette.primary}, ${palette.secondary})`, boxShadow: `0 0 0 6px ${palette.primary}2e, 0 12px 50px -12px ${palette.primary}`, color: "#0b0a06" }}
468+ aria-label="Start round"
469+ >
470+ {phase === "cashed" || phase === "crashed" ? "AGAIN" : "START"}
471+ </button>
472+ )}
473+ <span className="text-[11px] font-semibold uppercase tracking-wider text-fg-3">{running ? `${formatSC(potential)}` : "space = start / cash out"}</span>
474+ </div>
475+
476+ <div className="flex flex-col items-end gap-2 text-right">
477+ <div>
478+ <div className="eyebrow">Auto cash-out</div>
479+ <div className="flex items-center gap-1.5">
480+ <button role="switch" aria-checked={autoOn} disabled={running} onClick={() => setAutoOn((v) => !v)} className={cn("relative h-6 w-10 rounded-full border transition-colors", autoOn ? "bg-accent border-accent" : "bg-surface-3 border-line-2")}>
481+ <span className={cn("absolute top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform", autoOn ? "translate-x-[18px]" : "translate-x-0.5")} />
482+ </button>
483+ <input
484+ type="number"
485+ step="0.1"
486+ min="1.01"
487+ max={definition.maxMultiplier}
488+ value={autoTarget}
489+ disabled={running || !autoOn}
490+ onChange={(e) => setAutoTarget(e.target.value)}
491+ onBlur={() => setAutoTarget(String(Math.max(1.01, Math.min(definition.maxMultiplier, Number(autoTarget) || 2)).toFixed(2)))}
492+ className="h-9 w-[76px] rounded-md border border-line-2 bg-bg-1 px-2 text-right text-sm font-bold tabular text-fg disabled:opacity-50 focus-ring"
493+ aria-label="Auto cash-out multiplier"
494+ />
495+ <span className="text-sm text-fg-3">×</span>
496+ </div>
497+ </div>
498+ <div className="text-[11px] text-fg-4">Max {formatMultiplier(definition.maxMultiplier)}</div>
499+ </div>
500+ </div>
501+ </div>
502+
503+ <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />
504+ <FairSheet open={fairSheet} onClose={() => setFairSheet(false)} round={round} />
505+ <Sheet open={historySheet} onClose={() => setHistorySheet(false)} title="Recent rounds" side="right">
506+ {history.length === 0 ? <p className="text-sm text-fg-3">No rounds yet.</p> : (
507+ <ul className="grid grid-cols-4 gap-2">
508+ {history.map((h, i) => (
509+ <li key={i} className={cn("rounded-md px-2 py-2 text-center text-sm font-bold tabular", h.crash < 1.5 ? "bg-danger/15 text-danger" : h.crash < 3 ? "bg-white/10 text-fg-2" : h.crash < 10 ? "bg-success/15 text-success" : "bg-accent-soft text-accent-2")}>
510+ {formatMultiplier(h.crash)}
511+ </li>
512+ ))}
513+ </ul>
514+ )}
515+ </Sheet>
516+ <span className="sr-only">
517+ <SpinzaMark />
518+ </span>
519+ </div>
520+ );
521+}
522+
523+function crashWord(scene: string): string {
524+ return (
525+ {
526+ sky: "Vanished",
527+ ocean: "Hull breach",
528+ rocket: "Engine failure",
529+ bank: "Busted",
530+ volcano: "Eruption",
531+ blackhole: "Pulled in",
532+ freefall: "Too late",
533+ reactor: "Meltdown",
534+ storm: "Swallowed",
535+ elevator: "Cable snapped",
536+ }[scene] ?? "Crashed"
537+ );
538+}
539+
540+function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: CrashGameDefinition }) {
541+ const [tab, setTab] = useState<"rules" | "about">("rules");
542+ return (
543+ <Sheet open={open} onClose={onClose} title={game.name} side="right">
544+ <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" />
545+ {tab === "rules" ? (
546+ <ul className="space-y-3 text-sm text-fg-2">
547+ {game.rules.map((r, i) => (
548+ <li key={i} className="flex gap-3">
549+ <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>
550+ <span>{r}</span>
551+ </li>
552+ ))}
553+ </ul>
554+ ) : (
555+ <div className="space-y-4 text-sm text-fg-2">
556+ <p>{game.description}</p>
557+ <dl className="grid grid-cols-2 gap-3">
558+ {[
559+ ["Volatility", game.volatility],
560+ ["RTP", `${(game.rtp * 100).toFixed(2)}% for any strategy`],
561+ ["Max multiplier", formatMultiplier(definition.maxMultiplier)],
562+ ["Instant end", `${((1 - game.rtp) * 100).toFixed(0)}% of rounds`],
563+ ["Reach 2× odds", `${((game.rtp / 2) * 100).toFixed(0)}%`],
564+ ["Reach 10× odds", `${((game.rtp / 10) * 100).toFixed(1)}%`],
565+ ].map(([k, v]) => (
566+ <div key={k} className="surface rounded-md p-3">
567+ <dt className="eyebrow">{k}</dt>
568+ <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd>
569+ </div>
570+ ))}
571+ </dl>
572+ <p className="text-[12px] text-fg-3">P(end ≥ x) = {game.rtp} / x. Milestones, boosts and cooling windows change the pace, never the odds. Spinza Credits are fictional and have no cash value.</p>
573+ </div>
574+ )}
575+ </Sheet>
576+ );
577+}
578+
579+function FairSheet({ open, onClose, round }: { open: boolean; onClose: () => void; round: RoundView | null }) {
580+ return (
581+ <Sheet open={open} onClose={onClose} title="Provably fair" side="right">
582+ <div className="space-y-4 text-sm text-fg-2">
583+ <p>Before each round starts, the server draws the end multiplier and publishes a SHA-256 commitment of <code className="font-mono text-[12px]">seed:multiplier</code>. When the round ends, the seed is revealed so you can verify the commitment yourself.</p>
584+ {round ? (
585+ <div className="space-y-2 surface rounded-md p-3 text-[12px]">
586+ <div><span className="text-fg-3">Round</span> <span className="font-mono">{round.roundId}</span></div>
587+ <div><span className="text-fg-3">Commitment</span> <span className="break-all font-mono">{round.commitment}</span></div>
588+ <div><span className="text-fg-3">Seed</span> <span className="break-all font-mono">{round.seed ?? "revealed when the round ends"}</span></div>
589+ <div><span className="text-fg-3">End multiplier</span> <span className="font-mono">{round.crashMultiplier ? round.crashMultiplier.toFixed(2) : "hidden while running"}</span></div>
590+ </div>
591+ ) : (
592+ <p className="text-fg-3">Start a round to see its commitment.</p>
593+ )}
594+ <p className="text-[12px] text-fg-3">Verify: sha256(&quot;seed:multiplier&quot;) with the multiplier formatted to two decimals must equal the commitment.</p>
595+ </div>
596+ </Sheet>
597+ );
598+}
added apps/web/src/components/crash/scenes.ts +788 −0
@@ -0,0 +1,788 @@
1+"use client";
2+
3+import type { CrashScene } from "@spinza/game-core/client";
4+
5+/**
6+ * Procedural 2D-canvas scenes for the ten Risk Games. Each painter receives
7+ * the normalised progress `p` (0 at 1.00×, →1 near very high multipliers,
8+ * log scale), the elapsed time, the current multiplier and the round phase.
9+ * All drawing is original and parametric — no image assets.
10+ */
11+
12+export interface SceneFrame {
13+ ctx: CanvasRenderingContext2D;
14+ w: number;
15+ h: number;
16+ /** log-scale progress 0..1 (1 ≈ 1000×). */
17+ p: number;
18+ /** Seconds since round start (0 when idle). */
19+ t: number;
20+ multiplier: number;
21+ phase: "idle" | "running" | "cashed" | "crashed";
22+ /** Seconds since the phase changed (for crash/cash animations). */
23+ phaseT: number;
24+ palette: { primary: string; secondary: string; glow: string; bg: string; surface: string };
25+ /** Active event label, if any. */
26+ event: string | null;
27+ /** Elevator floor (steps curves). */
28+ floor: number;
29+ reduceMotion: boolean;
30+}
31+
32+type Painter = (f: SceneFrame) => void;
33+
34+const TAU = Math.PI * 2;
35+
36+function hashNoise(i: number, salt = 0): number {
37+ const x = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453;
38+ return x - Math.floor(x);
39+}
40+
41+function withAlpha(hex: string, a: number): string {
42+ const n = parseInt(hex.replace("#", ""), 16);
43+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
44+}
45+
46+function mix(a: string, b: string, t: number): string {
47+ const na = parseInt(a.replace("#", ""), 16);
48+ const nb = parseInt(b.replace("#", ""), 16);
49+ const ch = (s: number) => Math.round(((na >> s) & 255) * (1 - t) + ((nb >> s) & 255) * t);
50+ return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
51+}
52+
53+function gradient(ctx: CanvasRenderingContext2D, w: number, h: number, stops: [number, string][]) {
54+ const g = ctx.createLinearGradient(0, 0, 0, h);
55+ for (const [o, c] of stops) g.addColorStop(o, c);
56+ ctx.fillStyle = g;
57+ ctx.fillRect(0, 0, w, h);
58+}
59+
60+function stars(f: SceneFrame, count: number, alpha: number, drift = 0) {
61+ const { ctx, w, h, t } = f;
62+ ctx.fillStyle = `rgba(255,255,255,${alpha})`;
63+ for (let i = 0; i < count; i++) {
64+ const x = (hashNoise(i, 1) * w + drift * t * (0.3 + hashNoise(i, 4))) % w;
65+ const y = (hashNoise(i, 2) * h + drift * t * 2 * (0.3 + hashNoise(i, 5))) % h;
66+ const r = 0.6 + hashNoise(i, 3) * 1.6;
67+ ctx.beginPath();
68+ ctx.arc(x < 0 ? x + w : x, y < 0 ? y + h : y, r, 0, TAU);
69+ ctx.fill();
70+ }
71+}
72+
73+function shake(f: SceneFrame, amount: number) {
74+ if (f.reduceMotion) return;
75+ f.ctx.translate((hashNoise(Math.floor(f.t * 60), 9) - 0.5) * amount, (hashNoise(Math.floor(f.t * 60), 10) - 0.5) * amount);
76+}
77+
78+function crashFlash(f: SceneFrame, color: string) {
79+ if (f.phase !== "crashed") return;
80+ const a = Math.max(0, 0.75 - f.phaseT * 1.2);
81+ f.ctx.fillStyle = withAlpha(color, a);
82+ f.ctx.fillRect(0, 0, f.w, f.h);
83+}
84+
85+function cashGlow(f: SceneFrame) {
86+ if (f.phase !== "cashed") return;
87+ const a = Math.max(0, 0.35 - f.phaseT * 0.5);
88+ const g = f.ctx.createRadialGradient(f.w / 2, f.h / 2, 0, f.w / 2, f.h / 2, Math.max(f.w, f.h) * 0.7);
89+ g.addColorStop(0, `rgba(201,169,97,${a})`);
90+ g.addColorStop(1, "rgba(201,169,97,0)");
91+ f.ctx.fillStyle = g;
92+ f.ctx.fillRect(0, 0, f.w, f.h);
93+}
94+
95+/* ------------------------------------------------------------------ sky */
96+
97+const sky: Painter = (f) => {
98+ const { ctx, w, h, p, t } = f;
99+ // Sky colour from dawn blue → storm grey → deep indigo → black space.
100+ const top = p < 0.35 ? mix("#1d4ed8", "#334155", p / 0.35) : p < 0.7 ? mix("#334155", "#0b1226", (p - 0.35) / 0.35) : mix("#0b1226", "#000000", (p - 0.7) / 0.3);
101+ const bottom = p < 0.5 ? mix("#7dd3fc", "#475569", p / 0.5) : mix("#475569", "#020617", (p - 0.5) / 0.5);
102+ gradient(ctx, w, h, [[0, top], [1, bottom]]);
103+ if (p > 0.6) stars(f, 90, Math.min(0.9, (p - 0.6) * 2.5), 0);
104+ // Clouds scrolling down as we climb.
105+ const cloudAlpha = p < 0.55 ? 0.55 : Math.max(0, 0.55 - (p - 0.55) * 3);
106+ for (let i = 0; i < 9; i++) {
107+ const y = ((hashNoise(i, 21) * h + t * (60 + i * 12) * (1 + p * 3)) % (h + 80)) - 40;
108+ const x = hashNoise(i, 22) * w;
109+ ctx.fillStyle = `rgba(255,255,255,${cloudAlpha * (0.5 + hashNoise(i, 23) * 0.5)})`;
110+ for (let k = 0; k < 4; k++) {
111+ ctx.beginPath();
112+ ctx.arc(x + k * 26 - 40, y + (k % 2) * 8, 18 + hashNoise(i + k, 24) * 16, 0, TAU);
113+ ctx.fill();
114+ }
115+ }
116+ // Storm lightning in the middle band.
117+ if (p > 0.3 && p < 0.65 && hashNoise(Math.floor(t * 4), 30) > 0.93) {
118+ ctx.fillStyle = "rgba(255,255,255,0.18)";
119+ ctx.fillRect(0, 0, w, h);
120+ }
121+ // Jet.
122+ const cx = w * 0.5;
123+ const cy = h * (0.62 - p * 0.25) + Math.sin(t * 3) * 3;
124+ ctx.save();
125+ ctx.translate(cx, cy);
126+ if (f.phase === "crashed") {
127+ ctx.rotate(Math.min(1.2, f.phaseT * 2.5));
128+ ctx.translate(f.phaseT * 60, f.phaseT * 160);
129+ ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.8);
130+ }
131+ ctx.rotate(-0.25);
132+ // Exhaust.
133+ const trail = ctx.createLinearGradient(-30, 0, -140, 0);
134+ trail.addColorStop(0, withAlpha(f.palette.secondary, 0.8));
135+ trail.addColorStop(1, withAlpha(f.palette.secondary, 0));
136+ ctx.fillStyle = trail;
137+ ctx.fillRect(-140, -3, 110, 6);
138+ // Fuselage.
139+ ctx.fillStyle = "#e5e7eb";
140+ ctx.beginPath();
141+ ctx.moveTo(40, 0);
142+ ctx.lineTo(-30, -9);
143+ ctx.lineTo(-40, 0);
144+ ctx.lineTo(-30, 9);
145+ ctx.closePath();
146+ ctx.fill();
147+ ctx.fillStyle = "#94a3b8";
148+ ctx.beginPath();
149+ ctx.moveTo(-5, -4);
150+ ctx.lineTo(-40, -30);
151+ ctx.lineTo(-22, -2);
152+ ctx.closePath();
153+ ctx.fill();
154+ ctx.beginPath();
155+ ctx.moveTo(-5, 4);
156+ ctx.lineTo(-40, 30);
157+ ctx.lineTo(-22, 2);
158+ ctx.closePath();
159+ ctx.fill();
160+ ctx.fillStyle = f.palette.primary;
161+ ctx.fillRect(10, -3, 14, 6);
162+ ctx.restore();
163+ crashFlash(f, "#ffffff");
164+ cashGlow(f);
165+};
166+
167+/* ---------------------------------------------------------------- ocean */
168+
169+const ocean: Painter = (f) => {
170+ const { ctx, w, h, p, t } = f;
171+ const top = mix("#0e7490", "#020617", Math.min(1, p * 1.4));
172+ const bottom = mix("#064e6a", "#000000", Math.min(1, p * 1.2));
173+ gradient(ctx, w, h, [[0, top], [1, bottom]]);
174+ // Light rays near surface.
175+ if (p < 0.5) {
176+ ctx.fillStyle = `rgba(255,255,255,${0.06 * (1 - p * 2)})`;
177+ for (let i = 0; i < 5; i++) {
178+ const x = w * (0.1 + i * 0.2) + Math.sin(t * 0.6 + i) * 20;
179+ ctx.beginPath();
180+ ctx.moveTo(x - 20, 0);
181+ ctx.lineTo(x + 20, 0);
182+ ctx.lineTo(x + 90, h);
183+ ctx.lineTo(x - 90, h);
184+ ctx.fill();
185+ }
186+ }
187+ // Rising bubbles.
188+ ctx.strokeStyle = "rgba(255,255,255,0.35)";
189+ for (let i = 0; i < 24; i++) {
190+ const y = h - ((hashNoise(i, 40) * h + t * (40 + i * 6) * (1 + p * 2)) % (h + 20));
191+ const x = hashNoise(i, 41) * w + Math.sin(t * 2 + i) * 6;
192+ ctx.beginPath();
193+ ctx.arc(x, y, 1.5 + hashNoise(i, 42) * 3, 0, TAU);
194+ ctx.stroke();
195+ }
196+ // Depth marks.
197+ ctx.fillStyle = "rgba(255,255,255,0.15)";
198+ ctx.font = "600 11px Geist, system-ui";
199+ for (let i = 0; i < 6; i++) {
200+ const y = ((i * h) / 5 - ((t * 30 * (1 + p * 3)) % (h / 5)) + h) % h;
201+ ctx.fillRect(0, y, w, 1);
202+ }
203+ // Leviathan silhouette during event.
204+ if (f.event) {
205+ ctx.fillStyle = "rgba(0,0,0,0.55)";
206+ ctx.beginPath();
207+ const ex = ((t * 120) % (w + 400)) - 200;
208+ ctx.ellipse(ex, h * 0.3, 170, 45, 0.1, 0, TAU);
209+ ctx.fill();
210+ ctx.fillStyle = withAlpha(f.palette.secondary, 0.8);
211+ ctx.beginPath();
212+ ctx.arc(ex + 120, h * 0.28, 5, 0, TAU);
213+ ctx.fill();
214+ }
215+ // Submarine.
216+ const cx = w * 0.5 + Math.sin(t * 1.2) * 6;
217+ const cy = h * (0.35 + p * 0.3);
218+ ctx.save();
219+ ctx.translate(cx, cy);
220+ if (f.phase === "crashed") {
221+ ctx.scale(1 + f.phaseT * 0.6, 1 - Math.min(0.9, f.phaseT * 0.9));
222+ ctx.globalAlpha = Math.max(0, 1 - f.phaseT);
223+ }
224+ ctx.fillStyle = "#fbbf24";
225+ ctx.beginPath();
226+ ctx.ellipse(0, 0, 54, 18, 0, 0, TAU);
227+ ctx.fill();
228+ ctx.fillStyle = "#d97706";
229+ ctx.fillRect(-12, -32, 22, 16);
230+ ctx.fillRect(-2, -42, 3, 12);
231+ ctx.fillStyle = "#0ea5e9";
232+ for (const dx of [-24, -6, 12]) {
233+ ctx.beginPath();
234+ ctx.arc(dx, -2, 5, 0, TAU);
235+ ctx.fill();
236+ }
237+ // Propeller wash.
238+ ctx.fillStyle = "rgba(255,255,255,0.25)";
239+ ctx.fillRect(-70 - (t * 200) % 20, -2, 14, 4);
240+ ctx.restore();
241+ crashFlash(f, "#38bdf8");
242+ cashGlow(f);
243+};
244+
245+/* --------------------------------------------------------------- rocket */
246+
247+const rocket: Painter = (f) => {
248+ const { ctx, w, h, p, t } = f;
249+ gradient(ctx, w, h, [[0, mix("#1e1b4b", "#000000", Math.min(1, p * 1.3))], [1, mix("#7c2d12", "#0a0612", Math.min(1, p * 1.6))]]);
250+ stars(f, 120, 0.3 + p * 0.6, 8 + p * 40);
251+ // Ground at the start.
252+ if (p < 0.25) {
253+ ctx.fillStyle = `rgba(60,30,20,${1 - p * 4})`;
254+ ctx.fillRect(0, h * 0.85 + p * h, w, h);
255+ }
256+ // Mars grows near the end.
257+ if (p > 0.55) {
258+ const r = (p - 0.55) * 400;
259+ ctx.fillStyle = "#c2410c";
260+ ctx.beginPath();
261+ ctx.arc(w * 0.8, h * 0.2, r, 0, TAU);
262+ ctx.fill();
263+ ctx.fillStyle = "rgba(0,0,0,0.25)";
264+ ctx.beginPath();
265+ ctx.arc(w * 0.8 + r * 0.3, h * 0.2 - r * 0.2, r * 0.3, 0, TAU);
266+ ctx.fill();
267+ }
268+ const cx = w * 0.5;
269+ const cy = h * 0.6 - Math.sin(t * 5) * 2;
270+ ctx.save();
271+ ctx.translate(cx, cy);
272+ if (f.phase === "crashed") {
273+ shake(f, 14);
274+ ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.9);
275+ ctx.rotate(f.phaseT * 3);
276+ }
277+ // Flame.
278+ const flame = ctx.createLinearGradient(0, 40, 0, 40 + 80 + p * 60);
279+ flame.addColorStop(0, "#fff7ed");
280+ flame.addColorStop(0.3, f.palette.primary);
281+ flame.addColorStop(1, "rgba(251,146,60,0)");
282+ ctx.fillStyle = flame;
283+ ctx.beginPath();
284+ ctx.moveTo(-14, 40);
285+ ctx.lineTo(14, 40);
286+ ctx.lineTo(Math.sin(t * 30) * 6, 40 + 80 + p * 60 + Math.sin(t * 25) * 10);
287+ ctx.closePath();
288+ ctx.fill();
289+ // Body.
290+ ctx.fillStyle = "#f1f5f9";
291+ ctx.beginPath();
292+ ctx.moveTo(0, -60);
293+ ctx.bezierCurveTo(30, -20, 26, 20, 20, 40);
294+ ctx.lineTo(-20, 40);
295+ ctx.bezierCurveTo(-26, 20, -30, -20, 0, -60);
296+ ctx.fill();
297+ ctx.fillStyle = f.palette.secondary;
298+ ctx.beginPath();
299+ ctx.moveTo(-20, 15);
300+ ctx.lineTo(-40, 50);
301+ ctx.lineTo(-20, 40);
302+ ctx.closePath();
303+ ctx.fill();
304+ ctx.beginPath();
305+ ctx.moveTo(20, 15);
306+ ctx.lineTo(40, 50);
307+ ctx.lineTo(20, 40);
308+ ctx.closePath();
309+ ctx.fill();
310+ ctx.fillStyle = "#0ea5e9";
311+ ctx.beginPath();
312+ ctx.arc(0, -15, 8, 0, TAU);
313+ ctx.fill();
314+ ctx.restore();
315+ crashFlash(f, "#fb923c");
316+ cashGlow(f);
317+};
318+
319+/* ----------------------------------------------------------------- bank */
320+
321+const bank: Painter = (f) => {
322+ const { ctx, w, h, p, t } = f;
323+ gradient(ctx, w, h, [[0, "#0b0a08"], [1, "#1b1811"]]);
324+ // Vault wall with deposit boxes.
325+ const cols = Math.ceil(w / 60);
326+ const rows = Math.ceil(h / 40);
327+ for (let i = 0; i < cols; i++)
328+ for (let j = 0; j < rows; j++) {
329+ ctx.fillStyle = `rgba(201,169,97,${0.04 + hashNoise(i * 31 + j, 50) * 0.04})`;
330+ ctx.fillRect(i * 60 + 3, j * 40 + 3, 54, 34);
331+ }
332+ // Police light sweep intensifies with progress.
333+ const siren = Math.max(0, p - 0.25) * 1.3;
334+ if (siren > 0) {
335+ const flash = Math.sin(t * 8) > 0;
336+ ctx.fillStyle = flash ? `rgba(239,68,68,${siren * 0.18})` : `rgba(59,130,246,${siren * 0.18})`;
337+ ctx.fillRect(0, 0, w, h);
338+ }
339+ // Growing bag.
340+ const size = 50 + p * 120;
341+ const cx = w * 0.5;
342+ const cy = h * 0.62;
343+ ctx.save();
344+ ctx.translate(cx, cy);
345+ if (f.phase === "crashed") {
346+ ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.9);
347+ ctx.translate(0, f.phaseT * 80);
348+ }
349+ const wobble = 1 + Math.sin(t * 6) * 0.02;
350+ ctx.scale(wobble, 1 / wobble);
351+ ctx.fillStyle = "#b08d57";
352+ ctx.beginPath();
353+ ctx.moveTo(-size * 0.7, size * 0.6);
354+ ctx.bezierCurveTo(-size * 1.1, -size * 0.4, -size * 0.3, -size * 0.7, 0, -size * 0.65);
355+ ctx.bezierCurveTo(size * 0.3, -size * 0.7, size * 1.1, -size * 0.4, size * 0.7, size * 0.6);
356+ ctx.closePath();
357+ ctx.fill();
358+ ctx.fillStyle = "#8a6d2e";
359+ ctx.fillRect(-size * 0.3, -size * 0.95, size * 0.6, size * 0.3);
360+ ctx.fillStyle = "#1a1408";
361+ ctx.font = `800 ${Math.round(size * 0.5)}px Geist, system-ui`;
362+ ctx.textAlign = "center";
363+ ctx.textBaseline = "middle";
364+ ctx.fillText("SC", 0, size * 0.1);
365+ ctx.restore();
366+ // Bills flying.
367+ ctx.fillStyle = withAlpha(f.palette.primary, 0.6);
368+ for (let i = 0; i < 12 * p + 2; i++) {
369+ const x = (hashNoise(i, 60) * w + t * 40) % w;
370+ const y = (hashNoise(i, 61) * h + t * 90) % h;
371+ ctx.save();
372+ ctx.translate(x, y);
373+ ctx.rotate(t * 2 + i);
374+ ctx.fillRect(-9, -4, 18, 8);
375+ ctx.restore();
376+ }
377+ crashFlash(f, "#ef4444");
378+ cashGlow(f);
379+};
380+
381+/* -------------------------------------------------------------- volcano */
382+
383+const volcano: Painter = (f) => {
384+ const { ctx, w, h, p, t } = f;
385+ gradient(ctx, w, h, [[0, mix("#1c0a05", "#3b0a05", p)], [1, mix("#3b0a05", "#7f1d1d", p)]]);
386+ // Cave walls.
387+ ctx.fillStyle = "#0a0503";
388+ ctx.beginPath();
389+ ctx.moveTo(0, 0);
390+ for (let y = 0; y <= h; y += 20) ctx.lineTo(w * 0.18 + Math.sin(y / 60 + t * 0.2) * 20, y);
391+ ctx.lineTo(0, h);
392+ ctx.fill();
393+ ctx.beginPath();
394+ ctx.moveTo(w, 0);
395+ for (let y = 0; y <= h; y += 20) ctx.lineTo(w * 0.82 + Math.cos(y / 70 + t * 0.2) * 20, y);
396+ ctx.lineTo(w, h);
397+ ctx.fill();
398+ // Rising lava level (visual tension) — rises with progress + wobble.
399+ const lavaY = h * (1 - Math.min(0.95, 0.15 + p * 0.75)) + Math.sin(t * 2) * 6;
400+ const lava = ctx.createLinearGradient(0, lavaY, 0, h);
401+ lava.addColorStop(0, "#fde68a");
402+ lava.addColorStop(0.15, f.palette.primary);
403+ lava.addColorStop(1, "#7f1d1d");
404+ ctx.fillStyle = lava;
405+ ctx.beginPath();
406+ ctx.moveTo(0, lavaY);
407+ for (let x = 0; x <= w; x += 20) ctx.lineTo(x, lavaY + Math.sin(x / 40 + t * 3) * 6);
408+ ctx.lineTo(w, h);
409+ ctx.lineTo(0, h);
410+ ctx.fill();
411+ // Crystals hanging.
412+ for (let i = 0; i < 8; i++) {
413+ const x = w * (0.25 + hashNoise(i, 70) * 0.5);
414+ const y = ((hashNoise(i, 71) * h + t * 25 * (1 + p)) % (h * 0.9));
415+ ctx.fillStyle = withAlpha(f.palette.secondary, 0.85);
416+ ctx.beginPath();
417+ ctx.moveTo(x, y - 14);
418+ ctx.lineTo(x + 8, y);
419+ ctx.lineTo(x, y + 14);
420+ ctx.lineTo(x - 8, y);
421+ ctx.closePath();
422+ ctx.fill();
423+ }
424+ // Explorer.
425+ const cx = w * 0.5;
426+ const cy = h * 0.4 + Math.sin(t * 2) * 3;
427+ ctx.save();
428+ ctx.translate(cx, cy);
429+ if (f.phase === "crashed") {
430+ shake(f, 18);
431+ ctx.globalAlpha = Math.max(0, 1 - f.phaseT);
432+ }
433+ ctx.strokeStyle = "#e5e7eb";
434+ ctx.lineWidth = 3;
435+ ctx.beginPath();
436+ ctx.moveTo(0, -300);
437+ ctx.lineTo(0, -20);
438+ ctx.stroke(); // rope
439+ ctx.fillStyle = "#f59e0b";
440+ ctx.beginPath();
441+ ctx.arc(0, -14, 9, 0, TAU);
442+ ctx.fill(); // helmet
443+ ctx.fillStyle = "#e5e7eb";
444+ ctx.fillRect(-7, -6, 14, 22);
445+ ctx.fillStyle = "#fde68a";
446+ ctx.beginPath();
447+ ctx.arc(0, -14, 3, 0, TAU);
448+ ctx.fill(); // lamp
449+ ctx.restore();
450+ if (p > 0.6) shake(f, (p - 0.6) * 10);
451+ crashFlash(f, "#ff5c3d");
452+ cashGlow(f);
453+};
454+
455+/* ------------------------------------------------------------ blackhole */
456+
457+const blackhole: Painter = (f) => {
458+ const { ctx, w, h, p, t } = f;
459+ gradient(ctx, w, h, [[0, "#050308"], [1, "#0f0a1e"]]);
460+ stars(f, 140, 0.7, 2 + p * 30);
461+ const cx = w * 0.72;
462+ const cy = h * 0.42;
463+ const r = 40 + p * 120;
464+ // Accretion disk.
465+ for (let i = 0; i < 5; i++) {
466+ ctx.strokeStyle = withAlpha(i % 2 ? f.palette.primary : f.palette.secondary, 0.35 - i * 0.05);
467+ ctx.lineWidth = 6 - i;
468+ ctx.beginPath();
469+ ctx.ellipse(cx, cy, r * (1.5 + i * 0.3), r * (0.45 + i * 0.1), 0.35 + t * 0.04 * (i + 1), 0, TAU);
470+ ctx.stroke();
471+ }
472+ // Lensing glow + hole.
473+ const glow = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.2);
474+ glow.addColorStop(0, withAlpha(f.palette.glow, 0.5));
475+ glow.addColorStop(1, "rgba(0,0,0,0)");
476+ ctx.fillStyle = glow;
477+ ctx.fillRect(0, 0, w, h);
478+ ctx.fillStyle = "#000";
479+ ctx.beginPath();
480+ ctx.arc(cx, cy, r, 0, TAU);
481+ ctx.fill();
482+ // Ship spiralling in.
483+ const dist = Math.max(r + 20, (w * 0.55) * (1 - p) + r);
484+ const ang = -0.6 - t * (0.4 + p * 2);
485+ const sx = cx + Math.cos(ang) * dist;
486+ const sy = cy + Math.sin(ang) * dist * 0.5;
487+ ctx.save();
488+ ctx.translate(sx, sy);
489+ ctx.rotate(ang + Math.PI / 2);
490+ if (f.phase === "crashed") {
491+ ctx.scale(1 - Math.min(0.95, f.phaseT), 1 + f.phaseT * 3);
492+ ctx.globalAlpha = Math.max(0, 1 - f.phaseT);
493+ }
494+ ctx.fillStyle = "#e2e8f0";
495+ ctx.beginPath();
496+ ctx.moveTo(0, -18);
497+ ctx.lineTo(12, 14);
498+ ctx.lineTo(0, 8);
499+ ctx.lineTo(-12, 14);
500+ ctx.closePath();
501+ ctx.fill();
502+ ctx.fillStyle = f.palette.secondary;
503+ ctx.fillRect(-3, 12, 6, 12 + p * 20);
504+ ctx.restore();
505+ // Chromatic distortion near horizon.
506+ if (p > 0.5 && !f.reduceMotion) {
507+ ctx.fillStyle = `rgba(167,139,250,${(p - 0.5) * 0.15})`;
508+ ctx.fillRect(Math.sin(t * 10) * 4, 0, w, h);
509+ }
510+ crashFlash(f, "#a78bfa");
511+ cashGlow(f);
512+};
513+
514+/* ------------------------------------------------------------- freefall */
515+
516+const freefall: Painter = (f) => {
517+ const { ctx, w, h, p, t } = f;
518+ gradient(ctx, w, h, [[0, mix("#020617", "#1d4ed8", p)], [1, mix("#1e3a8a", "#7dd3fc", p)]]);
519+ if (p < 0.4) stars(f, 60, 0.6 * (1 - p * 2.5), 0);
520+ // Ground approaching: patchwork fields scale up.
521+ const scale = 0.2 + p * p * 3;
522+ ctx.save();
523+ ctx.translate(w / 2, h * 0.95);
524+ ctx.scale(scale, scale * 0.35);
525+ for (let i = -6; i < 6; i++)
526+ for (let j = -6; j < 6; j++) {
527+ ctx.fillStyle = `rgba(${60 + hashNoise(i * 13 + j, 80) * 60},${110 + hashNoise(i * 7 + j, 81) * 80},${50},${Math.min(1, 0.15 + p)})`;
528+ ctx.fillRect(i * 60, j * 60 - 200, 56, 56);
529+ }
530+ ctx.restore();
531+ // Clouds rushing up.
532+ ctx.fillStyle = "rgba(255,255,255,0.35)";
533+ for (let i = 0; i < 8; i++) {
534+ const y = (h - ((hashNoise(i, 90) * h + t * (120 + p * 500)) % (h + 100))) + 50;
535+ const x = hashNoise(i, 91) * w;
536+ ctx.beginPath();
537+ ctx.ellipse(x, y, 60 + hashNoise(i, 92) * 60, 14, 0, 0, TAU);
538+ ctx.fill();
539+ }
540+ // Skydiver.
541+ const cx = w * 0.5 + Math.sin(t * 2) * 8;
542+ const cy = h * 0.42;
543+ ctx.save();
544+ ctx.translate(cx, cy);
545+ if (f.phase === "cashed") {
546+ // Parachute opens.
547+ const o = Math.min(1, f.phaseT * 3);
548+ ctx.fillStyle = f.palette.secondary;
549+ ctx.beginPath();
550+ ctx.arc(0, -70 * o, 60 * o, Math.PI, 0);
551+ ctx.fill();
552+ ctx.strokeStyle = "rgba(255,255,255,0.7)";
553+ ctx.lineWidth = 1.5;
554+ for (const dx of [-50, -20, 20, 50]) {
555+ ctx.beginPath();
556+ ctx.moveTo(dx * o, -70 * o);
557+ ctx.lineTo(0, -10);
558+ ctx.stroke();
559+ }
560+ }
561+ if (f.phase === "crashed") ctx.globalAlpha = Math.max(0, 1 - f.phaseT);
562+ ctx.rotate(Math.sin(t * 3) * 0.1);
563+ ctx.fillStyle = "#f97316";
564+ ctx.fillRect(-8, -14, 16, 28);
565+ ctx.fillStyle = "#fde68a";
566+ ctx.beginPath();
567+ ctx.arc(0, -22, 8, 0, TAU);
568+ ctx.fill();
569+ ctx.strokeStyle = "#f97316";
570+ ctx.lineWidth = 5;
571+ ctx.beginPath();
572+ ctx.moveTo(-8, -8);
573+ ctx.lineTo(-28, -28);
574+ ctx.moveTo(8, -8);
575+ ctx.lineTo(28, -28);
576+ ctx.moveTo(-6, 14);
577+ ctx.lineTo(-20, 36);
578+ ctx.moveTo(6, 14);
579+ ctx.lineTo(20, 36);
580+ ctx.stroke();
581+ ctx.restore();
582+ if (p > 0.7) shake(f, (p - 0.7) * 12);
583+ crashFlash(f, "#ffffff");
584+ cashGlow(f);
585+};
586+
587+/* --------------------------------------------------------------- reactor */
588+
589+const reactor: Painter = (f) => {
590+ const { ctx, w, h, p, t } = f;
591+ gradient(ctx, w, h, [[0, "#070c06"], [1, mix("#111c0e", "#3f0f0f", p)]]);
592+ // Panel grid.
593+ ctx.strokeStyle = "rgba(163,230,53,0.08)";
594+ for (let x = 0; x < w; x += 40) {
595+ ctx.beginPath();
596+ ctx.moveTo(x, 0);
597+ ctx.lineTo(x, h);
598+ ctx.stroke();
599+ }
600+ // Core.
601+ const cx = w * 0.5;
602+ const cy = h * 0.45;
603+ const heat = p;
604+ const coreColor = mix("#a3e635", "#ef4444", heat);
605+ const pulse = 1 + Math.sin(t * (2 + heat * 12)) * 0.05 * (1 + heat);
606+ const glow = ctx.createRadialGradient(cx, cy, 10, cx, cy, 150 * pulse);
607+ glow.addColorStop(0, withAlpha(coreColor.startsWith("#") ? coreColor : f.palette.primary, 0.9));
608+ glow.addColorStop(0.4, coreColor.replace("rgb(", "rgba(").replace(")", ",0.45)"));
609+ glow.addColorStop(1, "rgba(0,0,0,0)");
610+ ctx.fillStyle = glow;
611+ ctx.fillRect(0, 0, w, h);
612+ // Hex containment.
613+ ctx.strokeStyle = withAlpha(f.palette.primary, 0.6);
614+ ctx.lineWidth = 3;
615+ ctx.beginPath();
616+ for (let i = 0; i < 6; i++) {
617+ const a = (i / 6) * TAU;
618+ const x = cx + Math.cos(a) * 110;
619+ const y = cy + Math.sin(a) * 110;
620+ if (i === 0) ctx.moveTo(x, y);
621+ else ctx.lineTo(x, y);
622+ }
623+ ctx.closePath();
624+ ctx.stroke();
625+ // Temperature gauge.
626+ const gx = w * 0.5;
627+ const gy = h * 0.86;
628+ ctx.fillStyle = "rgba(255,255,255,0.08)";
629+ ctx.fillRect(gx - 150, gy - 8, 300, 16);
630+ const tg = ctx.createLinearGradient(gx - 150, 0, gx + 150, 0);
631+ tg.addColorStop(0, "#a3e635");
632+ tg.addColorStop(0.6, "#facc15");
633+ tg.addColorStop(1, "#ef4444");
634+ ctx.fillStyle = tg;
635+ ctx.fillRect(gx - 150, gy - 8, 300 * Math.min(1, heat + 0.05), 16);
636+ ctx.fillStyle = "#ffffff";
637+ ctx.fillRect(gx + 150 * 0.8, gy - 14, 2, 28); // limit marker
638+ if (f.event) {
639+ ctx.fillStyle = f.event.toLowerCase().includes("cool") ? "rgba(56,189,248,0.12)" : "rgba(239,68,68,0.12)";
640+ ctx.fillRect(0, 0, w, h);
641+ }
642+ if (heat > 0.6) shake(f, (heat - 0.6) * 12);
643+ crashFlash(f, "#ef4444");
644+ cashGlow(f);
645+};
646+
647+/* ----------------------------------------------------------------- storm */
648+
649+const storm: Painter = (f) => {
650+ const { ctx, w, h, p, t } = f;
651+ gradient(ctx, w, h, [[0, mix("#1e293b", "#0a0d12", p)], [1, mix("#334155", "#1a212c", p)]]);
652+ // Road.
653+ ctx.fillStyle = "#0b0e14";
654+ ctx.beginPath();
655+ ctx.moveTo(w * 0.35, h);
656+ ctx.lineTo(w * 0.48, h * 0.55);
657+ ctx.lineTo(w * 0.52, h * 0.55);
658+ ctx.lineTo(w * 0.65, h);
659+ ctx.fill();
660+ ctx.strokeStyle = "rgba(255,255,255,0.5)";
661+ ctx.setLineDash([12, 18]);
662+ ctx.lineDashOffset = -t * 300;
663+ ctx.lineWidth = 3;
664+ ctx.beginPath();
665+ ctx.moveTo(w * 0.5, h);
666+ ctx.lineTo(w * 0.5, h * 0.55);
667+ ctx.stroke();
668+ ctx.setLineDash([]);
669+ // Tornado: grows as we approach.
670+ const size = 60 + p * 260;
671+ const tx = w * 0.5 + Math.sin(t * 0.7) * 30 * (1 - p);
672+ const ty = h * 0.55;
673+ for (let i = 0; i < 9; i++) {
674+ const yy = ty - i * (size * 0.12);
675+ const rw = size * (0.15 + i * 0.1);
676+ ctx.fillStyle = `rgba(${120 + i * 8},${130 + i * 8},${150 + i * 6},${0.35 + i * 0.05})`;
677+ ctx.beginPath();
678+ ctx.ellipse(tx + Math.sin(t * 6 + i) * 6, yy, rw, size * 0.07, 0, 0, TAU);
679+ ctx.fill();
680+ }
681+ // Debris.
682+ ctx.fillStyle = "rgba(200,200,200,0.5)";
683+ for (let i = 0; i < 20 * p + 4; i++) {
684+ const a = t * (3 + hashNoise(i, 100) * 3) + i;
685+ const rr = size * (0.3 + hashNoise(i, 101) * 0.6);
686+ ctx.fillRect(tx + Math.cos(a) * rr, ty - size * 0.5 + Math.sin(a) * rr * 0.4, 5, 3);
687+ }
688+ // Lightning.
689+ if (hashNoise(Math.floor(t * 6), 110) > 0.9 - p * 0.3) {
690+ ctx.fillStyle = "rgba(255,255,255,0.14)";
691+ ctx.fillRect(0, 0, w, h);
692+ }
693+ // Truck.
694+ ctx.save();
695+ ctx.translate(w * 0.5, h * 0.88);
696+ if (f.phase === "crashed") {
697+ ctx.rotate(f.phaseT * 4);
698+ ctx.translate(0, -f.phaseT * 200);
699+ ctx.globalAlpha = Math.max(0, 1 - f.phaseT);
700+ }
701+ shake(f, p * 4);
702+ ctx.fillStyle = f.palette.secondary;
703+ ctx.fillRect(-26, -30, 52, 30);
704+ ctx.fillStyle = "#0f172a";
705+ ctx.fillRect(-20, -26, 40, 12);
706+ ctx.fillStyle = "#111";
707+ ctx.beginPath();
708+ ctx.arc(-18, 2, 8, 0, TAU);
709+ ctx.arc(18, 2, 8, 0, TAU);
710+ ctx.fill();
711+ ctx.fillStyle = "#fde68a";
712+ ctx.fillRect(-26, -12, 6, 5);
713+ ctx.fillRect(20, -12, 6, 5);
714+ ctx.restore();
715+ crashFlash(f, "#cbd5e1");
716+ cashGlow(f);
717+};
718+
719+/* -------------------------------------------------------------- elevator */
720+
721+const elevator: Painter = (f) => {
722+ const { ctx, w, h, t, floor } = f;
723+ gradient(ctx, w, h, [[0, "#08090d"], [1, "#151821"]]);
724+ // Shaft with floor lines scrolling.
725+ const speed = 60 + Math.min(600, floor * 3);
726+ ctx.strokeStyle = "rgba(232,207,143,0.25)";
727+ ctx.fillStyle = "rgba(232,207,143,0.6)";
728+ ctx.font = "700 12px Geist, system-ui";
729+ ctx.textAlign = "right";
730+ const spacing = 110;
731+ const offset = (t * speed) % spacing;
732+ for (let i = -1; i < h / spacing + 2; i++) {
733+ const y = i * spacing + offset;
734+ const fl = floor + Math.round((h / 2 - y) / spacing);
735+ ctx.beginPath();
736+ ctx.moveTo(w * 0.2, y);
737+ ctx.lineTo(w * 0.8, y);
738+ ctx.stroke();
739+ if (fl >= 0) ctx.fillText(String(fl), w * 0.18, y - 6);
740+ if ([25, 50, 100, 250].includes(fl)) {
741+ ctx.fillStyle = "rgba(56,189,248,0.25)";
742+ ctx.fillRect(w * 0.2, y - spacing, w * 0.6, spacing);
743+ ctx.fillStyle = "rgba(232,207,143,0.6)";
744+ }
745+ }
746+ // Rails.
747+ ctx.fillStyle = "rgba(255,255,255,0.08)";
748+ ctx.fillRect(w * 0.2, 0, 4, h);
749+ ctx.fillRect(w * 0.8 - 4, 0, 4, h);
750+ // Cabin.
751+ ctx.save();
752+ ctx.translate(w * 0.5, h * 0.5);
753+ if (f.phase === "crashed") {
754+ ctx.translate(0, f.phaseT * f.phaseT * 900);
755+ ctx.rotate(Math.sin(f.phaseT * 20) * 0.05);
756+ }
757+ ctx.fillStyle = "#1f2330";
758+ ctx.fillRect(-70, -80, 140, 160);
759+ ctx.strokeStyle = f.palette.primary;
760+ ctx.lineWidth = 2;
761+ ctx.strokeRect(-70, -80, 140, 160);
762+ ctx.fillStyle = "rgba(232,207,143,0.2)";
763+ ctx.fillRect(-60, -70, 120, 30);
764+ ctx.fillStyle = f.palette.primary;
765+ ctx.font = "800 26px Geist, system-ui";
766+ ctx.textAlign = "center";
767+ ctx.textBaseline = "middle";
768+ ctx.fillText(String(floor), 0, -55);
769+ // Doors (open on cash-out).
770+ const open = f.phase === "cashed" ? Math.min(1, f.phaseT * 2) * 50 : 0;
771+ ctx.fillStyle = "#2b3040";
772+ ctx.fillRect(-60 - open, -30, 58, 100);
773+ ctx.fillRect(2 + open, -30, 58, 100);
774+ if (open > 0) {
775+ ctx.fillStyle = "rgba(232,207,143,0.6)";
776+ ctx.fillRect(-2, -30, 4, 100);
777+ }
778+ ctx.restore();
779+ crashFlash(f, "#ef4444");
780+ cashGlow(f);
781+};
782+
783+export const SCENES: Record<CrashScene, Painter> = { sky, ocean, rocket, bank, volcano, blackhole, freefall, reactor, storm, elevator };
784+
785+/** Log-scale progress helper: 1.00× → 0, 1000× → 1. */
786+export function progressFor(multiplier: number): number {
787+ return Math.max(0, Math.min(1, Math.log10(Math.max(1, multiplier)) / 3));
788+}
modified apps/web/src/components/lobby/games-browser.tsx +8 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/lobby/lobby.tsx +8 −3
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
modified packages/shared/src/types.ts +2 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme