"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, ChevronDown, ChevronUp, Heart, History, Info, Minus, Plus, Settings2, Volume2, VolumeX, Zap } from "lucide-react"; import { AUTO_SPIN_OPTIONS, BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo, type SpinResponse } from "@spinza/shared"; import type { SpinStep } 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 type { ClientDefinition, ClientOutcome } from "./types"; import { SlotRenderer } from "./renderer"; import { getSound } from "./sound"; interface Props { game: GameInfo; definition: ClientDefinition; } type Overlay = | { kind: "banner"; title: string; subtitle?: string } | { kind: "freespins"; title: string; spins: number } | { kind: "bonus"; name: string; picks: { cell: number; value: number; amount: number }[]; cells: number; total: number } | { kind: "jackpot"; tier: string; amount: number } | { kind: "win"; cls: string; amount: number; multiplier: number } | null; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); export function GameClient({ game, definition }: Props) { const router = useRouter(); const { status, wallet, settings, setBalance, setUserXp, user } = useSession(); const canvasRef = useRef(null); const rendererRef = useRef(null); const soundRef = useRef(getSound()); const [ready, setReady] = useState(false); const [progress, setProgress] = useState(0); const [bet, setBet] = useState(() => Math.max(definition.minBet, Math.min(100, definition.maxBet))); const [spinning, setSpinning] = useState(false); const [lastWin, setLastWin] = useState(0); const [displayWin, setDisplayWin] = useState(0); const [overlay, setOverlay] = useState(null); const [featureLabel, setFeatureLabel] = useState(null); const [fsInfo, setFsInfo] = useState<{ left: number; total: number } | null>(null); const [multiplier, setMultiplier] = useState(1); const [state, setState] = useState(null); const [auto, setAuto] = useState(0); const autoRef = useRef(0); const [autoSheet, setAutoSheet] = useState(false); const [betSheet, setBetSheet] = useState(false); const [infoSheet, setInfoSheet] = useState(false); const [settingsSheet, setSettingsSheet] = useState(false); const [historySheet, setHistorySheet] = useState(false); const [favorite, setFavorite] = useState(!!game.favorite); const [quick, setQuick] = useState(false); const [error, setError] = useState<{ code: string; message: string } | null>(null); const stopRequested = useRef(false); const soundOn = settings?.soundEnabled ?? true; const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]); const balance = wallet?.balance ?? 0; /* ------------------------------------------------------------- mount */ useEffect(() => { if (status === "guest") { router.replace(`/login?next=/games/${game.slug}`); return; } }, [status, router, game.slug]); useEffect(() => { if (!canvasRef.current || status !== "authenticated") return; const renderer = new SlotRenderer({ def: definition, reduceMotion: settings?.reduceMotion ?? false, intensity: settings?.animationIntensity ?? "high", quick, onReelStop: (i) => soundRef.current.reelStop(i), onCascade: () => soundRef.current.cascade(), onCoin: () => soundRef.current.coin(), }); rendererRef.current = renderer; let cancelled = false; (async () => { setProgress(15); try { await renderer.mount(canvasRef.current!); } catch (e) { console.error("renderer mount failed", e); if (!cancelled) setError({ code: "GAME_UNAVAILABLE", message: "Your browser could not start the game renderer (WebGL required)." }); return; } setProgress(60); try { const launch = await api<{ state: ClientOutcome["stateAfter"] | null }>(`/api/games/${game.slug}/launch`, { method: "POST" }); if (launch.state) setState(launch.state); } catch { /* non-blocking */ } setProgress(100); await wait(250); if (!cancelled) setReady(true); })(); return () => { cancelled = true; renderer.destroy(); rendererRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [status, game.slug]); useEffect(() => { rendererRef.current?.setOptions({ reduceMotion: settings?.reduceMotion ?? false, intensity: settings?.animationIntensity ?? "high", quick }); }, [settings?.reduceMotion, settings?.animationIntensity, quick]); 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); }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience]); useEffect(() => () => soundRef.current.destroy(), []); // Win counter animation. useEffect(() => { if (displayWin === lastWin) return; const from = displayWin; const to = lastWin; const start = performance.now(); const dur = to > from ? Math.min(1400, 300 + Math.log10(Math.max(1, to - from)) * 250) : 0; let raf = 0; const step = () => { const t = dur ? Math.min(1, (performance.now() - start) / dur) : 1; setDisplayWin(Math.round(from + (to - from) * (1 - Math.pow(1 - t, 3)))); if (t < 1) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastWin]); /* -------------------------------------------------------------- spin */ const playOutcome = useCallback( async (res: SpinResponse) => { const renderer = rendererRef.current; if (!renderer) return; const outcome = res.result as ClientOutcome; const sound = soundRef.current; let running = 0; let first = true; let inFreeSpins = false; for (let i = 0; i < outcome.steps.length; i++) { const step: SpinStep = outcome.steps[i]; const meta = step.meta; if (step.type === "freespin" && meta.label) { inFreeSpins = true; sound.bonus(); setOverlay({ kind: "freespins", title: meta.label, spins: meta.freeSpinsTotal ?? 0 }); await wait(quick ? 900 : 1700); setOverlay(null); setFeatureLabel(meta.label); } if (step.type === "freespin") setFsInfo({ left: meta.freeSpinsLeft ?? 0, total: meta.freeSpinsTotal ?? 0 }); if (step.type === "bonus" && meta.picks) { sound.bonus(); const cfg = definition.pickBonuses.find((b) => b.name === meta.bonusName); setOverlay({ kind: "bonus", name: meta.bonusName ?? "Bonus", picks: meta.picks, cells: cfg?.cells ?? 12, total: step.win }); await wait((quick ? 700 : 1100) * (meta.picks.length + 1)); setOverlay(null); } if (step.type === "jackpot" && meta.jackpot) { sound.jackpot(); setOverlay({ kind: "jackpot", tier: meta.jackpot.tier, amount: meta.jackpot.amount }); renderer.celebrate(4); await wait(quick ? 1500 : 2600); setOverlay(null); } if (step.type === "feature" && meta.label) { setOverlay({ kind: "banner", title: meta.label, subtitle: `Multiplier ×${step.multiplier}` }); await wait(quick ? 700 : 1300); setOverlay(null); } if (step.type === "respin" && meta.label === "Lock & Respin") { sound.bonus(); setOverlay({ kind: "banner", title: "Lock & Respin", subtitle: "Collect every coin" }); await wait(quick ? 700 : 1200); setOverlay(null); setFeatureLabel("Lock & Respin"); } if (meta.randomFeature) { setOverlay({ kind: "banner", title: meta.randomFeature }); await wait(quick ? 500 : 900); setOverlay(null); } if (meta.freeSpinsAwarded && step.type !== "base" && inFreeSpins && i > 0) { setOverlay({ kind: "banner", title: "Retrigger", subtitle: `+${meta.freeSpinsAwarded} free spins` }); await wait(quick ? 500 : 900); setOverlay(null); } setMultiplier(step.multiplier); await renderer.playStep(step, { first, showWins: true }); first = false; if (step.win > 0) { running += step.win; setLastWin(Math.min(running, outcome.totalWin)); const cls = classifyWin(step.win / res.bet); sound.win(cls === "none" || cls === "regular" ? 0 : cls === "win" ? 1 : cls === "big" ? 2 : 3); } if (step.type === "respin" && i === outcome.steps.length - 1 && step.win > 0) { running = outcome.totalWin; setLastWin(outcome.totalWin); } if (meta.freeSpinsAwarded && step.type === "base") { sound.bonus(); } } setFsInfo(null); setFeatureLabel(null); setMultiplier(1); setLastWin(outcome.totalWin); setState(outcome.stateAfter); // Win presentation. const cls = res.winClass; if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") { sound.bigWin(); const intensity = { big: 1, mega: 2, epic: 3, legendary: 5 }[cls]; renderer.celebrate(intensity); setOverlay({ kind: "win", cls, amount: outcome.totalWin, multiplier: res.multiplier }); await wait(quick ? 1400 : 2200 + intensity * 400); setOverlay(null); } setBalance(res.balance); setUserXp(res.xp.total, res.xp.level); if (res.xp.leveledUp) toast({ title: `Level ${res.xp.level} reached`, description: `+${formatSC(res.xp.levelReward)} level reward`, tone: "credit" }); for (const a of res.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" }); for (const m of res.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" }); return { bonus: outcome.freeSpinsTriggered || outcome.bonusTriggered }; }, [definition.pickBonuses, quick, setBalance, setUserXp], ); const spin = useCallback(async (): Promise<{ ok: boolean; bonus?: boolean }> => { if (spinning || !ready) return { ok: false }; const renderer = rendererRef.current; if (!renderer) return { ok: false }; if (balance < bet) { setError({ code: "INSUFFICIENT_CREDITS", message: "Not enough Spinza Credits for this bet." }); soundRef.current.error(); return { ok: false }; } setError(null); setSpinning(true); setLastWin(0); setDisplayWin(0); soundRef.current.unlock(); soundRef.current.spinStart(); setBalance(balance - bet); // optimistic; corrected by the server response renderer.startSpin(); const clientRoundId = crypto.randomUUID(); const started = performance.now(); try { const res = await api(`/api/games/${game.slug}/spin`, { json: { bet, clientRoundId } }); const elapsed = performance.now() - started; if (elapsed < 350) await wait(350 - elapsed); const r = await playOutcome(res); setSpinning(false); return { ok: true, bonus: r?.bonus }; } catch (e) { setSpinning(false); renderer.clearFx(); renderer.setGrid(renderer["currentGrid"] as string[][]); setBalance(balance); if (e instanceof ApiClientError) { if (e.status === 401) { router.replace(`/login?next=/games/${game.slug}`); return { ok: false }; } setError({ code: e.code, message: e.message }); if (e.code === "INSUFFICIENT_CREDITS" && e.details && typeof e.details === "object" && "balance" in e.details) setBalance((e.details as { balance: number }).balance); } else setError({ code: "UNKNOWN", message: "Something went wrong. Please try again." }); soundRef.current.error(); return { ok: false }; } }, [spinning, ready, balance, bet, game.slug, playOutcome, setBalance, router]); // Auto-spin loop. const startAuto = useCallback( async (count: number) => { autoRef.current = count; setAuto(count); stopRequested.current = false; while (autoRef.current > 0 && !stopRequested.current) { const r = await spin(); if (!r.ok) break; autoRef.current -= 1; setAuto(autoRef.current); if (r.bonus) break; // stop on bonus if ((useSession.getState().wallet?.balance ?? 0) < bet) break; await wait(quick ? 250 : 450); } autoRef.current = 0; setAuto(0); }, [spin, bet, quick], ); const stopAuto = () => { stopRequested.current = true; autoRef.current = 0; setAuto(0); }; // Keyboard: space to spin. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.code === "Space" && !e.repeat && !overlay && !betSheet && !infoSheet && !settingsSheet) { e.preventDefault(); if (auto) stopAuto(); else void spin(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [spin, auto, overlay, betSheet, infoSheet, settingsSheet]); 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); } }; const betIndex = bets.indexOf(bet); const palette = definition.presentation.palette; const frameStyle = FRAME_HUD[definition.presentation.frame] ?? FRAME_HUD.metal; const meter = definition.meter && state ? { value: state.meters[definition.meter.id] ?? 0, max: definition.meter.max } : null; const heat = definition.heat && state ? { value: state.heat, max: definition.heat.max } : null; const meterLevel = definition.meter && state ? state.meterLevels[definition.meter.id] ?? 0 : 0; if (status === "guest") return null; return (
{/* Top bar */}
{game.name}
{game.grid.reels}×{game.grid.rows} · {game.features[0]}
{/* Feature HUD */}
{featureLabel ? ( {featureLabel} {fsInfo ? ` · ${fsInfo.left} left of ${fsInfo.total}` : ""} ) : null} {multiplier > 1 ? ×{multiplier} : null} {meter && definition.meter ? (
{definition.meter.name}
{meter.value}/{meter.max} {meterLevel > 0 ? Lv {meterLevel} : null}
) : null} {heat && definition.heat ? (
Heat
×{definition.heat.ladder[Math.min(Math.floor(heat.value / definition.heat.bandSize), definition.heat.ladder.length - 1)]}
) : null}
{/* Canvas */}
{/* Splash */} {!ready ? (
Spinza presents
{game.name}
{game.tagline}
Loading {progress}%
) : null} {/* Error */} {error ? (
{errorTitle(error.code)}
{error.message}
{error.code === "INSUFFICIENT_CREDITS" ? (
) : error.code === "MAINTENANCE" ? ( ) : ( )}
) : null}
{/* Controls */}
{/* Balance + bet */}
Balance
{/* Spin */}
{auto > 0 ? ( ) : ( )}
{/* Win */}
Win
0 ? "text-credit" : "text-fg-4")}> {displayWin > 0 ? formatSC(displayWin) : "—"}
{/* Sheets */} setBetSheet(false)} title="Bet per spin">
{bets.map((b) => ( ))}

Bets are in fictional Spinza Credits. Max win {formatMultiplier(game.maxMultiplier)} the bet.

setAutoSheet(false)} title="Auto spin">
{AUTO_SPIN_OPTIONS.map((n) => ( ))}

Auto spin stops automatically when a bonus triggers, when your balance is too low for the bet, or when you press STOP.

setInfoSheet(false)} game={game} definition={definition} /> setSettingsSheet(false)} /> setHistorySheet(false)} slug={game.slug} /> {user?.username}
); } /** Per-frame HUD treatment so the control bar matches the reel frame material. */ const FRAME_HUD: Record = { metal: { controls: "linear-gradient(180deg, rgba(20,24,33,0.85), rgba(9,11,16,0.92))" }, glass: { controls: "linear-gradient(180deg, rgba(255,255,255,0.06), rgba(10,12,20,0.85))" }, gold: { controls: "linear-gradient(180deg, rgba(40,30,12,0.85), rgba(12,9,4,0.95))" }, stone: { controls: "linear-gradient(180deg, rgba(28,25,18,0.9), rgba(10,9,6,0.95))" }, ice: { controls: "linear-gradient(180deg, rgba(120,190,230,0.12), rgba(8,14,22,0.92))" }, carbon: { controls: "repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0 2px, rgba(0,0,0,0) 2px 5px), rgba(7,8,11,0.95)" }, neon: { controls: "linear-gradient(180deg, rgba(10,8,24,0.85), rgba(4,3,12,0.95))" }, obsidian: { controls: "rgba(0,0,0,0.92)" }, }; function shade(hexColor: string, amt: number): string { const n = parseInt(hexColor.replace("#", ""), 16); const ch = (v: number) => Math.max(0, Math.min(255, Math.round(v + 255 * amt))); const r = ch((n >> 16) & 255); const g = ch((n >> 8) & 255); const b = ch(n & 255); return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`; } function textOn(hexColor: string): string { const n = parseInt(hexColor.replace("#", ""), 16); const lum = (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255; return lum > 0.55 ? "#0b0a06" : "#ffffff"; } function errorTitle(code: string): string { return ( { INSUFFICIENT_CREDITS: "Insufficient credits", NETWORK: "Connection lost", MAINTENANCE: "Spinza is getting an upgrade", GAME_UNAVAILABLE: "Game unavailable", RATE_LIMITED: "Slow down", UNAUTHORIZED: "Session expired", }[code] ?? "Something went wrong" ); } /* ----------------------------------------------------------------- overlays */ function Overlays({ overlay, palette }: { overlay: Overlay; palette: ClientDefinition["presentation"]["palette"] }) { return ( {overlay ? (
{overlay.kind === "banner" ? (
{overlay.title}
{overlay.subtitle ?
{overlay.subtitle}
: null}
) : null} {overlay.kind === "freespins" ? (
Feature unlocked
{overlay.title}
{overlay.spins} spins
) : null} {overlay.kind === "jackpot" ? (
{overlay.tier} jackpot
{formatSC(overlay.amount)}
Fictional credits · no cash value
) : null} {overlay.kind === "win" ? : null} {overlay.kind === "bonus" ? : null} ) : null} ); } function WinPresentation({ cls, amount, multiplier }: { cls: string; amount: number; multiplier: number }) { const label = WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN"; const [shown, setShown] = useState(0); useEffect(() => { const start = performance.now(); const dur = 1400; let raf = 0; const step = () => { const t = Math.min(1, (performance.now() - start) / dur); setShown(Math.round(amount * (1 - Math.pow(1 - t, 3)))); if (t < 1) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [amount]); const size = { big: "text-[clamp(34px,9vw,72px)]", mega: "text-[clamp(38px,10vw,84px)]", epic: "text-[clamp(42px,11vw,96px)]", legendary: "text-[clamp(46px,12vw,110px)]" }[cls] ?? "text-5xl"; return (
{label}
{formatSC(shown)}
{formatMultiplier(multiplier)} the bet
); } function BonusReveal({ name, picks, cells, total, palette }: { name: string; picks: { cell: number; value: number; amount: number }[]; cells: number; total: number; palette: ClientDefinition["presentation"]["palette"] }) { const [revealed, setRevealed] = useState(0); useEffect(() => { if (revealed >= picks.length) return; const t = setTimeout(() => setRevealed((r) => r + 1), 900); return () => clearTimeout(t); }, [revealed, picks.length]); const byCell = new Map(picks.slice(0, revealed).map((p) => [p.cell, p])); const cols = cells <= 6 ? 3 : cells <= 9 ? 3 : 4; return (
Bonus game
{name}
{Array.from({ length: cells }, (_, i) => { const p = byCell.get(i); return ( {p ? formatSC(p.amount, { unit: false }) : "?"} ); })}
Total {formatSC(revealed >= picks.length ? total : picks.slice(0, revealed).reduce((a, p) => a + p.amount, 0))}
); } /* ------------------------------------------------------------------ sheets */ function GameInfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ClientDefinition }) { const [tab, setTab] = useState<"rules" | "paytable" | "about">("rules"); const symbols = new Map(definition.symbols.map((s) => [s.id, s])); return ( {tab === "rules" ? (
    {game.rules.map((r, i) => (
  • {i + 1} {r}
  • ))}
) : null} {tab === "paytable" ? (

Pays shown as multiples of the total bet{definition.payModel.type === "ways" ? " per way" : " per line"}. Scatter pays apply anywhere.

{game.paytable.map((row) => { const s = symbols.get(row.symbol); return (
{s?.style.label ?? row.symbol}
{row.label}
{s?.kind}
{Object.entries(row.pays) .sort((a, b) => Number(a[0]) - Number(b[0])) .map(([k, v]) => ( {k}× {v} ))}
); })}
) : null} {tab === "about" ? (

{game.description}

{game.certification ? (
Internal certification · {game.certification.status}
{game.certification.spins.toLocaleString("en-US")} simulated spins · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%
) : null}

RTP is a gameplay-balancing statistic over millions of simulated spins. Spinza Credits are fictional and have no cash value.

) : null}
); } function Stat({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function SettingsSheet({ open, onClose }: { open: boolean; onClose: () => void }) { const { settings, setSettings } = useSession(); if (!settings) return null; return (
setSettings({ masterVolume: v })} /> setSettings({ musicVolume: v })} /> setSettings({ effectsVolume: v })} />
Animation intensity
setSettings({ animationIntensity: v })} items={[{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }]} />
All account settings
); } function Slider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) { return ( ); } function HistorySheet({ open, onClose, slug }: { open: boolean; onClose: () => void; slug: string }) { return ( {open ? : null} ); } type HistoryRow = { roundId: string; bet: number; win: number; multiplier: number; balanceAfter: number; createdAt: string; features: string[] }; function HistoryList({ slug }: { slug: string }) { const [rows, setRows] = useState(null); const [expanded, setExpanded] = useState(null); useEffect(() => { let alive = true; api<{ entries: HistoryRow[] }>(`/api/games/${slug}/history?limit=30`) .then((r) => alive && setRows(r.entries)) .catch(() => alive && setRows([])); return () => { alive = false; }; }, [slug]); return ( <> {rows === null ? (
Loading…
) : rows.length === 0 ? (
No rounds yet on this game.
) : (
    {rows.map((r) => (
  • {expanded === r.roundId ? (
    {r.roundId}
    Balance after: {formatSC(r.balanceAfter)}
    {r.features.length ?
    Features: {r.features.join(", ")}
    : null}
    ) : null}
  • ))}
)} ); }