/** * CoinRush Plinko - Premium Casino Game * Matter.js Physics + React + Stake.com Style * * Features: * - Realistic ball physics with gravity and bounce * - Provably fair ball path (server-determined) * - Multiple risk levels with different multipliers * - Dynamic row scaling (8-16 rows) * - WebSocket for live balance updates * - Full auth integration */ const { useState, useEffect, useRef, useCallback } = React; const { Engine, Render, World, Bodies, Body, Events, Runner } = Matter; // ============================================ // SVG ICONS (replacing emojis) // ============================================ const Icons = { coin: () => ( ), slotMachine: () => ( ), volumeOn: () => ( ), volumeOff: () => ( ), check: () => ( ) }; // ============================================ // UTILITIES // ============================================ const formatCoins = (cents) => { const coins = (cents || 0) / 100; return coins.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; const parseCoinsInput = (input) => { if (typeof input === 'number') return Math.round(input * 100); const normalized = String(input).trim().replace(/\./g, '').replace(',', '.'); const parsed = parseFloat(normalized); if (!isFinite(parsed)) return 0; return Math.round(parsed * 100); }; // ============================================ // MULTIPLIER TABLES FOR DIFFERENT ROWS // ============================================ const MULTIPLIER_TABLES = { 8: { low: [5.6, 2.1, 1.1, 1, 0.5, 1, 1.1, 2.1, 5.6], medium: [13, 3, 1.3, 0.7, 0.4, 0.7, 1.3, 3, 13], high: [29, 9, 2, 1.4, 0.5, 1.4, 2, 9, 29] }, 9: { low: [5.6, 2, 1.6, 1, 0.7, 0.7, 1, 1.6, 2, 5.6], medium: [18, 4, 1.7, 0.9, 0.5, 0.5, 0.9, 1.7, 4, 18], high: [43, 13, 3, 1.3, 0.7, 0.7, 1.3, 3, 13, 43] }, 10: { low: [8.9, 3, 1.4, 1.1, 1, 0.5, 1, 1.1, 1.4, 3, 8.9], medium: [22, 5, 2, 1.4, 0.6, 0.4, 0.6, 1.4, 2, 5, 22], high: [76, 10, 3, 1.5, 0.5, 0.3, 0.5, 1.5, 3, 10, 76] }, 11: { low: [8.4, 3, 1.9, 1.3, 1, 0.7, 0.7, 1, 1.3, 1.9, 3, 8.4], medium: [24, 6, 3, 1.8, 0.7, 0.5, 0.5, 0.7, 1.8, 3, 6, 24], high: [120, 14, 5.2, 1.4, 0.7, 0.4, 0.4, 0.7, 1.4, 5.2, 14, 120] }, 12: { low: [10, 3, 1.6, 1.4, 1.1, 1, 0.5, 1, 1.1, 1.4, 1.6, 3, 10], medium: [33, 11, 4, 2, 1.1, 0.6, 0.3, 0.6, 1.1, 2, 4, 11, 33], high: [170, 24, 8.1, 2, 0.7, 0.2, 0.2, 0.2, 0.7, 2, 8.1, 24, 170] }, 13: { low: [8.1, 4, 3, 1.9, 1.2, 0.9, 0.7, 0.7, 0.9, 1.2, 1.9, 3, 4, 8.1], medium: [43, 13, 6, 3, 1.3, 0.7, 0.4, 0.4, 0.7, 1.3, 3, 6, 13, 43], high: [260, 37, 11, 4, 1, 0.4, 0.2, 0.2, 0.4, 1, 4, 11, 37, 260] }, 14: { low: [7.1, 4, 1.9, 1.4, 1.3, 1.1, 1, 0.5, 1, 1.1, 1.3, 1.4, 1.9, 4, 7.1], medium: [58, 15, 7, 4, 1.9, 1, 0.5, 0.2, 0.5, 1, 1.9, 4, 7, 15, 58], high: [420, 56, 18, 5, 1.9, 0.5, 0.2, 0.2, 0.2, 0.5, 1.9, 5, 18, 56, 420] }, 15: { low: [15, 8, 3, 2, 1.5, 1.1, 1, 0.7, 0.7, 1, 1.1, 1.5, 2, 3, 8, 15], medium: [88, 18, 11, 5, 3, 1.3, 0.5, 0.3, 0.3, 0.5, 1.3, 3, 5, 11, 18, 88], high: [620, 83, 27, 8, 3, 0.5, 0.2, 0.2, 0.2, 0.2, 0.5, 3, 8, 27, 83, 620] }, 16: { low: [16, 9, 2, 1.4, 1.4, 1.2, 1.1, 1, 0.5, 1, 1.1, 1.2, 1.4, 1.4, 2, 9, 16], medium: [110, 41, 10, 5, 3, 1.5, 1, 0.5, 0.3, 0.5, 1, 1.5, 3, 5, 10, 41, 110], high: [1000, 130, 26, 9, 4, 2, 0.2, 0.2, 0.2, 0.2, 0.2, 2, 4, 9, 26, 130, 1000] } }; // ============================================ // PLINKO CONFIGURATION // ============================================ const getConfig = (rows, containerHeight = null) => { // Calculate dimensions to fit viewport // Default height calculation, but can be overridden const navbarHeight = 72; const padding = 40; const headerHeight = 50; const bucketsHeight = 40; // If container height provided, use it; otherwise calculate from viewport let availableHeight; if (containerHeight) { availableHeight = containerHeight - headerHeight - bucketsHeight - 20; } else { availableHeight = window.innerHeight - navbarHeight - padding - headerHeight - bucketsHeight - 40; } // Clamp height to reasonable bounds const height = Math.max(350, Math.min(550, availableHeight)); // Calculate width based on aspect ratio (slightly wider than tall) const width = Math.min(760, height * 1.3); return { // Board dimensions - responsive CANVAS_WIDTH: width, CANVAS_HEIGHT: height, // Physics - tuned for realistic bouncing GRAVITY: 1.2, BALL_RADIUS: Math.max(6, Math.floor(height / 80)), PEG_RADIUS: Math.max(4, Math.floor(height / 120)), BALL_FRICTION: 0.3, BALL_RESTITUTION: 0.5, // Layout START_Y: 40, BUCKET_HEIGHT: 40, // Colors - Stake.com style COLORS: { background: '#0d1117', peg: '#1e3a5f', pegHit: '#00e701', ball: '#ff9500', ballGlow: 'rgba(255, 149, 0, 0.6)', } }; }; // ============================================ // PLINKO GAME COMPONENT // ============================================ function PlinkoGame() { // Auth & Balance state const [user, setUser] = useState(null); const [balance, setBalance] = useState(0); const [wsConnected, setWsConnected] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); // Game state const [betAmount, setBetAmount] = useState(100); const [betDisplay, setBetDisplay] = useState('1,00'); const [risk, setRisk] = useState('medium'); const [rows, setRows] = useState(16); const [isDropping, setIsDropping] = useState(false); const [activeBalls, setActiveBalls] = useState(0); const [lastResult, setLastResult] = useState(null); const [showResult, setShowResult] = useState(false); const [soundEnabled, setSoundEnabled] = useState(true); const [autoMode, setAutoMode] = useState(false); const [autoBets, setAutoBets] = useState(0); const [autoRunning, setAutoRunning] = useState(false); // Persistent history - load from localStorage const [history, setHistory] = useState(() => { try { const saved = localStorage.getItem('plinko_history'); return saved ? JSON.parse(saved) : []; } catch { return []; } }); // Save history to localStorage whenever it changes useEffect(() => { try { localStorage.setItem('plinko_history', JSON.stringify(history.slice(0, 50))); } catch {} }, [history]); // Physics refs const canvasRef = useRef(null); const engineRef = useRef(null); const renderRef = useRef(null); const runnerRef = useRef(null); const worldRef = useRef(null); const configRef = useRef(getConfig(rows)); const cleanupRef = useRef(null); // Spacebar rapid-fire refs const spaceHeldRef = useRef(false); const dropIntervalRef = useRef(null); const DROP_INTERVAL = 100; // ms between drops when holding spacebar // WebSocket ref const wsRef = useRef(null); // Get multipliers for current settings const multipliers = MULTIPLIER_TABLES[rows]?.[risk] || MULTIPLIER_TABLES[16][risk]; // ============================================ // WEBSOCKET CONNECTION (use shared lobby) // ============================================ useEffect(() => { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${wsProtocol}//${window.location.host}/ws/lobby`; const connect = () => { try { const socket = new WebSocket(wsUrl); wsRef.current = socket; socket.onopen = () => { setWsConnected(true); }; socket.onmessage = (event) => { try { const data = JSON.parse(event.data); // Handle balance updates if (data.type === 'balance_update') { setBalance(data.balance); if (window.updateCoinRushBalance) { window.updateCoinRushBalance(data.balance, data.delta || null); } } } catch (e) { console.error('WebSocket message error:', e); } }; socket.onclose = () => { setWsConnected(false); // Reconnect after 3 seconds setTimeout(connect, 3000); }; socket.onerror = () => { setWsConnected(false); }; } catch (e) { console.error('WebSocket connection error:', e); setWsConnected(false); } }; connect(); return () => { if (wsRef.current) { wsRef.current.close(); } }; }, []); // ============================================ // FETCH USER DATA // ============================================ useEffect(() => { fetchUserData(); // Listen for auth changes const handleAuthChange = () => fetchUserData(); window.addEventListener('coinrush:auth', handleAuthChange); window.addEventListener('coinrush:balance', (e) => { if (e.detail?.balance !== undefined) { setBalance(e.detail.balance); } }); return () => { window.removeEventListener('coinrush:auth', handleAuthChange); }; }, []); const fetchUserData = async () => { try { // Use credentials: include to send cookies (same as other pages) const res = await fetch('/me', { credentials: 'include' }); if (res.ok) { const data = await res.json(); setUser(data); setBalance(data.balance || 0); } else { setUser(null); setBalance(0); } } catch (err) { console.error('Failed to fetch user:', err); setUser(null); setBalance(0); } }; // ============================================ // INITIALIZE/REBUILD PHYSICS ENGINE // ============================================ const initPhysics = useCallback(() => { if (!canvasRef.current) return; // Cleanup previous engine if (cleanupRef.current) { cleanupRef.current(); } const config = getConfig(rows); configRef.current = config; // Create engine const engine = Engine.create({ gravity: { x: 0, y: config.GRAVITY } }); engineRef.current = engine; worldRef.current = engine.world; // Create renderer const render = Render.create({ canvas: canvasRef.current, engine: engine, options: { width: config.CANVAS_WIDTH, height: config.CANVAS_HEIGHT, wireframes: false, background: config.COLORS.background, pixelRatio: Math.min(window.devicePixelRatio, 2) } }); renderRef.current = render; // Create runner const runner = Runner.create(); runnerRef.current = runner; // Build the board buildBoard(engine, config); // Collision detection const collisionHandler = (event) => handleCollision(event, config); Events.on(engine, 'collisionStart', collisionHandler); // After each physics update, gently guide balls toward their target const afterUpdateHandler = () => { const bodies = engine.world.bodies; bodies.forEach(body => { if (body.label === 'ball' && !body.hasLanded && body.targetX !== undefined) { const ballX = body.position.x; const ballY = body.position.y; const targetX = body.targetX; // How far down? (0 = top, 1 = bottom) const progress = Math.max(0, (ballY - config.START_Y) / (config.CANVAS_HEIGHT - config.START_Y - config.BUCKET_HEIGHT)); // Error from target const error = targetX - ballX; // Apply gentle corrective force - stronger near the bottom const strength = 0.00003 * (1 + progress * 3); const correction = Math.sign(error) * Math.min(Math.abs(error) * strength, 0.0005); Body.applyForce(body, body.position, { x: correction, y: 0 }); // Keep ball in bounds if (ballX < 30) { Body.setPosition(body, { x: 30, y: ballY }); Body.setVelocity(body, { x: Math.abs(body.velocity.x) * 0.5, y: body.velocity.y }); } if (ballX > config.CANVAS_WIDTH - 30) { Body.setPosition(body, { x: config.CANVAS_WIDTH - 30, y: ballY }); Body.setVelocity(body, { x: -Math.abs(body.velocity.x) * 0.5, y: body.velocity.y }); } } }); }; Events.on(engine, 'afterUpdate', afterUpdateHandler); // Start Render.run(render); Runner.run(runner, engine); // Store cleanup function cleanupRef.current = () => { Events.off(engine, 'collisionStart', collisionHandler); Events.off(engine, 'afterUpdate', afterUpdateHandler); Render.stop(render); Runner.stop(runner); World.clear(engine.world); Engine.clear(engine); }; }, [rows, risk]); useEffect(() => { initPhysics(); return () => { if (cleanupRef.current) { cleanupRef.current(); } }; }, [initPhysics]); // ============================================ // BUILD PLINKO BOARD // ============================================ const buildBoard = (engine, config) => { const bodies = []; const numBuckets = rows + 1; // Calculate layout const boardPadding = 40; const availableWidth = config.CANVAS_WIDTH - boardPadding * 2; const bucketWidth = availableWidth / numBuckets; // Peg area const pegAreaTop = config.START_Y; const pegAreaBottom = config.CANVAS_HEIGHT - config.BUCKET_HEIGHT - 20; const pegAreaHeight = pegAreaBottom - pegAreaTop; const rowSpacing = pegAreaHeight / rows; // Create pegs in pyramid pattern // The bottom row has (rows + 2) pegs, top row has 3 pegs for (let row = 0; row < rows; row++) { const pegsInRow = row + 3; const rowWidth = (pegsInRow - 1) * bucketWidth; const startX = (config.CANVAS_WIDTH - rowWidth) / 2; const y = pegAreaTop + row * rowSpacing; for (let col = 0; col < pegsInRow; col++) { const x = startX + col * bucketWidth; const peg = Bodies.circle(x, y, config.PEG_RADIUS, { isStatic: true, restitution: 0.6, friction: 0.0, label: 'peg', render: { fillStyle: config.COLORS.peg } }); bodies.push(peg); } } // Create bucket area at bottom const bucketY = config.CANVAS_HEIGHT - config.BUCKET_HEIGHT / 2; const bucketStartX = (config.CANVAS_WIDTH - availableWidth) / 2; // Bucket dividers for (let i = 0; i <= numBuckets; i++) { const x = bucketStartX + i * bucketWidth; const divider = Bodies.rectangle(x, bucketY, 4, config.BUCKET_HEIGHT, { isStatic: true, label: 'divider', render: { fillStyle: '#1a1f2e' } }); bodies.push(divider); } // Bucket floors (sensors for detection) for (let i = 0; i < numBuckets; i++) { const x = bucketStartX + (i + 0.5) * bucketWidth; const floor = Bodies.rectangle(x, config.CANVAS_HEIGHT - 8, bucketWidth - 8, 12, { isStatic: true, label: `bucket_${i}`, isSensor: true, render: { fillStyle: getBucketColorForIndex(i, numBuckets), opacity: 0.9 } }); bodies.push(floor); } // Side walls - solid walls to keep ball in bounds const wallThickness = 40; bodies.push( // Left wall - extends beyond canvas Bodies.rectangle(-wallThickness / 2 + 10, config.CANVAS_HEIGHT / 2, wallThickness, config.CANVAS_HEIGHT + 100, { isStatic: true, restitution: 0.5, label: 'wall', render: { visible: false } }), // Right wall - extends beyond canvas Bodies.rectangle(config.CANVAS_WIDTH + wallThickness / 2 - 10, config.CANVAS_HEIGHT / 2, wallThickness, config.CANVAS_HEIGHT + 100, { isStatic: true, restitution: 0.5, label: 'wall', render: { visible: false } }), // Bottom - catches any balls Bodies.rectangle(config.CANVAS_WIDTH / 2, config.CANVAS_HEIGHT + 20, config.CANVAS_WIDTH + 100, 40, { isStatic: true, label: 'floor', render: { visible: false } }) ); World.add(engine.world, bodies); }; // Helper to get bucket color based on multiplier const getBucketColorForIndex = (index, total) => { const mult = multipliers[index] || 1; if (mult >= 100) return '#ff0055'; if (mult >= 50) return '#ff2266'; if (mult >= 20) return '#ff4477'; if (mult >= 10) return '#ff6644'; if (mult >= 5) return '#ff8833'; if (mult >= 2) return '#ffaa22'; if (mult >= 1) return '#cccc00'; if (mult >= 0.5) return '#66bb66'; return '#44aa44'; }; // ============================================ // HANDLE COLLISION - Simple and reliable // ============================================ const handleCollision = (event, config) => { event.pairs.forEach(pair => { const { bodyA, bodyB } = pair; // Ball hit bucket if (bodyA.label?.startsWith('bucket_') || bodyB.label?.startsWith('bucket_')) { const ballBody = bodyA.label === 'ball' ? bodyA : (bodyB.label === 'ball' ? bodyB : null); if (ballBody && !ballBody.hasLanded) { ballBody.hasLanded = true; // Use the SERVER-determined bucket for correct payout onBallLand(ballBody, ballBody.targetBucket); // Remove ball after a short delay setTimeout(() => { if (engineRef.current) { World.remove(engineRef.current.world, ballBody); } }, 500); } } // Ball hit floor (fallback if misses bucket) if ((bodyA.label === 'floor' || bodyB.label === 'floor')) { const ballBody = bodyA.label === 'ball' ? bodyA : (bodyB.label === 'ball' ? bodyB : null); if (ballBody && !ballBody.hasLanded) { ballBody.hasLanded = true; onBallLand(ballBody, ballBody.targetBucket); setTimeout(() => { if (engineRef.current) { World.remove(engineRef.current.world, ballBody); } }, 300); } } // Peg hit - just flash, no physics manipulation if ((bodyA.label === 'peg' || bodyB.label === 'peg') && (bodyA.label === 'ball' || bodyB.label === 'ball')) { const pegBody = bodyA.label === 'peg' ? bodyA : bodyB; if (soundEnabled) playPegSound(); // Flash peg green const originalColor = pegBody.render.fillStyle; pegBody.render.fillStyle = config.COLORS.pegHit; setTimeout(() => { pegBody.render.fillStyle = originalColor; }, 100); } }); }; // ============================================ // ON BALL LAND // ============================================ const onBallLand = (ball, bucketIndex) => { const mult = multipliers[bucketIndex] || 1; const payout = Math.floor(ball.betAmount * mult); const profit = payout - ball.betAmount; const isWin = profit > 0; const isBigWin = mult >= 5; setLastResult({ bucketIndex, multiplier: mult, payout, profit, isWin }); setHistory(prev => [{ multiplier: mult, isWin, timestamp: Date.now() }, ...prev.slice(0, 49)]); // Keep 50 entries // Update balance from server response stored on ball if (ball.serverBalance !== undefined) { setBalance(ball.serverBalance); if (window.updateCoinRushBalance) { const delta = payout - ball.betAmount; window.updateCoinRushBalance(ball.serverBalance, isWin ? delta : null); } } setShowResult(true); setTimeout(() => setShowResult(false), 1500); if (soundEnabled) { if (isBigWin) { playBigWinSound(); } else if (isWin) { playWinSound(); } else { playLoseSound(); } } // Remove ball setTimeout(() => { if (engineRef.current) { World.remove(engineRef.current.world, ball); setActiveBalls(prev => Math.max(0, prev - 1)); } setIsDropping(false); }, 300); }; // ============================================ // DROP BALL - Allows up to 30 concurrent balls // ============================================ const MAX_BALLS = 30; const dropBall = async () => { if (!user) { setShowAuthModal(true); return; } if (betAmount < 100) { return; // Silently ignore - don't alert during rapid fire } if (betAmount > balance) { return; // Silently ignore - don't alert during rapid fire } // Allow multiple balls, up to MAX_BALLS if (activeBalls >= MAX_BALLS) { return; // Silently ignore if too many balls } try { const res = await fetch('/api/plinko/drop', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: betAmount, risk: risk, rows: rows }) }); if (!res.ok) { const error = await res.json(); throw new Error(error.detail || 'Failed to drop ball'); } const data = await res.json(); // Update balance immediately (deducted) setBalance(data.balance); // Play drop sound if (soundEnabled) playDropSound(); // Create ball with slight random delay for visual variety when spamming const delay = Math.random() * 40; setTimeout(() => { createBall(data.path, data.bucket_index, data.balance, data.payout); setActiveBalls(prev => prev + 1); }, delay); } catch (err) { console.error('Drop error:', err); } }; // ============================================ // SPACEBAR HOLD TO DROP - Rapid fire mode // ============================================ useEffect(() => { const handleKeyDown = (e) => { // Don't trigger if typing in an input if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.code === 'Space') { e.preventDefault(); // Only start interval on first press (not repeat) if (!e.repeat && !spaceHeldRef.current) { spaceHeldRef.current = true; // Drop first ball immediately dropBall(); // Start interval for continuous drops dropIntervalRef.current = setInterval(() => { if (spaceHeldRef.current) { dropBall(); } }, DROP_INTERVAL); } } }; const handleKeyUp = (e) => { if (e.code === 'Space') { e.preventDefault(); spaceHeldRef.current = false; // Clear the interval if (dropIntervalRef.current) { clearInterval(dropIntervalRef.current); dropIntervalRef.current = null; } } }; // Cleanup when window loses focus (release spacebar) const handleBlur = () => { spaceHeldRef.current = false; if (dropIntervalRef.current) { clearInterval(dropIntervalRef.current); dropIntervalRef.current = null; } }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); window.addEventListener('blur', handleBlur); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); if (dropIntervalRef.current) { clearInterval(dropIntervalRef.current); } }; }, [user, betAmount, balance, activeBalls, risk, rows, soundEnabled]); // ============================================ // AUTO MODE - Automatic continuous drops // ============================================ const autoIntervalRef = useRef(null); const autoRunningRef = useRef(false); // Ref to track running state in interval const [autoDropCount, setAutoDropCount] = useState(10); // Number of drops const [autoDropSpeed, setAutoDropSpeed] = useState(200); // ms between drops const [autoDropsRemaining, setAutoDropsRemaining] = useState(0); const startAutoMode = () => { if (!user || autoRunningRef.current) return; autoRunningRef.current = true; setAutoRunning(true); setAutoDropsRemaining(autoDropCount); let remaining = autoDropCount; // Drop first ball immediately dropBall(); remaining--; setAutoDropsRemaining(remaining); // Continue dropping at interval autoIntervalRef.current = setInterval(() => { // Check ref instead of state (state has closure issues) if (remaining <= 0 || !autoRunningRef.current) { stopAutoMode(); return; } dropBall(); remaining--; setAutoDropsRemaining(remaining); if (remaining <= 0) { stopAutoMode(); } }, autoDropSpeed); }; const stopAutoMode = () => { autoRunningRef.current = false; setAutoRunning(false); setAutoDropsRemaining(0); if (autoIntervalRef.current) { clearInterval(autoIntervalRef.current); autoIntervalRef.current = null; } }; // Cleanup auto mode on unmount useEffect(() => { return () => { if (autoIntervalRef.current) { clearInterval(autoIntervalRef.current); } }; }, []); // ============================================ // CREATE BALL - Plinko-style bouncy animation // ============================================ const createBall = (path, targetBucket, serverBalance, serverPayout) => { if (!engineRef.current) return; const config = configRef.current; const numBuckets = rows + 1; const boardPadding = 40; const availableWidth = config.CANVAS_WIDTH - boardPadding * 2; const bucketWidth = availableWidth / numBuckets; // Peg layout calculations const pegAreaTop = config.START_Y; const pegAreaBottom = config.CANVAS_HEIGHT - config.BUCKET_HEIGHT - 20; const rowSpacing = (pegAreaBottom - pegAreaTop) / rows; // Build waypoints from server path const waypoints = []; let posIdx = 1; // Starting position waypoints.push({ x: config.CANVAS_WIDTH / 2, y: pegAreaTop - 25, type: 'start' }); // Each peg bounce - realistic multi-bounce physics for (let row = 0; row < rows; row++) { const pegsInRow = row + 3; const rowWidth = (pegsInRow - 1) * bucketWidth; const rowStartX = (config.CANVAS_WIDTH - rowWidth) / 2; const pegX = rowStartX + posIdx * bucketWidth; const pegY = pegAreaTop + row * rowSpacing; const goRight = path && path[row] === 1; const dir = goRight ? 1 : -1; // 1. Hit the peg (ball touches peg) waypoints.push({ x: pegX + dir * 2, y: pegY, pegX, pegY, type: 'peg' }); // 2. First bounce - big deflection upward waypoints.push({ x: pegX + dir * bucketWidth * 0.22, y: pegY + rowSpacing * 0.25, type: 'bounce1', arcHeight: 12 }); // 3. Second bounce - medium waypoints.push({ x: pegX + dir * bucketWidth * 0.32, y: pegY + rowSpacing * 0.48, type: 'bounce2', arcHeight: 7 }); // 4. Third small bounce - settling waypoints.push({ x: pegX + dir * bucketWidth * 0.38, y: pegY + rowSpacing * 0.68, type: 'bounce3', arcHeight: 3 }); // 5. Final settle into position for next peg waypoints.push({ x: pegX + dir * bucketWidth * 0.42, y: pegY + rowSpacing * 0.90, type: 'settle' }); if (goRight) posIdx++; } // Final bucket position const bucketX = boardPadding + (targetBucket + 0.5) * bucketWidth; waypoints.push({ x: bucketX, y: config.CANVAS_HEIGHT - 12, type: 'bucket' }); // Create ball (static - we animate manually) const ball = Bodies.circle(waypoints[0].x, waypoints[0].y, config.BALL_RADIUS, { isStatic: true, label: 'ball', collisionFilter: { group: -1 }, render: { fillStyle: config.COLORS.ball, strokeStyle: config.COLORS.ballGlow, lineWidth: 2 } }); ball.betAmount = betAmount; ball.serverBalance = serverBalance; ball.serverPayout = serverPayout; ball.targetBucket = targetBucket; World.add(engineRef.current.world, ball); // Animation state let segmentIdx = 0; let segmentStart = performance.now(); const animate = (now) => { if (!engineRef.current) return; // Done? if (segmentIdx >= waypoints.length - 1) { World.remove(engineRef.current.world, ball); onBallLand(ball, targetBucket); return; } const from = waypoints[segmentIdx]; const to = waypoints[segmentIdx + 1]; const elapsed = now - segmentStart; // Variable durations for different segment types let duration; if (to.type === 'peg') { duration = 32 + Math.random() * 6; // Quick fall to peg } else if (to.type === 'bounce1') { duration = 26 + Math.random() * 5; // Fast first bounce } else if (to.type === 'bounce2') { duration = 22 + Math.random() * 4; // Medium second bounce } else if (to.type === 'bounce3') { duration = 18 + Math.random() * 3; // Quick third bounce } else if (to.type === 'settle') { duration = 14 + Math.random() * 3; // Quick settle } else if (to.type === 'bucket') { duration = 55; // Slower fall to bucket } else { duration = 35; } let t = Math.min(elapsed / duration, 1); // Different easing for different phases let eased; if (to.type === 'peg') { // Accelerating into peg (ease-in quad) eased = t * t; } else if (to.type === 'bounce1' || to.type === 'bounce2' || to.type === 'bounce3') { // Quick bounce away (ease-out) eased = 1 - Math.pow(1 - t, 2); } else if (to.type === 'settle') { // Gentle settle (ease-out cubic) eased = 1 - Math.pow(1 - t, 3); } else { // Smooth ease-out for bucket eased = 1 - Math.pow(1 - t, 3); } // Interpolate position let x = from.x + (to.x - from.x) * eased; let y = from.y + (to.y - from.y) * eased; // Add bounce arc for bounce segments if (to.type === 'bounce1' || to.type === 'bounce2' || to.type === 'bounce3') { const arcHeight = to.arcHeight || 6; const arc = Math.sin(t * Math.PI) * arcHeight; y -= arc; // Slight horizontal wobble (less on smaller bounces) const wobble = to.type === 'bounce1' ? 1.5 : (to.type === 'bounce2' ? 0.8 : 0.4); x += Math.sin(t * Math.PI * 1.5) * wobble; } Body.setPosition(ball, { x, y }); // Segment complete if (t >= 1) { // Flash peg and play sound on peg hit if (to.type === 'peg' && to.pegX !== undefined) { const bodies = engineRef.current.world.bodies; for (const b of bodies) { if (b.label === 'peg' && Math.abs(b.position.x - to.pegX) < 10 && Math.abs(b.position.y - to.pegY) < 10) { // Always reset to the base peg color (not current which might be green) b.render.fillStyle = config.COLORS.pegHit; // Clear any existing timeout on this peg if (b._flashTimeout) clearTimeout(b._flashTimeout); b._flashTimeout = setTimeout(() => { b.render.fillStyle = config.COLORS.peg; b._flashTimeout = null; }, 100); break; } } if (soundEnabled) playPegSound(segmentIdx / 4); } segmentIdx++; segmentStart = now; } requestAnimationFrame(animate); }; requestAnimationFrame(animate); }; // ============================================ // SOUND EFFECTS - Premium casino-style addictive sounds // ============================================ const audioCtxRef = useRef(null); const lastPegTimeRef = useRef(0); const getAudioContext = () => { if (!audioCtxRef.current || audioCtxRef.current.state === 'closed') { audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtxRef.current.state === 'suspended') { audioCtxRef.current.resume(); } return audioCtxRef.current; }; // Musical scale for ascending peg sounds (pentatonic for pleasing sound) const pegNotes = [523, 587, 659, 784, 880, 988, 1047, 1175, 1319, 1397, 1568, 1760, 1976, 2093, 2349, 2637]; const playPegSound = (pegIndex = 0) => { try { const ctx = getAudioContext(); const now = ctx.currentTime; // Throttle sounds slightly to prevent audio overload if (now - lastPegTimeRef.current < 0.015) return; lastPegTimeRef.current = now; // Musical ascending notes as ball goes down const noteIndex = Math.min(Math.floor(pegIndex), pegNotes.length - 1); const freq = pegNotes[noteIndex] + (Math.random() - 0.5) * 20; // Create a more complex, satisfying ping // Main tone const osc1 = ctx.createOscillator(); const gain1 = ctx.createGain(); osc1.connect(gain1); gain1.connect(ctx.destination); osc1.frequency.value = freq; osc1.type = 'sine'; gain1.gain.setValueAtTime(0.08, now); gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.08); osc1.start(now); osc1.stop(now + 0.1); // Octave harmonic (bright shimmer) const osc2 = ctx.createOscillator(); const gain2 = ctx.createGain(); osc2.connect(gain2); gain2.connect(ctx.destination); osc2.frequency.value = freq * 2; osc2.type = 'sine'; gain2.gain.setValueAtTime(0.03, now); gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.05); osc2.start(now); osc2.stop(now + 0.06); // Soft click for impact feel const clickOsc = ctx.createOscillator(); const clickGain = ctx.createGain(); clickOsc.connect(clickGain); clickGain.connect(ctx.destination); clickOsc.frequency.value = freq * 4; clickOsc.type = 'triangle'; clickGain.gain.setValueAtTime(0.02, now); clickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.02); clickOsc.start(now); clickOsc.stop(now + 0.025); } catch (e) {} }; const playDropSound = () => { try { const ctx = getAudioContext(); const now = ctx.currentTime; // Satisfying "whoosh" drop sound const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.setValueAtTime(600, now); osc.frequency.exponentialRampToValueAtTime(200, now + 0.1); osc.type = 'sine'; gain.gain.setValueAtTime(0.06, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12); osc.start(now); osc.stop(now + 0.15); } catch (e) {} }; const playWinSound = () => { try { const ctx = getAudioContext(); const now = ctx.currentTime; // Triumphant ascending arpeggio (slot machine jackpot style) const notes = [523, 659, 784, 1047, 1319, 1568]; // C major scale up notes.forEach((freq, i) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = freq; osc.type = 'sine'; gain.gain.setValueAtTime(0.12, now + i * 0.08); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.3); osc.start(now + i * 0.08); osc.stop(now + i * 0.08 + 0.35); // Add shimmer const osc2 = ctx.createOscillator(); const gain2 = ctx.createGain(); osc2.connect(gain2); gain2.connect(ctx.destination); osc2.frequency.value = freq * 2.01; // Slight detune for shimmer osc2.type = 'sine'; gain2.gain.setValueAtTime(0.04, now + i * 0.08); gain2.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.25); osc2.start(now + i * 0.08); osc2.stop(now + i * 0.08 + 0.3); }); // Coin drop sounds for (let i = 0; i < 5; i++) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = 2000 + Math.random() * 2000; osc.type = 'sine'; const delay = 0.3 + i * 0.06 + Math.random() * 0.03; gain.gain.setValueAtTime(0.05, now + delay); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.05); osc.start(now + delay); osc.stop(now + delay + 0.06); } } catch (e) {} }; const playLoseSound = () => { try { const ctx = getAudioContext(); const now = ctx.currentTime; // Soft descending tone (not harsh, keeps player engaged) const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.setValueAtTime(400, now); osc.frequency.exponentialRampToValueAtTime(200, now + 0.15); osc.type = 'sine'; gain.gain.setValueAtTime(0.04, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2); osc.start(now); osc.stop(now + 0.25); // Soft thud const osc2 = ctx.createOscillator(); const gain2 = ctx.createGain(); osc2.connect(gain2); gain2.connect(ctx.destination); osc2.frequency.value = 80; osc2.type = 'sine'; gain2.gain.setValueAtTime(0.06, now); gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.1); osc2.start(now); osc2.stop(now + 0.12); } catch (e) {} }; const playBigWinSound = () => { try { const ctx = getAudioContext(); const now = ctx.currentTime; // Dramatic fanfare for big multipliers const fanfare = [523, 659, 784, 1047, 784, 1047, 1319]; fanfare.forEach((freq, i) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = freq; osc.type = 'sawtooth'; gain.gain.setValueAtTime(0.08, now + i * 0.1); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.1 + 0.4); osc.start(now + i * 0.1); osc.stop(now + i * 0.1 + 0.45); }); // Sparkle overlay for (let i = 0; i < 12; i++) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = 1500 + Math.random() * 3000; osc.type = 'sine'; const delay = Math.random() * 0.8; gain.gain.setValueAtTime(0.03, now + delay); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.08); osc.start(now + delay); osc.stop(now + delay + 0.1); } } catch (e) {} }; // ============================================ // BET INPUT HANDLER // ============================================ const handleBetChange = (value) => { setBetDisplay(value); const cents = parseCoinsInput(value); setBetAmount(cents); }; const handleBetBlur = () => { setBetDisplay(formatCoins(betAmount)); }; // ============================================ // GET BUCKET TIER FOR STYLING // ============================================ const getBucketTier = (index) => { const mult = multipliers[index] || 1; if (mult >= 100) return 'tier-1'; if (mult >= 50) return 'tier-1'; if (mult >= 20) return 'tier-2'; if (mult >= 10) return 'tier-2'; if (mult >= 5) return 'tier-3'; if (mult >= 2) return 'tier-4'; if (mult >= 1) return 'tier-5'; if (mult >= 0.5) return 'tier-6'; return 'tier-7'; }; // ============================================ // RENDER // ============================================ const config = configRef.current; // Get global components const NavbarComponent = window.CoinRushNavbar; const ChatComponent = window.CoinRushChatApp; const AuthModalComponent = window.AuthModal; // Handle auth success const handleAuthSuccess = (userData) => { setUser(userData); setBalance(userData.balance || 0); setShowAuthModal(false); }; return (