SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%

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 16 days ago (Sep 8, 2026) parent 3f93e65

12 changed files +1,935 −137

modified apps/api/src/app.ts +2 −0
@@ -10,6 +10,7 @@ import { gameRoutes } from "./routes/games";
10 10 import { rewardRoutes } from "./routes/rewards";
11 11 import { healthRoutes } from "./routes/health";
12 12 import { adminRoutes } from "./routes/admin";
13 +import { crashRoutes } from "./routes/crash";
13 14 import { refreshSettings } from "./lib/settings";
14 15
15 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 70 await app.register(userRoutes);
70 71 await app.register(gameRoutes);
71 72 await app.register(rewardRoutes);
73 + await app.register(crashRoutes);
72 74 await app.register(adminRoutes);
73 75
74 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 1 import type { FastifyInstance } from "fastify";
2 2 import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database";
3 import { getGame, FIRST_GAME_RECOMMENDATIONS } from "@spinza/games";
3 +import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG } from "@spinza/games";
4 4 import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared";
5 5 import { requireUser } from "../plugins/auth";
6 6 import { errors } from "../lib/errors";
@@ -32,6 +32,8 @@ function toCard(g: GameRow, extra: { popularity?: number; favorite?: boolean; la
32 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 33 : undefined,
34 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 37 isNew: g.isNew,
36 38 isFeatured: g.isFeatured,
37 39 isJackpot: !!s.isJackpot,
@@ -71,8 +73,25 @@ export async function gameRoutes(app: FastifyInstance) {
71 73 app.get("/api/games/:slug", async (req) => {
72 74 const { slug } = req.params as { slug: string };
73 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 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 95 const s = g.summary as Record<string, unknown>;
77 96 const cert = (s.certification as GameInfo["certification"] | null) ?? null;
78 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 2 import { runSpin, type PlayerGameState, type SpinOutcome } from "@spinza/game-core";
3 3 import { getGame } from "@spinza/games";
4 4 import { BET_LEVELS, classifyWin, type SpinResponse } from "@spinza/shared";
@@ -6,7 +6,7 @@ import { errors } from "../lib/errors";
6 6 import { newRoundId } from "../lib/crypto";
7 7 import { flag, maintenance } from "../lib/settings";
8 8 import { applyCredit, lockWallet, saveWallet } from "./wallet";
9 import { advanceMissions, awardXp, checkAchievements, spinXp, upsertLeaderboards, type UserCounters } from "./progression";
9 +import { settleRound } from "./settle";
10 10 import { redis } from "../lib/redis";
11 11 import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";
12 12
@@ -74,57 +74,15 @@ export async function spin(input: SpinInput): Promise<SpinResponse> {
74 74 await applyCredit(tx, wallet, "BET", -input.bet, roundId, { game: def.slug });
75 75 if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: def.slug, multiplier: outcome.multiplier });
76 76
77 // User counters.
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({
77 + const settled = await settleRound(tx, wallet, {
118 78 roundId,
119 userId: input.userId,
120 79 gameId: game.id,
121 80 gameSlug: def.slug,
122 81 gameVersion: def.version,
123 82 clientRoundId: input.clientRoundId,
124 83 bet: input.bet,
125 84 win: outcome.totalWin,
126 multiplier: outcome.multiplier.toFixed(4),
127 balanceAfter: wallet.balance,
85 + multiplier: outcome.multiplier,
128 86 result: storableResult(outcome),
129 87 features: outcome.features,
130 88 freeSpins: outcome.freeSpinsTriggered,
@@ -132,89 +90,9 @@ export async function spin(input: SpinInput): Promise<SpinResponse> {
132 90 jackpotTier: outcome.jackpot?.tier ?? null,
133 91 rngReference: outcome.rngReference,
134 92 durationMs: Date.now() - started,
93 + stateAfter: outcome.stateAfter as unknown as Record<string, unknown>,
135 94 });
136
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);
95 + const cls = settled.winClass;
218 96
219 97 // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards.
220 98 await saveWallet(tx, wallet);
@@ -238,9 +116,9 @@ export async function spin(input: SpinInput): Promise<SpinResponse> {
238 116 multiplier: outcome.multiplier,
239 117 winClass: cls,
240 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 120 result: storableResult(outcome),
243 unlocked: { achievements: ach.unlocked, missions: mis.completed },
121 + unlocked: settled.unlocked,
244 122 } satisfies SpinResponse;
245 123 });
246 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 2 import { notFound } from "next/navigation";
3 3 import { apiServer } from "@/lib/api-server";
4 4 import type { GameInfo } from "@spinza/shared";
5 +import type { CrashGameDefinition } from "@spinza/game-core/client";
5 6 import type { ClientDefinition } from "@/components/game/types";
6 7 import { GameClient } from "@/components/game/game-client";
8 +import { CrashClient } from "@/components/crash/crash-client";
7 9
8 10 type Params = { params: Promise<{ slug: string }> };
9 11
@@ -16,7 +18,8 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
16 18
17 19 export default async function GamePage({ params }: Params) {
18 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 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
@@ -16,6 +16,8 @@ const FILTERS = [
16 16 { key: "high", label: "High volatility" },
17 17 { key: "relaxed", label: "Relaxed" },
18 18 { key: "jackpot", label: "Jackpot" },
19 + { key: "risk", label: "Risk · Cash out" },
20 + { key: "originals", label: "Beyond Slots" },
19 21 { key: "favourites", label: "Favourites" },
20 22 ] as const;
21 23 type FilterKey = (typeof FILTERS)[number]["key"];
@@ -53,6 +55,10 @@ export function GamesBrowser({ games }: { games: GameCardData[] }) {
53 55 return g.volatility === "low" || g.volatility === "medium";
54 56 case "jackpot":
55 57 return g.isJackpot;
58 + case "risk":
59 + return g.kind === "crash";
60 + case "originals":
61 + return g.kind === "arcade";
56 62 case "favourites":
57 63 return favs[g.slug];
58 64 default:
@@ -70,6 +76,8 @@ export function GamesBrowser({ games }: { games: GameCardData[] }) {
70 76 high: games.filter((g) => g.volatility === "high" || g.volatility === "extreme").length,
71 77 relaxed: games.filter((g) => g.volatility === "low" || g.volatility === "medium").length,
72 78 jackpot: games.filter((g) => g.isJackpot).length,
79 + risk: games.filter((g) => g.kind === "crash").length,
80 + originals: games.filter((g) => g.kind === "arcade").length,
73 81 favourites: Object.values(favs).filter(Boolean).length,
74 82 }),
75 83 [games, favs],
modified apps/web/src/components/lobby/lobby.tsx +8 −3
@@ -21,14 +21,17 @@ const FLAGSHIP_SLUG = "spinza-original";
21 21
22 22 export function Lobby({ user, games, livePlayers, recommendations }: LobbyProps) {
23 23 const list = games ?? [];
24 + const slots = list.filter((g) => (g.kind ?? "slot") === "slot");
25 + const risk = list.filter((g) => g.kind === "crash");
26 + const originals = list.filter((g) => g.kind === "arcade");
24 27 const flagship = list.find((g) => g.slug === FLAGSHIP_SLUG) ?? list.find((g) => g.isFeatured) ?? list[0] ?? null;
25 28 const continuePlaying = list.filter((g) => g.lastPlayedAt).sort((a, b) => new Date(b.lastPlayedAt!).getTime() - new Date(a.lastPlayedAt!).getTime());
26 29 const featured = list.filter((g) => g.isFeatured);
27 30 const fresh = list.filter((g) => g.isNew);
28 31 const popular = [...list].sort((a, b) => b.popularity - a.popularity).slice(0, 10);
29 const highVol = list.filter((g) => g.volatility === "high" || g.volatility === "extreme");
30 const relaxed = list.filter((g) => g.volatility === "low" || g.volatility === "medium");
31 const jackpots = list.filter((g) => g.isJackpot);
32 + const highVol = slots.filter((g) => g.volatility === "high" || g.volatility === "extreme");
33 + const relaxed = slots.filter((g) => g.volatility === "low" || g.volatility === "medium");
34 + const jackpots = slots.filter((g) => g.isJackpot);
32 35 const picks = recommendations.map((s) => list.find((g) => g.slug === s)).filter((g): g is GameCardData => !!g);
33 36 const welcomePicks = picks.length ? picks : list.slice(0, 3);
34 37
@@ -103,6 +106,8 @@ export function Lobby({ user, games, livePlayers, recommendations }: LobbyProps)
103 106 </section>
104 107 ) : null}
105 108
109 + <GameRow id="risk" title="Risk Games — Cash Out" eyebrow="One decision: take it now, or push?" games={risk} href="/games?filter=risk" />
110 + <GameRow id="originals" title="Spinza Originals — Beyond Slots" eyebrow="Interactive originals, no reels" games={originals} href="/games?filter=originals" />
106 111 <GameRow id="featured" title="Featured games" eyebrow="Curated tonight" games={featured} href="/games?filter=featured" />
107 112 <GameRow id="new" title="New releases" eyebrow="Fresh from the studio" games={fresh} href="/games?filter=new" />
108 113 <GameRow id="popular" title="Popular" eyebrow="Most played this week" games={popular} href="/games" />
modified packages/shared/src/types.ts +2 −0
@@ -50,6 +50,8 @@ export interface GameCard {
50 50 /** Renderer presentation hints (frame, backdrop, particles, ambience). */
51 51 presentation?: { frame: string; backdrop: string; particles: string; ambience: string };
52 52 lifecycle: GameLifecycle;
53 + kind: "slot" | "crash" | "arcade";
54 + category: "slots" | "risk" | "originals";
53 55 isNew: boolean;
54 56 isFeatured: boolean;
55 57 isJackpot: boolean;
56 58