"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowLeft, Heart, History, Info, Minus, Plus, ShieldCheck, Volume2, VolumeX } from "lucide-react"; import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared"; import { floorAt, multiplierAt, type CrashEvent, type CrashGameDefinition } from "@spinza/game-core/client"; import { api, ApiClientError } from "@/lib/api"; import { toast, useSession } from "@/lib/store"; import { cn } from "@/lib/utils"; import { Button, Credits, Sheet, Tabs } from "@/components/ui"; import { SpinzaMark } from "@/components/brand/logo"; import { getSound } from "@/components/game/sound"; import { SCENES, progressFor } from "./scenes"; interface RoundView { roundId: string; game: string; version: string; bet: number; status: "running" | "cashed" | "crashed"; startedAt: string; serverNow: number; elapsedMs: number; commitment: string; events: CrashEvent[]; autoCashout: number | null; crashMultiplier: number | null; seed: string | null; cashoutMultiplier: number | null; win: number | null; } interface Progression { xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number }; unlocked: { achievements: string[]; missions: string[] }; } type Phase = "idle" | "starting" | "running" | "cashed" | "crashed"; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); export function CrashClient({ game, definition }: { game: GameInfo; definition: CrashGameDefinition }) { const router = useRouter(); const { status, wallet, settings, setBalance, setUserXp } = useSession(); const canvasRef = useRef(null); const soundRef = useRef(getSound()); const [bet, setBet] = useState(100); const [autoOn, setAutoOn] = useState(false); const [autoTarget, setAutoTarget] = useState("2.00"); const [phase, setPhase] = useState("idle"); const [round, setRound] = useState(null); const [display, setDisplay] = useState(1); const [history, setHistory] = useState<{ crash: number; at: string }[]>([]); const [lastResult, setLastResult] = useState<{ status: "cashed" | "crashed"; multiplier: number; win: number; crash: number } | null>(null); const [error, setError] = useState(null); const [infoSheet, setInfoSheet] = useState(false); const [historySheet, setHistorySheet] = useState(false); const [fairSheet, setFairSheet] = useState(false); const [favorite, setFavorite] = useState(!!game.favorite); const [activeEvent, setActiveEvent] = useState(null); const [milestone, setMilestone] = useState(null); const offsetRef = useRef(0); // serverNow - clientNow const phaseRef = useRef("idle"); const phaseAtRef = useRef(0); const roundRef = useRef(null); const displayRef = useRef(1); const cashingRef = useRef(false); const shownMilestones = useRef(new Set()); const soundOn = settings?.soundEnabled ?? true; const palette = definition.presentation.palette; const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]); const balance = wallet?.balance ?? 0; const setPhaseBoth = useCallback((p: Phase) => { phaseRef.current = p; phaseAtRef.current = performance.now(); setPhase(p); }, []); useEffect(() => { if (status === "guest") router.replace(`/login?next=/games/${game.slug}`); }, [status, router, game.slug]); const adoptRound = useCallback((r: RoundView) => { offsetRef.current = r.serverNow - Date.now(); roundRef.current = r; setRound(r); shownMilestones.current.clear(); setLastResult(null); setPhaseBoth("running"); }, [setPhaseBoth]); const finish = useCallback((r: RoundView, bal: number | null, prog: Progression | null) => { if (phaseRef.current !== "running") return; roundRef.current = r; setRound(r); const cashed = r.status === "cashed"; setPhaseBoth(cashed ? "cashed" : "crashed"); const m = cashed ? (r.cashoutMultiplier ?? 0) : (r.crashMultiplier ?? 0); displayRef.current = m; setDisplay(m); setLastResult({ status: cashed ? "cashed" : "crashed", multiplier: m, win: r.win ?? 0, crash: r.crashMultiplier ?? 0 }); if (bal !== null) setBalance(bal); if (cashed) { const cls = classifyWin(m); if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") soundRef.current.bigWin(); else soundRef.current.win(2); } else soundRef.current.error(); if (prog) { setUserXp(prog.xp.total, prog.xp.level); if (prog.xp.leveledUp) toast({ title: `Level ${prog.xp.level} reached`, description: `+${formatSC(prog.xp.levelReward)} level reward`, tone: "credit" }); for (const a of prog.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" }); } setHistory((h) => [{ crash: r.crashMultiplier ?? 0, at: new Date().toISOString() }, ...h].slice(0, 24)); cashingRef.current = false; }, [setBalance, setUserXp, setPhaseBoth]); // Load history + resume a running round. useEffect(() => { if (status !== "authenticated") return; api<{ history: { crash: number; at: string }[] }>(`/api/crash/${game.slug}/history`).then((r) => setHistory(r.history)).catch(() => {}); api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {}); api<{ round: RoundView | null }>(`/api/crash/${game.slug}/current`) .then((r) => { if (r.round && r.round.status === "running") adoptRound(r.round); }) .catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [status, game.slug]); useEffect(() => { const s = soundRef.current; s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 }); s.setAmbience(definition.presentation.ambience); return () => s.destroy(); }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience]); /* ------------------------------------------------------- animation loop */ useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; let raf = 0; let lastTick = 0; const start = performance.now(); const loop = () => { const dpr = Math.min(2, window.devicePixelRatio || 1); const rect = canvas.getBoundingClientRect(); if (canvas.width !== Math.round(rect.width * dpr) || canvas.height !== Math.round(rect.height * dpr)) { canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const r = roundRef.current; const ph = phaseRef.current; let m = displayRef.current; let t = (performance.now() - start) / 1000; let event: string | null = null; if (r && ph === "running") { const elapsed = Date.now() + offsetRef.current - new Date(r.startedAt).getTime(); m = multiplierAt(definition, r.events, elapsed); t = elapsed / 1000; const ev = r.events.find((e) => elapsed >= e.startMs && elapsed < e.endMs); event = ev?.label ?? null; if (m !== displayRef.current) { displayRef.current = m; setDisplay(m); if (performance.now() - lastTick > 90) { lastTick = performance.now(); soundRef.current.tick(); } } for (const ms of definition.milestones) { if (m >= ms.multiplier && !shownMilestones.current.has(ms.multiplier)) { shownMilestones.current.add(ms.multiplier); setMilestone(ms.label); setTimeout(() => setMilestone(null), 1800); } } } else if (r && (ph === "cashed" || ph === "crashed")) { m = ph === "cashed" ? (r.cashoutMultiplier ?? displayRef.current) : (r.crashMultiplier ?? displayRef.current); t = r.status !== "running" ? (r.elapsedMs ?? 0) / 1000 : t; } setActiveEvent((prev) => (prev === event ? prev : event)); const painter = SCENES[definition.presentation.scene]; ctx.save(); painter({ ctx, w: rect.width, h: rect.height, p: progressFor(m), t, multiplier: m, phase: ph === "starting" || ph === "idle" ? "idle" : ph, phaseT: (performance.now() - phaseAtRef.current) / 1000, palette, event, floor: floorAt(definition, m), reduceMotion: settings?.reduceMotion ?? false, }); ctx.restore(); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf); }, [definition, palette, settings?.reduceMotion]); /* --------------------------------------------------------------- polling */ const roundId = round?.roundId; useEffect(() => { if (phase !== "running" || !roundId) return; let alive = true; const poll = async () => { while (alive && phaseRef.current === "running") { try { const r = await api<{ round: RoundView; balance: number | null; progression: Progression | null }>(`/api/crash/${game.slug}/rounds/${roundId}`); offsetRef.current = r.round.serverNow - Date.now(); if (r.round.status !== "running") { finish(r.round, r.balance, r.progression); break; } } catch { /* keep polling */ } await wait(350); } }; void poll(); return () => { alive = false; }; }, [phase, roundId, finish, game.slug]); /* ---------------------------------------------------------------- actions */ const start = useCallback(async () => { if (phaseRef.current === "running" || phaseRef.current === "starting") return; if (balance < bet) { setError("Not enough Spinza Credits for this bet."); soundRef.current.error(); return; } setError(null); soundRef.current.unlock(); soundRef.current.spinStart(); setPhaseBoth("starting"); setLastResult(null); displayRef.current = 1; setDisplay(1); const auto = autoOn ? Number(autoTarget) : null; try { 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 } }); if (r.balance !== null) setBalance(r.balance); adoptRound(r.round); } catch (e) { setPhaseBoth("idle"); if (e instanceof ApiClientError) { if (e.status === 401) router.replace(`/login?next=/games/${game.slug}`); else if (e.code === "ROUND_IN_PROGRESS") api<{ round: RoundView | null }>(`/api/crash/${game.slug}/current`).then((c) => c.round && adoptRound(c.round)).catch(() => {}); else setError(e.message); } else setError("Connection lost. Try again."); soundRef.current.error(); } }, [balance, bet, autoOn, autoTarget, game.slug, router, setBalance, adoptRound, setPhaseBoth]); const cashout = useCallback(async () => { const r = roundRef.current; if (!r || phaseRef.current !== "running" || cashingRef.current) return; cashingRef.current = true; soundRef.current.click(); try { const res = await api<{ round: RoundView; balance: number; progression: Progression | null }>(`/api/crash/${game.slug}/cashout`, { json: { roundId: r.roundId, claimedMultiplier: displayRef.current } }); finish(res.round, res.balance, res.progression); } catch { cashingRef.current = false; } }, [game.slug, finish]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.code === "Space" && !e.repeat && !infoSheet && !historySheet && !fairSheet) { e.preventDefault(); if (phaseRef.current === "running") void cashout(); else void start(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [start, cashout, infoSheet, historySheet, fairSheet]); const toggleFavorite = async () => { setFavorite((f) => !f); try { const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" }); setFavorite(r.favorite); } catch { setFavorite((f) => !f); } }; if (status === "guest") return null; const running = phase === "running"; const betIndex = bets.indexOf(bet); const unitValue = definition.curve.type === "steps" ? floorAt(definition, display) : Math.round((display - 1) * definition.presentation.unitScale); const potential = Math.round(bet * display); const cls = lastResult?.status === "cashed" ? classifyWin(lastResult.multiplier) : "none"; const winLabel = WIN_CLASSES.find((c) => c.id === cls)?.label; return (
{/* Top bar */}
{game.name}
Risk game · cash out anytime
{/* History strip */}
{history.length === 0 ? No rounds yet — be the first. : null} {history.map((h, i) => ( {formatMultiplier(h.crash)} ))}
{/* Scene */}
{/* Multiplier */}
{phase === "idle" || phase === "starting" ? ( {lastResult ? (
{lastResult.status === "cashed" ? `Cashed out at ${formatMultiplier(lastResult.multiplier)} · +${formatSC(lastResult.win)}` : `Crashed at ${formatMultiplier(lastResult.crash)}`}
) : null}
{phase === "starting" ? "…" : "1.00×"}
{phase === "starting" ? "Starting round" : `Press ${definition.presentation.verb} before it ends`}
) : (
{formatMultiplier(display)}
{definition.curve.type === "steps" ? `Floor ${unitValue}` : `${Math.abs(unitValue).toLocaleString("en-US")} ${definition.presentation.unit}`} · {running ? `${formatSC(potential)} on the line` : ""}
{phase === "cashed" && lastResult ? ( {winLabel ?
{winLabel}
: null}
+{formatSC(lastResult.win)}
The round would have ended at {formatMultiplier(lastResult.crash)}
) : null} {phase === "crashed" ? ( {crashWord(definition.presentation.scene)} ) : null}
)}
{activeEvent && running ? ( {activeEvent} ) : null} {milestone ? ( {milestone} ) : null}
{error ? (
{error}
) : null}
{/* Controls */}
Balance
Bet {formatSC(bet)}
{running ? ( ) : ( )} {running ? `${formatSC(potential)}` : "space = start / cash out"}
Auto cash-out
setAutoTarget(e.target.value)} onBlur={() => setAutoTarget(String(Math.max(1.01, Math.min(definition.maxMultiplier, Number(autoTarget) || 2)).toFixed(2)))} 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" aria-label="Auto cash-out multiplier" /> ×
Max {formatMultiplier(definition.maxMultiplier)}
setInfoSheet(false)} game={game} definition={definition} /> setFairSheet(false)} round={round} /> setHistorySheet(false)} title="Recent rounds" side="right"> {history.length === 0 ?

No rounds yet.

: (
    {history.map((h, i) => (
  • {formatMultiplier(h.crash)}
  • ))}
)}
); } function crashWord(scene: string): string { return ( { sky: "Vanished", ocean: "Hull breach", rocket: "Engine failure", bank: "Busted", volcano: "Eruption", blackhole: "Pulled in", freefall: "Too late", reactor: "Meltdown", storm: "Swallowed", elevator: "Cable snapped", }[scene] ?? "Crashed" ); } function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: CrashGameDefinition }) { const [tab, setTab] = useState<"rules" | "about">("rules"); return ( {tab === "rules" ? (
    {game.rules.map((r, i) => (
  • {i + 1} {r}
  • ))}
) : (

{game.description}

{[ ["Volatility", game.volatility], ["RTP", `${(game.rtp * 100).toFixed(2)}% for any strategy`], ["Max multiplier", formatMultiplier(definition.maxMultiplier)], ["Instant end", `${((1 - game.rtp) * 100).toFixed(0)}% of rounds`], ["Reach 2× odds", `${((game.rtp / 2) * 100).toFixed(0)}%`], ["Reach 10× odds", `${((game.rtp / 10) * 100).toFixed(1)}%`], ].map(([k, v]) => (
{k}
{v}
))}

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.

)}
); } function FairSheet({ open, onClose, round }: { open: boolean; onClose: () => void; round: RoundView | null }) { return (

Before each round starts, the server draws the end multiplier and publishes a SHA-256 commitment of seed:multiplier. When the round ends, the seed is revealed so you can verify the commitment yourself.

{round ? (
Round {round.roundId}
Commitment {round.commitment}
Seed {round.seed ?? "revealed when the round ends"}
End multiplier {round.crashMultiplier ? round.crashMultiplier.toFixed(2) : "hidden while running"}
) : (

Start a round to see its commitment.

)}

Verify: sha256("seed:multiplier") with the multiplier formatted to two decimals must equal the commitment.

); }