/**
* 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)}
>
) : (
)}
{/* Footer */}
{formatCoins(battle.amount)}
{battle.min_roll > 1 && (
Min: {battle.min_roll}
)}
{isResolved ? (
<>WINNER: {battle.winner}>
) : isRolling ? (
<>🎲 ROLLING...>
) : (
<>{Icons.clock} {formatTime(timeLeft)}>
)}
onView(battle)}>
{isResolved ? Icons.refresh : Icons.eye}
{canJoin && (
onJoin(battle)}
disabled={isJoining}
>
{isJoining ? '...' : 'Join'}
)}
);
}
// ============================================
// 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 */}
{/* Amount Presets */}
{['5', '10', '25', '50', '100', '250', '500'].map(p => (
setAmount(p + ',00')}
className="preset-btn"
>
{p}
))}
{user?.balance > 0 && (
setAmount(formatCoins(user.balance))}
className="preset-btn preset-btn--max"
>
MAX
)}
{/* Min Roll */}
{/* Error */}
{error &&
{error}
}
{/* CTA */}
{user ? (
{Icons.dice}
{loading ? 'Creating...' : 'Create Battle'}
) : (
window.openAuthModal?.('login')}
className="cta-btn cta-btn--login cta-btn--full"
>
{Icons.users}
Login to Play
)}
);
}
// ============================================
// 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
{Icons.close}
{rules.map((rule, i) => (
{rule.icon}
))}
Got It!
);
}
// ============================================
// 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.close}
{Icons.gift}
Claim Your Daily Bonus!
Come back every day for free coins
+50.00
{claiming ? 'Claiming...' : 'Claim Reward'}
);
}
// ============================================
// 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 && (
)}
{/* Modal Card */}
e.stopPropagation()}>
{/* Close button */}
{Icons.close}
{/* 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
{localPhase === 'resolved' ? (userWon ? 'Collect' : 'Close') : 'Close'}
);
}
// ============================================
// 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 => (
setFilter(f)}
className={clsx('sd-filter-tab', filter === f && 'sd-filter-tab--active')}
>
{f === 'all' ? 'All' : f === 'high' ? (
<>{Icons.crown} High>
) : 'Open'}
))}
setShowRules(true)}
>
{Icons.info}
Rules
{/* 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 */}
{/* 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( );
}