/** * CoinRush Slice 'n Dice V2 - React App * Premium dice battle arena matching Coinflip design */ const { useState, useEffect, useRef, useCallback, useMemo } = React; // ============================================ // CONFIGURATION // ============================================ const CONFIG = { minBet: 100, // 1.00 coins in hundredths maxBet: 10000000, rollDuration: 3000, wsReconnectDelay: 3000, battleTimeout: 300 // 5 minutes in seconds }; // ============================================ // SVG ICONS // ============================================ const Icons = { dice: ( ), swords: ( ), trophy: ( ), clock: ( ), bolt: ( ), crown: ( ), close: ( ), eye: ( ), refresh: ( ), info: ( ), gift: ( ), users: ( ), shield: ( ), check: ( ), target: ( ), sparkle: ( ), hourglass: ( ), crossX: ( ), checkCircle: ( ) }; // ============================================ // UTILITIES // ============================================ // For Slice 'n Dice, amounts are stored in COINS (not hundredths) // So we format directly without dividing by 100 const formatCoins = (coins) => { const amount = coins || 0; return amount.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; // For user balance (which IS in hundredths) const formatBalance = (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); }; const timeAgo = (dateStr) => { const date = new Date(dateStr); const now = new Date(); const seconds = Math.floor((now - date) / 1000); if (seconds < 60) return 'Just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; }; const formatTime = (seconds) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; const clsx = (...classes) => classes.filter(Boolean).join(' '); // API helper const api = async (endpoint, options = {}) => { const res = await fetch(endpoint, { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', ...options.headers }, credentials: 'include', body: options.body ? JSON.stringify(options.body) : undefined }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || data.error || 'Request failed'); return data; }; // ============================================ // AVATAR COMPONENT - With Level Borders (matching Coinflip) // ============================================ function Avatar({ username, size = 'md', showBorder = true, level = 1, showLevel = false }) { const sizeClasses = { xs: 'sd-avatar--xs', sm: 'sd-avatar--sm', md: 'sd-avatar--md', lg: 'sd-avatar--lg', xl: 'sd-avatar--xl' }; const frameSizes = { xs: 44, sm: 72, md: 86, lg: 100, xl: 120 }; const getBackground = () => { if (typeof window.colorFrom === 'function') { return window.colorFrom(username || 'X'); } const hue = [...(username || 'X')].reduce((a, c) => a + c.charCodeAt(0), 0) % 360; return `linear-gradient(135deg, hsl(${hue} 70% 50%), hsl(${hue} 70% 35%))`; }; const getBorderFrame = () => { if (level >= 40) return '/static/img/borders/Challengerborder.png'; if (level >= 30) return '/static/img/borders/Diamondborder.png'; if (level >= 20) return '/static/img/borders/Rubyborder.png'; if (level >= 15) return '/static/img/borders/Goldborder.png'; if (level >= 10) return '/static/img/borders/Silverborder.png'; if (level >= 5) return '/static/img/borders/Bronzeborder.png'; return null; }; const getBorderClass = () => { if (typeof window.getAvatarBorderClass === 'function') { return window.getAvatarBorderClass(level); } return ''; }; const borderClass = showBorder ? getBorderClass() : ''; const frameUrl = showBorder ? getBorderFrame() : null; const frameSize = frameSizes[size] || 86; return (
{(username || 'X').slice(0, 2).toUpperCase()} {showLevel && level > 0 && ( {level} )} {frameUrl && ( )}
); } // ============================================ // ANIMATED DICE ROLL COMPONENT // ============================================ function AnimatedRoll({ value, isRolling, maxValue = 1000 }) { const [display, setDisplay] = useState(value || '---'); useEffect(() => { if (isRolling) { const interval = setInterval(() => { setDisplay(Math.floor(Math.random() * maxValue)); }, 50); return () => clearInterval(interval); } else if (value !== null && value !== undefined) { setDisplay(value); } }, [isRolling, value, maxValue]); return {display}; } // ============================================ // BATTLE CARD COMPONENT // ============================================ function BattleCard({ battle, user, onJoin, onView, isJoining }) { const [timeLeft, setTimeLeft] = useState(CONFIG.battleTimeout); const isOwn = user && battle.creator_username === user.username; const isWaiting = battle.status === 'waiting' || battle.status === 'open'; const canJoin = user && isWaiting && !isOwn; const isResolved = battle.status === 'resolved' || battle.status === 'completed'; const isRolling = battle.status === 'rolling'; useEffect(() => { if (isResolved || isRolling) return; let createdAtStr = battle.created_at; if (createdAtStr && !createdAtStr.endsWith('Z') && !createdAtStr.includes('+')) { createdAtStr += 'Z'; } const createdAt = new Date(createdAtStr).getTime(); if (!Number.isFinite(createdAt)) return; const updateTimer = () => { const elapsed = (Date.now() - createdAt) / 1000; const remaining = Math.max(0, CONFIG.battleTimeout - elapsed); setTimeLeft(remaining); }; updateTimer(); const interval = setInterval(updateTimer, 1000); return () => clearInterval(interval); }, [battle.created_at, isResolved, isRolling]); const isUrgent = timeLeft < 60; const isWarning = timeLeft < 120 && timeLeft >= 60; const winnerIsCreator = isResolved && battle.winner === battle.creator_username; return (
{/* Main row */}
{/* Creator */}
🎲
{battle.creator_username} {formatCoins(battle.amount)}
{isResolved && battle.creator_roll !== undefined && ( {battle.creator_roll} )} {isResolved && winnerIsCreator && ( {Icons.crown} )}
{/* VS dice */}
🎲
{/* Joiner or waiting */}
{battle.joiner_username ? ( <> {isResolved && !winnerIsCreator && ( {Icons.crown} )} {isResolved && battle.joiner_roll !== undefined && ( {battle.joiner_roll} )}
{battle.joiner_username} {formatCoins(battle.join_amount || battle.amount)}
🎲
) : (
Waiting...
?
)}
{/* Footer */}
{formatCoins(battle.amount)}
{battle.min_roll > 1 && (
Min: {battle.min_roll}
)}
{isResolved ? ( <>WINNER: {battle.winner} ) : isRolling ? ( <>🎲 ROLLING... ) : ( <>{Icons.clock} {formatTime(timeLeft)} )}
{canJoin && ( )}
); } // ============================================ // CREATE BATTLE PANEL // ============================================ function CreateBattlePanel({ user, onRefresh }) { const [amount, setAmount] = useState('10,00'); const [minRoll, setMinRoll] = useState('1'); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const amountCents = parseCoinsInput(amount); const handleCreate = async () => { if (!user) { window.openAuthModal?.('login'); return; } if (amountCents < CONFIG.minBet) { setError('Minimum bet is 1.00 coins'); return; } if (amountCents > (user.balance || 0)) { setError('Insufficient balance'); return; } setLoading(true); setError(''); try { // API expects amount in coins (not hundredths) const amountCoins = amountCents / 100; await api('/api/slice-dice/create', { method: 'POST', body: { amount: amountCoins, min_roll: parseInt(minRoll) || 1 } }); onRefresh?.(); setAmount('10,00'); } catch (err) { setError(err.message || 'Failed to create battle'); } finally { setLoading(false); } }; return (
{Icons.bolt} Create Battle
{/* Amount Input */}
setAmount(e.target.value)} placeholder="10,00" />
{/* Amount Presets */}
{['5', '10', '25', '50', '100', '250', '500'].map(p => ( ))} {user?.balance > 0 && ( )}
{/* Min Roll */}
Min Roll (optional)
setMinRoll(e.target.value)} placeholder="1" /> Default: 1
{/* Error */} {error &&
{error}
} {/* CTA */}
{user ? ( ) : ( )}
); } // ============================================ // HISTORY TICKER // ============================================ function HistoryTicker({ history }) { if (!history?.length) return null; // Duplicate for seamless scroll const items = [...history.slice(0, 12), ...history.slice(0, 12)]; return (
{items.map((h, i) => (
#{h.id} {h.winner} won {formatCoins(h.amount)} with {h.winner_roll}
))}
); } // ============================================ // RULES MODAL // ============================================ function RulesModal({ isOpen, onClose }) { if (!isOpen) return null; const rules = [ { icon: Icons.dice, title: 'Roll the Dice', desc: 'Both players roll a random number between 1 and 1000. The higher roll wins!' }, { icon: Icons.trophy, title: 'Winner Takes All', desc: 'The winner receives the combined pot minus a small 2% house fee.' }, { icon: Icons.target, title: 'Min Roll Option', desc: 'Set a minimum roll requirement to filter out low-stakes players.' }, { icon: Icons.shield, title: 'Provably Fair', desc: 'All rolls are cryptographically verified. Check any result with the seed!' } ]; return (
e.stopPropagation()}>
{Icons.info} How to Play
    {rules.map((rule, i) => (
  • {rule.icon}

    {rule.title}

    {rule.desc}

  • ))}
); } // ============================================ // DAILY REWARD MODAL // ============================================ function DailyRewardModal({ isOpen, onClose, user, onClaim }) { const [claiming, setClaiming] = useState(false); if (!isOpen) return null; const handleClaim = async () => { setClaiming(true); try { await api('/api/daily-reward/claim', { method: 'POST' }); onClaim?.(); onClose(); } catch (err) { console.error('Failed to claim:', err); } finally { setClaiming(false); } }; return (
e.stopPropagation()}>
{Icons.gift} Daily Reward
{Icons.gift}

Claim Your Daily Bonus!

Come back every day for free coins

+50.00
); } // ============================================ // SLOT DIGIT - Smooth Scrolling Number Wheel // Each slot spins at constant speed, then individually decelerates to land on target // ============================================ function SlotDigit({ targetValue, isSpinning = false, stopDelay = 0, onStopped }) { const [offset, setOffset] = useState(0); const [phase, setPhase] = useState('idle'); // 'idle' | 'spinning' | 'stopping' | 'stopped' const animationRef = useRef(null); const stopTimerRef = useRef(null); const hasStoppedRef = useRef(false); const targetReceivedRef = useRef(false); const currentOffsetRef = useRef(0); const phaseRef = useRef('idle'); // Height of each number in pixels const DIGIT_HEIGHT = 72; const FULL_ROTATION = DIGIT_HEIGHT * 10; // SLOW spin speed (pixels per millisecond) - nice and readable const SPIN_SPEED = 0.12; // All numbers for the strip (repeated for smooth wrap-around) const numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; // Cleanup function const cleanup = useCallback(() => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } if (stopTimerRef.current) { clearTimeout(stopTimerRef.current); stopTimerRef.current = null; } }, []); // Cleanup on unmount useEffect(() => { return cleanup; }, [cleanup]); // Main animation logic useEffect(() => { // Start spinning if (isSpinning && phaseRef.current === 'idle') { phaseRef.current = 'spinning'; setPhase('spinning'); currentOffsetRef.current = Math.random() * FULL_ROTATION; let lastTime = performance.now(); const spin = (timestamp) => { if (phaseRef.current !== 'spinning') return; const delta = timestamp - lastTime; lastTime = timestamp; currentOffsetRef.current = (currentOffsetRef.current + delta * SPIN_SPEED) % FULL_ROTATION; setOffset(currentOffsetRef.current); animationRef.current = requestAnimationFrame(spin); }; animationRef.current = requestAnimationFrame(spin); } }, [isSpinning]); // Handle stopping when target value arrives useEffect(() => { if (targetValue === null || targetValue === undefined) return; if (targetReceivedRef.current) return; if (phaseRef.current !== 'spinning') return; targetReceivedRef.current = true; // Schedule the stop stopTimerRef.current = setTimeout(() => { // Cancel spinning animation if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } phaseRef.current = 'stopping'; setPhase('stopping'); // Where we want to land const targetOffset = targetValue * DIGIT_HEIGHT; const startOffset = currentOffsetRef.current; // Calculate distance to target - ensure we travel forward to it let distanceToTarget = ((targetOffset - startOffset) % FULL_ROTATION + FULL_ROTATION) % FULL_ROTATION; if (distanceToTarget < DIGIT_HEIGHT * 3) { distanceToTarget += FULL_ROTATION; // Go around once more if too close } // We want to decelerate smoothly from SPIN_SPEED to 0 // Using physics: distance = v0 * t - 0.5 * a * t^2, where final velocity = 0 // For smooth stop: distance = v0 * t / 2 (average velocity over deceleration) // So: t = 2 * distance / v0 const DECEL_DURATION = (2 * distanceToTarget) / SPIN_SPEED; const decelStartTime = performance.now(); let lastTime = decelStartTime; let currentSpeed = SPIN_SPEED; const decelRate = SPIN_SPEED / DECEL_DURATION; // How much to reduce speed per ms const decelerate = (timestamp) => { const delta = timestamp - lastTime; lastTime = timestamp; // Reduce speed linearly currentSpeed = Math.max(0, currentSpeed - decelRate * delta); // Move based on current speed currentOffsetRef.current = (currentOffsetRef.current + delta * currentSpeed) % FULL_ROTATION; setOffset(currentOffsetRef.current); if (currentSpeed > 0.001) { animationRef.current = requestAnimationFrame(decelerate); } else { // Done - snap to exact target setOffset(targetOffset); phaseRef.current = 'stopped'; setPhase('stopped'); if (onStopped && !hasStoppedRef.current) { hasStoppedRef.current = true; onStopped(); } } }; animationRef.current = requestAnimationFrame(decelerate); }, stopDelay); }, [targetValue, stopDelay, onStopped]); // Cleanup on unmount useEffect(() => { return cleanup; }, [cleanup]); // Show placeholder when idle if (phase === 'idle') { return (
-
); } return (
{numbers.map((num, i) => ( {num} ))}
); } // ============================================ // DUEL POPUP - Premium Dice Battle Modal // ============================================ function DuelPopup({ duel, phase, user, onClose, onRefresh }) { const [localPhase, setLocalPhase] = useState(phase || 'waiting'); const [showConfetti, setShowConfetti] = useState(false); const [confetti, setConfetti] = useState([]); const [countdown, setCountdown] = useState(null); const [isSpinning, setIsSpinning] = useState(false); // Controls when dice start spinning const [stoppedCount, setStoppedCount] = useState(0); const countdownRef = useRef(null); const hasStartedRef = useRef(false); if (!duel) return null; const maxRoll = duel.amount || 100; const digitCount = String(maxRoll).length; const minRoll = duel.min_roll || 1; const creatorRoll = duel.creator_roll; const joinerRoll = duel.joiner_roll; const hasRolls = creatorRoll !== undefined && joinerRoll !== undefined && creatorRoll !== null && joinerRoll !== null; const winnerIsCreator = hasRolls && creatorRoll > joinerRoll; const winnerUsername = hasRolls ? (winnerIsCreator ? duel.creator_username : duel.joiner_username) : null; const difference = hasRolls ? Math.abs(creatorRoll - joinerRoll) : 0; const userIsCreator = user?.username === duel.creator_username; const userIsJoiner = user?.username === duel.joiner_username; const userInGame = userIsCreator || userIsJoiner; const userWon = userInGame && hasRolls && ( (userIsCreator && winnerIsCreator) || (userIsJoiner && !winnerIsCreator) ); // Pad number to fixed digits const padNumber = (num, len) => { return String(num).padStart(len, '0').split('').map(Number); }; // Target digits - passed to SlotDigit when rolls are available const creatorDigits = hasRolls ? padNumber(creatorRoll, digitCount) : null; const joinerDigits = hasRolls ? padNumber(joinerRoll, digitCount) : null; // Generate confetti const generateConfetti = () => { const colors = ['#5bffb2', '#3dd492', '#fbbf24', '#22c55e', '#4ade80', '#ffffff']; return Array.from({ length: 60 }, (_, i) => ({ id: i, x: Math.random() * 100, color: colors[Math.floor(Math.random() * colors.length)], size: 6 + Math.random() * 8, delay: Math.random() * 0.5, duration: 2 + Math.random() * 2 })); }; // Start countdown when phase becomes 'ready' useEffect(() => { if (phase === 'ready' && !hasStartedRef.current) { hasStartedRef.current = true; setLocalPhase('ready'); setCountdown(3); // Faster countdown if (countdownRef.current) clearInterval(countdownRef.current); let count = 3; countdownRef.current = setInterval(() => { count -= 1; if (count <= 0) { clearInterval(countdownRef.current); countdownRef.current = null; setCountdown(null); // Start spinning immediately when countdown ends! setLocalPhase('rolling'); setIsSpinning(true); } else { setCountdown(count); } }, 800); // Faster tick (0.8s instead of 1s) } return () => { if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; } }; }, [phase]); // FALLBACK: If rolls arrive without countdown (late joiner, spectator, etc) useEffect(() => { if (hasRolls && !hasStartedRef.current && !isSpinning) { // No countdown was started, just start spinning directly setLocalPhase('rolling'); setIsSpinning(true); } }, [hasRolls, isSpinning]); // Track when all digits have stopped rolling const handleDigitStopped = useCallback(() => { setStoppedCount(prev => { const newCount = prev + 1; // Each player has digitCount digits, so total is digitCount * 2 if (newCount >= digitCount * 2) { // All digits stopped - show result after a brief pause setTimeout(() => { setLocalPhase('resolved'); setConfetti(generateConfetti()); setShowConfetti(true); setTimeout(() => setShowConfetti(false), 4000); }, 500); } return newCount; }); }, [digitCount]); // Handle direct resolved phase (for spectators joining late) useEffect(() => { if (phase === 'resolved' && localPhase !== 'resolved' && !isSpinning) { setLocalPhase('resolved'); setIsSpinning(true); } }, [phase, localPhase, isSpinning]); return (
{/* Backdrop */}
{/* Confetti */} {showConfetti && (
{confetti.map(c => (
))}
)} {/* Modal Card */}
e.stopPropagation()}> {/* Close button */} {/* Header - centered amount */}
{formatCoins(duel.amount)}
Battle #{duel.id}
{/* Status */}
{localPhase === 'waiting' && ( <> {Icons.hourglass} Waiting for opponent... )} {localPhase === 'ready' && ( <> {countdown !== null ? ( <> {countdown} Get ready! ) : ( <> {Icons.swords} Battle Starting! )} )} {localPhase === 'rolling' && ( <> {Icons.dice} Rolling the dice... )} {localPhase === 'revealing' && ( <> {Icons.sparkle} Revealing... )} {localPhase === 'resolved' && ( <> {Icons.crown} {winnerUsername} wins! )}
{/* Players */}
{/* Creator */}
{localPhase === 'resolved' && winnerIsCreator && (
{Icons.crown}
)}
{duel.creator_username} {userIsCreator && YOU}
{formatCoins(duel.amount)}
{/* Roll Display */}
{Array.from({ length: digitCount }).map((_, i) => ( ))}
{/* VS Center */}
{Icons.dice}
VS
{minRoll} - {maxRoll}
{/* Joiner */}
{localPhase === 'resolved' && !winnerIsCreator && (
{Icons.crown}
)} {duel.joiner_username ? ( <>
{duel.joiner_username} {userIsJoiner && YOU}
{formatCoins(duel.join_amount || duel.amount)}
{/* Roll Display */}
{Array.from({ length: digitCount }).map((_, i) => ( ))}
) : ( <>
?
Waiting...
)}
{/* Result Info */} {localPhase === 'resolved' && (
Difference: {difference}
Winner takes: +{formatCoins(difference)}
)} {/* User Result Banner */} {userInGame && localPhase === 'resolved' && (
{userWon ? ( <> {Icons.checkCircle} YOU WON! +{formatCoins(difference)} ) : ( <> {Icons.crossX} YOU LOST -{formatCoins(difference)} )}
)} {/* Footer */}
Provably Fair
); } // ============================================ // MAIN APP // ============================================ function SliceDiceApp() { // State const [user, setUser] = useState(null); const [battles, setBattles] = useState([]); const [history, setHistory] = useState([]); const [wsConnected, setWsConnected] = useState(false); const [filter, setFilter] = useState('all'); const [joiningId, setJoiningId] = useState(null); // Modals const [showRules, setShowRules] = useState(false); const [showDailyModal, setShowDailyModal] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); const [authModalMode, setAuthModalMode] = useState('login'); // Duel state const [activeDuel, setActiveDuel] = useState(null); const [duelPhase, setDuelPhase] = useState(null); const wsRef = useRef(null); const activeDuelRef = useRef(null); const userRef = useRef(null); // Keep refs in sync with state useEffect(() => { activeDuelRef.current = activeDuel; }, [activeDuel]); useEffect(() => { userRef.current = user; }, [user]); // Load user const loadUser = useCallback(async () => { try { const data = await api('/me'); setUser(data); } catch { setUser(null); } }, []); // Load battles const loadBattles = useCallback(async () => { try { const data = await api('/api/slice-dice/active'); setBattles(data.games || []); } catch (err) { console.error('Failed to load battles:', err); } }, []); // Load history const loadHistory = useCallback(async () => { try { const data = await api('/api/slice-dice/history'); setHistory(data.history || []); } catch (err) { console.error('Failed to load history:', err); } }, []); // Initial load useEffect(() => { loadUser(); loadBattles(); loadHistory(); }, [loadUser, loadBattles, loadHistory]); // WebSocket connection useEffect(() => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const socket = new WebSocket(`${protocol}//${window.location.host}/ws/slice-dice`); wsRef.current = socket; socket.onopen = () => { console.log('[SliceDice WS] Connected'); setWsConnected(true); }; socket.onclose = () => { console.log('[SliceDice WS] Disconnected'); setWsConnected(false); // Reconnect setTimeout(() => { if (wsRef.current === socket) { loadBattles(); } }, CONFIG.wsReconnectDelay); }; socket.onmessage = (event) => { try { const data = JSON.parse(event.data); console.log('[SliceDice WS]', data); // Use refs to access current values without recreating socket const currentDuel = activeDuelRef.current; const currentUser = userRef.current; if (data.type === 'game_created') { // WS sends flat data, not nested game object const game = { id: data.game_id, creator_username: data.creator_username, creator_level: data.creator_level || 1, amount: data.amount, min_roll: data.min_roll, status: 'waiting', created_at: data.created_at, server_seed_hash: data.server_seed_hash }; setBattles(prev => [game, ...prev.filter(g => g.id !== game.id)]); // If current user created this game, open the popup for them if (currentUser && data.creator_username === currentUser.username) { setActiveDuel(game); setDuelPhase('waiting'); } } if (data.type === 'game_joined') { // Reload battles to get fresh data (removes the joined game) loadBattles(); // If we're watching this game OR we're the creator, update if (currentDuel && currentDuel.id === data.game_id) { setActiveDuel(prev => ({ ...prev, joiner_username: data.joiner_username, joiner_id: data.joiner_id })); setDuelPhase('ready'); } } if (data.type === 'game_countdown') { // Countdown started - start the 5 second countdown, but stay in 'ready' phase // The countdown visual runs in the DuelPopup component if (currentDuel && currentDuel.id === data.game_id) { // Phase stays 'ready' - the countdown is shown during 'ready' phase // After 5 seconds, set to 'rolling' for the dice animation setTimeout(() => { setDuelPhase('rolling'); }, 5000); } } if (data.type === 'game_rolling') { // The roll results are in - update activeDuel and trigger reveal if (currentDuel && currentDuel.id === data.game_id) { setActiveDuel(prev => ({ ...prev, creator_roll: data.creator_roll, joiner_roll: data.joiner_roll, winner_username: data.winner_username, difference: data.difference, net_winnings: data.net_winnings, house_fee: data.house_fee, server_seed: data.server_seed, client_seed: data.client_seed, nonce: data.nonce, ticket: data.ticket })); setDuelPhase('revealing'); } // Update user balance if we're the creator or joiner if (currentUser && (currentUser.id === data.creator_id || currentUser.id === data.joiner_id)) { const newBalance = currentUser.id === data.creator_id ? data.creator_balance : data.joiner_balance; setUser(prev => prev ? { ...prev, balance: newBalance } : prev); } } if (data.type === 'game_complete') { // Game is fully complete if (currentDuel && currentDuel.id === data.game_id) { setDuelPhase('resolved'); } // Reload battles and history loadBattles(); loadHistory(); } if (data.type === 'game_resolved') { // Reload battles and history loadBattles(); loadHistory(); // Refresh user balance loadUser(); } if (data.type === 'game_expired') { setBattles(prev => prev.filter(g => g.id !== data.game_id)); } } catch (err) { console.error('[SliceDice WS] Parse error:', err); } }; return () => socket.close(); }, [loadBattles, loadUser, loadHistory]); // Handle join const handleJoin = async (battle) => { if (joiningId) return; setJoiningId(battle.id); // Open the popup IMMEDIATELY before API call (API blocks for ~12 seconds) const gameData = { ...battle, joiner_username: user?.username || 'You', joiner_level: user?.level || 1, creator_level: battle.creator_level || 1, }; setActiveDuel(gameData); setDuelPhase('ready'); try { const result = await api('/api/slice-dice/join', { method: 'POST', body: { game_id: battle.id } }); // Update user balance after join completes if (result.balance !== undefined) { setUser(prev => prev ? { ...prev, balance: result.balance } : prev); } } catch (err) { console.error('Failed to join:', err); alert(err.message || 'Failed to join battle'); // Close popup on error setActiveDuel(null); setDuelPhase(null); } finally { setJoiningId(null); } }; // Handle logout const handleLogout = async () => { try { await api('/auth/logout', { method: 'POST' }); setUser(null); } catch {} }; // Auth success const handleAuthSuccess = (userData) => { setUser(userData); setShowAuthModal(false); }; // Expose functions useEffect(() => { window.openAuthModal = (mode) => { setAuthModalMode(mode); setShowAuthModal(true); }; window.showSliceDiceRules = () => setShowRules(true); window.dailyRewardsModal = { open: () => setShowDailyModal(true) }; }, []); // Filter battles const filteredBattles = battles.filter(b => { if (filter === 'all') return true; if (filter === 'high') return b.amount >= 10000; if (filter === 'open') return b.status === 'waiting'; return true; }); // Components const NavbarComponent = window.CoinRushNavbar; const ChatComponent = window.CoinRushChatApp; return (
{/* Background effects */}
{[...Array(25)].map((_, i) => (
))}
{/* Navbar */} {NavbarComponent && ( )} {/* Auth Modal */} {window.AuthModal && ( setShowAuthModal(false)} onSuccess={handleAuthSuccess} /> )} {/* Connection warning */} {!wsConnected && (
⚠️ Connection lost. Reconnecting...
)} {/* History ticker */} {/* Main layout */}
{/* Left sidebar: Create panel */}
{/* Center: Battles */}
{/* Header */}

Live Battles

{battles.length} Active
{['all', 'open', 'high'].map(f => ( ))}
{/* Battle list */}
{filteredBattles.length > 0 ? ( filteredBattles.map(battle => ( { setActiveDuel(b); setDuelPhase(b.status === 'resolved' || b.status === 'completed' ? 'resolved' : b.status === 'rolling' ? 'rolling' : 'waiting'); }} isJoining={joiningId === battle.id} /> )) ) : (
{Icons.dice}

No Active Battles

Create the first battle to get started!

)}
{/* Right sidebar: Chat */}
{ChatComponent && ( )}
{/* Duel Popup */} {activeDuel && ( { setActiveDuel(null); setDuelPhase(null); }} onRefresh={loadBattles} /> )} {/* Modals */} setShowRules(false)} /> setShowDailyModal(false)} user={user} onClaim={loadUser} />
); } // ============================================ // RENDER // ============================================ const rootEl = document.getElementById('slicedice-root'); if (rootEl) { const root = ReactDOM.createRoot(rootEl); root.render(); }