import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { initializeApp } from 'firebase/app';
import {
getAuth,
signInAnonymously,
signInWithCustomToken,
onAuthStateChanged,
} from 'firebase/auth';
import {
getFirestore,
doc,
setDoc,
getDoc,
updateDoc,
deleteDoc,
collection,
onSnapshot,
} from 'firebase/firestore';
import {
Trophy,
Users,
Play,
Target,
Settings,
Volume2,
VolumeX,
RotateCcw,
Shield,
Award,
Zap,
Copy,
Check,
ChevronLeft,
ChevronRight,
RefreshCw,
User,
Sparkles,
Crosshair,
Clock,
AlertTriangle,
Info,
Cpu,
Globe,
Lock,
Unlock,
Sliders,
HelpCircle,
LogOut,
Plus,
Search,
Undo2,
Eye,
Activity,
Server,
Crown,
} from 'lucide-react';
// ============================================================================
// 1. FIREBASE INITIALIZATION (STRICT COMPLIANCE WITH RULES 1, 2, 3)
// ============================================================================
const firebaseConfig =
typeof __firebase_config !== 'undefined' && __firebase_config
? JSON.parse(__firebase_config)
: {
apiKey: 'demo-api-key',
authDomain: 'demo.firebaseapp.com',
projectId: 'demo-project',
};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'cue-masters-online';
// ============================================================================
// 2. PROCEDURAL WEB AUDIO SYNTHESIZER (NO EXTERNAL ASSETS REQUIRED)
// ============================================================================
class SoundEngine {
constructor() {
this.ctx = null;
this.muted = false;
this.volume = 0.75;
this.lastBallHitTime = 0;
}
init() {
if (!this.ctx && typeof window !== 'undefined') {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) {
this.ctx = new AudioCtx();
}
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume().catch(() => {});
}
}
playCueStrike(powerRatio = 0.5) {
if (this.muted) return;
this.init();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
const baseFreq = 140 + powerRatio * 90;
osc.frequency.setValueAtTime(baseFreq, now);
osc.frequency.exponentialRampToValueAtTime(45, now + 0.085);
const vol = Math.min(1, Math.max(0.1, powerRatio)) * this.volume * 0.7;
gain.gain.setValueAtTime(vol, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.09);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.095);
}
playBallCollision(impactVelocity = 5) {
if (this.muted) return;
this.init();
if (!this.ctx) return;
const nowMs = performance.now();
if (nowMs - this.lastBallHitTime < 18) return; // Rate limit rapid break hits
this.lastBallHitTime = nowMs;
const now = this.ctx.currentTime;
const normVel = Math.min(1, Math.max(0.05, impactVelocity / 22));
// Crisp phenolic resin click
const osc = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc2.type = 'triangle';
osc.frequency.setValueAtTime(1850 + normVel * 600, now);
osc.frequency.exponentialRampToValueAtTime(620, now + 0.035);
osc2.frequency.setValueAtTime(920 + normVel * 300, now);
osc2.frequency.exponentialRampToValueAtTime(310, now + 0.04);
const vol = normVel * this.volume * 0.65;
gain.gain.setValueAtTime(vol, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.042);
osc.connect(gain);
osc2.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc2.start(now);
osc.stop(now + 0.045);
osc2.stop(now + 0.045);
}
playCushionImpact(speed = 4) {
if (this.muted) return;
this.init();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const norm = Math.min(1, Math.max(0.05, speed / 18));
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(115 + norm * 40, now);
osc.frequency.exponentialRampToValueAtTime(38, now + 0.075);
gain.gain.setValueAtTime(norm * this.volume * 0.45, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.085);
}
playPocketDrop() {
if (this.muted) return;
this.init();
if (!this.ctx) return;
const now = this.ctx.currentTime;
[190, 145, 260].forEach((freq, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
const start = now + idx * 0.055;
osc.frequency.setValueAtTime(freq, start);
osc.frequency.exponentialRampToValueAtTime(70, start + 0.09);
gain.gain.setValueAtTime(this.volume * 0.35, start);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.095);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(start);
osc.stop(start + 0.1);
});
}
playFoul() {
if (this.muted) return;
this.init();
if (!this.ctx) return;
const now = this.ctx.currentTime;
[220, 165].forEach((freq, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
const start = now + idx * 0.14;
osc.frequency.setValueAtTime(freq, start);
gain.gain.setValueAtTime(this.volume * 0.25, start);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.18);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(start);
osc.stop(start + 0.19);
});
}
playWinFanfare() {
if (this.muted) return;
this.init();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const notes = [261.63, 329.63, 392.0, 523.25];
notes.forEach((freq, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
const start = now + idx * 0.11;
osc.frequency.setValueAtTime(freq, start);
gain.gain.setValueAtTime(this.volume * 0.35, start);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.35);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(start);
osc.stop(start + 0.36);
});
}
}
const soundFX = new SoundEngine();
// ============================================================================
// 3. CONSTANTS, TABLE GEOMETRY, CUES, FELT THEMES & BALL DEFINITIONS
// ============================================================================
const TABLE_WIDTH = 1000; // Logical coordinate width (2:1 standard championship ratio)
const TABLE_HEIGHT = 500;
const RAIL_SIZE = 44;
const PLAY_LEFT = RAIL_SIZE;
const PLAY_RIGHT = TABLE_WIDTH - RAIL_SIZE;
const PLAY_TOP = RAIL_SIZE;
const PLAY_BOTTOM = TABLE_HEIGHT - RAIL_SIZE;
const PLAY_W = PLAY_RIGHT - PLAY_LEFT;
const PLAY_H = PLAY_BOTTOM - PLAY_TOP;
// Pockets (6 standard pockets: 4 corners + 2 middle)
const POCKETS = [
{ id: 0, x: PLAY_LEFT + 4, y: PLAY_TOP + 4, radius: 24, isCorner: true },
{ id: 1, x: TABLE_WIDTH / 2, y: PLAY_TOP - 4, radius: 22, isCorner: false },
{ id: 2, x: PLAY_RIGHT - 4, y: PLAY_TOP + 4, radius: 24, isCorner: true },
{ id: 3, x: PLAY_LEFT + 4, y: PLAY_BOTTOM - 4, radius: 24, isCorner: true },
{ id: 4, x: TABLE_WIDTH / 2, y: PLAY_BOTTOM + 4, radius: 22, isCorner: false },
{ id: 5, x: PLAY_RIGHT - 4, y: PLAY_BOTTOM - 4, radius: 24, isCorner: true },
];
// Baulk / Head String Line & Snooker D-Zone
const HEAD_STRING_X = PLAY_LEFT + PLAY_W * 0.22;
const SNOOKER_D_RADIUS = PLAY_H * 0.165;
const FOOT_SPOT_X = PLAY_LEFT + PLAY_W * 0.75;
const FOOT_SPOT_Y = TABLE_HEIGHT / 2;
// Pool Ball Visual Palette (1-15)
const POOL_BALL_COLORS = {
0: { color: '#F8FAFC', label: '', isStripe: false },
1: { color: '#EAB308', label: '1', isStripe: false }, // Yellow
2: { color: '#2563EB', label: '2', isStripe: false }, // Blue
3: { color: '#DC2626', label: '3', isStripe: false }, // Red
4: { color: '#7C3AED', label: '4', isStripe: false }, // Purple
5: { color: '#EA580C', label: '5', isStripe: false }, // Orange
6: { color: '#16A34A', label: '6', isStripe: false }, // Green
7: { color: '#881337', label: '7', isStripe: false }, // Maroon
8: { color: '#0F172A', label: '8', isStripe: false }, // Black 8
9: { color: '#EAB308', label: '9', isStripe: true },
10: { color: '#2563EB', label: '10', isStripe: true },
11: { color: '#DC2626', label: '11', isStripe: true },
12: { color: '#7C3AED', label: '12', isStripe: true },
13: { color: '#EA580C', label: '13', isStripe: true },
14: { color: '#16A34A', label: '14', isStripe: true },
15: { color: '#881337', label: '15', isStripe: true },
};
// Snooker Color Spot Definitions
const SNOOKER_COLORS_CONFIG = [
{
id: 20,
snookerType: 'color',
name: 'Yellow',
points: 2,
color: '#FACC15',
spotX: HEAD_STRING_X,
spotY: TABLE_HEIGHT / 2 + SNOOKER_D_RADIUS,
},
{
id: 21,
snookerType: 'color',
name: 'Green',
points: 3,
color: '#16A34A',
spotX: HEAD_STRING_X,
spotY: TABLE_HEIGHT / 2 - SNOOKER_D_RADIUS,
},
{
id: 22,
snookerType: 'color',
name: 'Brown',
points: 4,
color: '#92400E',
spotX: HEAD_STRING_X,
spotY: TABLE_HEIGHT / 2,
},
{
id: 23,
snookerType: 'color',
name: 'Blue',
points: 5,
color: '#2563EB',
spotX: TABLE_WIDTH / 2,
spotY: TABLE_HEIGHT / 2,
},
{
id: 24,
snookerType: 'color',
name: 'Pink',
points: 6,
color: '#F472B6',
spotX: PLAY_LEFT + PLAY_W * 0.72,
spotY: TABLE_HEIGHT / 2,
},
{
id: 25,
snookerType: 'color',
name: 'Black',
points: 7,
color: '#0F172A',
spotX: PLAY_LEFT + PLAY_W * 0.91,
spotY: TABLE_HEIGHT / 2,
},
];
// Cosmetics — Cue Sticks (Purely Cosmetic for Fair Play)
const CUE_CATALOG = [
{
id: 'classic_maple',
name: 'Classic Ash & Maple',
tier: 'Standard',
cost: 0,
minLevel: 1,
buttColor: '#451A03',
shaftColor: '#FDE68A',
wrapColor: '#1E293B',
accentColor: '#D97706',
trailColor: 'rgba(255,255,255,0.65)',
},
{
id: 'carbon_pro',
name: 'Stealth Carbon Pro',
tier: 'Pro',
cost: 250,
minLevel: 2,
buttColor: '#0F172A',
shaftColor: '#475569',
wrapColor: '#1E293B',
accentColor: '#38BDF8',
trailColor: 'rgba(56,189,248,0.75)',
},
{
id: 'emerald_viper',
name: 'Emerald Cruiser',
tier: 'Elite',
cost: 500,
minLevel: 3,
buttColor: '#064E3B',
shaftColor: '#FEF3C7',
wrapColor: '#022C22',
accentColor: '#10B981',
trailColor: 'rgba(16,185,129,0.75)',
},
{
id: 'royal_sovereign',
name: 'Royal Sovereign Gold',
tier: 'Master',
cost: 900,
minLevel: 4,
buttColor: '#78350F',
shaftColor: '#FEF08A',
wrapColor: '#451A03',
accentColor: '#FACC15',
trailColor: 'rgba(250,204,21,0.85)',
},
{
id: 'celestial_neon',
name: 'Celestial Cyber Cue',
tier: 'Grandmaster',
cost: 1500,
minLevel: 5,
buttColor: '#311042',
shaftColor: '#E2E8F0',
wrapColor: '#090D16',
accentColor: '#E879F9',
trailColor: 'rgba(232,121,249,0.85)',
},
];
// Cosmetics — Table Felt Themes
const TABLE_THEMES = [
{
id: 'championship_green',
name: 'Crucible Championship Green',
cost: 0,
feltCenter: '#0D7A49',
feltEdge: '#064E2E',
railColor: '#451A03',
trimColor: '#D97706',
},
{
id: 'tournament_blue',
name: 'US Open Tournament Blue',
cost: 200,
feltCenter: '#1D5B9E',
feltEdge: '#123A66',
railColor: '#1E293B',
trimColor: '#94A3B8',
},
{
id: 'royal_burgundy',
name: 'Mayfair Club Burgundy',
cost: 400,
feltCenter: '#881337',
feltEdge: '#4C0519',
railColor: '#3B180C',
trimColor: '#F59E0B',
},
{
id: 'midnight_slate',
name: 'Midnight Obsidian Slate',
cost: 650,
feltCenter: '#1E293B',
feltEdge: '#0F172A',
railColor: '#090D16',
trimColor: '#38BDF8',
},
];
const AVATAR_OPTIONS = ['🎱', '👑', '🦊', '🐯', '⚡', '🔥', '💎', '🎯', '🦁', '🐉'];
// Helper to determine Rank Tier from Elo
function getRankTier(elo = 1200) {
if (elo >= 1800) return { name: 'Grandmaster', color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/15 border-fuchsia-500/40' };
if (elo >= 1550) return { name: 'Expert', color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/40' };
if (elo >= 1350) return { name: 'Advanced', color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/40' };
if (elo >= 1150) return { name: 'Intermediate', color: 'text-sky-400', bg: 'bg-sky-500/15 border-sky-500/40' };
return { name: 'Beginner', color: 'text-slate-300', bg: 'bg-slate-500/15 border-slate-500/40' };
}
// ============================================================================
// 4. INITIAL RACK GENERATORS FOR 8-BALL, 9-BALL, SNOOKER & DRILLS
// ============================================================================
function createInitialBalls(mode = '8ball', drillType = 'standard') {
const isSnooker = mode === 'snooker';
const radius = isSnooker ? 10.5 : 13.2;
const balls = [];
// Cue ball (id: 0)
balls.push({
id: 0,
x: HEAD_STRING_X - 35,
y: TABLE_HEIGHT / 2 + (isSnooker ? 25 : 0),
vx: 0,
vy: 0,
spinX: 0,
spinY: 0,
radius,
color: '#F8FAFC',
label: '',
isStripe: false,
potted: false,
snookerType: 'cue',
points: 0,
rollPhase: 0,
});
if (mode === '8ball') {
if (drillType === 'clearance_drill') {
// Practice clearance drill
const customSpots = [
{ id: 1, x: 580, y: 180 },
{ id: 2, x: 720, y: 140 },
{ id: 3, x: 820, y: 310 },
{ id: 9, x: 640, y: 360 },
{ id: 10, x: 770, y: 240 },
{ id: 8, x: 840, y: 250 },
];
customSpots.forEach((s) => {
const meta = POOL_BALL_COLORS[s.id];
balls.push({
id: s.id,
x: s.x,
y: s.y,
vx: 0,
vy: 0,
spinX: 0,
spinY: 0,
radius,
color: meta.color,
label: meta.label,
isStripe: meta.isStripe,
potted: false,
group: s.id === 8 ? 'eight' : s.id < 8 ? 'solids' : 'stripes',
rollPhase: 0,
});
});
return balls;
}
// Standard 15-ball triangle rack
// Row layout: 5 rows (1, 2, 3, 4, 5)
// Ensure 8-ball is in center of row 3 (index 4), and bottom corners are 1 solid & 1 stripe
const rackOrder = [
1, // Row 0 apex
9, 2, // Row 1
10, 8, 3, // Row 2 (8-ball in center!)
4, 14, 11, 5, // Row 3
12, 6, 13, 15, 7, // Row 4 (corners: 12 stripe & 7 solid)
];
const dx = radius * Math.sqrt(3) + 0.4;
const dy = radius + 0.25;
let idx = 0;
for (let row = 0; row < 5; row++) {
for (let col = 0; col <= row; col++) {
const ballId = rackOrder[idx++];
const meta = POOL_BALL_COLORS[ballId];
const x = FOOT_SPOT_X + row * dx;
const y = FOOT_SPOT_Y + (col - row / 2) * (dy * 2);
balls.push({
id: ballId,
x,
y,
vx: 0,
vy: 0,
spinX: 0,
spinY: 0,
radius,
color: meta.color,
label: meta.label,
isStripe: meta.isStripe,
potted: false,
group: ballId === 8 ? 'eight' : ballId < 8 ? 'solids' : 'stripes',
rollPhase: (ballId * 0.7) % 6.28,
});
}
}
} else if (mode === '9ball') {
// 9-Ball Diamond Rack: 1-ball at apex, 9-ball in center
// Diamond rows have lengths: 1, 2, 3, 2, 1
const diamondRows = [
[1],
[2, 3],
[4, 9, 5],
[6, 7],
[8],
];
const dx = radius * Math.sqrt(3) + 0.4;
const dy = radius + 0.25;
for (let row = 0; row < diamondRows.length; row++) {
const rowBalls = diamondRows[row];
for (let col = 0; col < rowBalls.length; col++) {
const ballId = rowBalls[col];
const meta = POOL_BALL_COLORS[ballId];
const x = FOOT_SPOT_X + row * dx;
const y = FOOT_SPOT_Y + (col - (rowBalls.length - 1) / 2) * (dy * 2);
balls.push({
id: ballId,
x,
y,
vx: 0,
vy: 0,
spinX: 0,
spinY: 0,
radius,
color: meta.color,
label: meta.label,
isStripe: meta.isStripe,
potted: false,
group: 'nineball',
rollPhase: (ballId * 0.8) % 6.28,
});
}
}
} else if (mode === 'snooker') {
// 6 Colored Balls on their official spots
SNOOKER_COLORS_CONFIG.forEach((c) => {
balls.push({
id: c.id,
x: c.spotX,
y: c.spotY,
spotX: c.spotX,
spotY: c.spotY,
vx: 0,
vy: 0,
spinX: 0,
spinY: 0,
radius,
color: c.color,
label: String(c.points),
name: c.name,
isStripe: false,
potted: false,
snookerType: 'color',
points: c.points,
rollPhase: 0,
});
});
// 15 Red Balls racked just to the right of the Pink ball (spotX = PLAY_LEFT + PLAY_W * 0.72)
const pinkSpotX = PLAY_LEFT + PLAY_W * 0.72;
const apexX = pinkSpotX + radius * 2.35;
const dx = radius * Math.sqrt(3) + 0.35;
const dy = radius + 0.2;
let redId = 1;
for (let row = 0; row < 5; row++) {
for (let col = 0; col <= row; col++) {
const x = apexX + row * dx;
const y = FOOT_SPOT_Y + (col - row / 2) * (dy * 2);
balls.push({
id: redId++,
x,
y,
vx: 0,
vy: 0,
spinX: 0,
spinY: 0,
radius,
color: '#DC2626',
label: '',
name: 'Red',
isStripe: false,
potted: false,
snookerType: 'red',
points: 1,
rollPhase: 0,
});
}
}
}
return balls;
}
// ============================================================================
// 5. DETERMINISTIC PHYSICS STEP & RAYCAST AIMING ENGINE
// ============================================================================
function isBallLegalTarget(ball, gameState) {
if (!ball || ball.id === 0 || ball.potted) return false;
const { mode, currentTurn, playerGroups, balls, snookerTarget } = gameState;
if (mode === '8ball') {
const group = playerGroups[currentTurn];
if (!group) {
// Open table: any ball except 8-ball is a legal first contact
return ball.id !== 8;
}
const remainingInGroup = balls.filter(
(b) => !b.potted && b.id !== 0 && b.id !== 8 && b.group === group
).length;
if (remainingInGroup > 0) {
return ball.group === group;
}
return ball.id === 8;
}
if (mode === '9ball') {
// Must contact the lowest-numbered ball on the table first
const activeObjectBalls = balls.filter((b) => !b.potted && b.id > 0);
if (activeObjectBalls.length === 0) return false;
const lowestId = Math.min(...activeObjectBalls.map((b) => b.id));
return ball.id === lowestId;
}
if (mode === 'snooker') {
if (snookerTarget === 'red') {
return ball.snookerType === 'red';
}
if (snookerTarget === 'color') {
return ball.snookerType === 'color';
}
if (typeof snookerTarget === 'number') {
return ball.points === snookerTarget;
}
}
return true;
}
// Compute Raycast for Aiming Line, Ghost Ball, and Deflection Vectors
function computeAimRaycast(balls, aimAngle, spinX = 0, spinY = 0) {
const cueBall = balls.find((b) => b.id === 0 && !b.potted);
if (!cueBall) return null;
const dx = Math.cos(aimAngle);
const dy = Math.sin(aimAngle);
const r = cueBall.radius;
let closestDist = Infinity;
let hitBall = null;
// Check intersection with all active object balls (circle-circle sweep)
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.id === 0 || b.potted) continue;
const rx = b.x - cueBall.x;
const ry = b.y - cueBall.y;
const proj = rx * dx + ry * dy;
if (proj <= 0) continue; // Behind cue ball movement direction
const perpDistSq = rx * rx + ry * ry - proj * proj;
const sumR = r + b.radius;
if (perpDistSq < sumR * sumR) {
const offset = Math.sqrt(sumR * sumR - perpDistSq);
const dist = proj - offset;
if (dist >= 0 && dist < closestDist) {
closestDist = dist;
hitBall = b;
}
}
}
// Check cushion boundaries if no ball hit or before ball hit
let wallDist = Infinity;
let wallNormal = { x: 0, y: 0 };
if (dx > 0.0001) {
const d = (PLAY_RIGHT - r - cueBall.x) / dx;
if (d < wallDist) {
wallDist = d;
wallNormal = { x: -1, y: 0 };
}
} else if (dx < -0.0001) {
const d = (PLAY_LEFT + r - cueBall.x) / dx;
if (d < wallDist) {
wallDist = d;
wallNormal = { x: 1, y: 0 };
}
}
if (dy > 0.0001) {
const d = (PLAY_BOTTOM - r - cueBall.y) / dy;
if (d < wallDist) {
wallDist = d;
wallNormal = { x: 0, y: -1 };
}
} else if (dy < -0.0001) {
const d = (PLAY_TOP + r - cueBall.y) / dy;
if (d < wallDist) {
wallDist = d;
wallNormal = { x: 0, y: 1 };
}
}
if (hitBall && closestDist <= wallDist) {
const ghostX = cueBall.x + dx * closestDist;
const ghostY = cueBall.y + dy * closestDist;
// Normal from ghost ball center to hitBall center
const nx = (hitBall.x - ghostX) / (r + hitBall.radius);
const ny = (hitBall.y - ghostY) / (r + hitBall.radius);
// Tangent direction for cue ball deflection
const dot = dx * nx + dy * ny;
let tangentX = dx - dot * nx;
let tangentY = dy - dot * ny;
// Incorporate topspin/backspin influence on post-impact cue ball path preview
tangentX += dx * spinY * 0.45 - dy * spinX * 0.2;
tangentY += dy * spinY * 0.45 + dx * spinX * 0.2;
return {
type: 'ball',
ghostX,
ghostY,
hitBall,
targetDirX: nx,
targetDirY: ny,
cueDeflectX: tangentX,
cueDeflectY: tangentY,
};
}
// Hit cushion first
const ghostX = cueBall.x + dx * wallDist;
const ghostY = cueBall.y + dy * wallDist;
// Reflected vector with sidespin influence
let refX = wallNormal.x !== 0 ? -dx : dx;
let refY = wallNormal.y !== 0 ? -dy : dy;
refX += -wallNormal.y * spinX * 0.35;
refY += wallNormal.x * spinX * 0.35;
return {
type: 'cushion',
ghostX,
ghostY,
hitBall: null,
cueDeflectX: refX,
cueDeflectY: refY,
};
}
// Single deterministic physics step (runs multiple times per visual frame)
function stepPhysicsSimulation(balls, shotTracker, playAudio = true) {
const SUBSTEPS = 6;
const FRICTION = 0.9918;
const SPIN_DECAY = 0.986;
const RESTITUTION_BALL = 0.945;
const RESTITUTION_RAIL = 0.81;
let anyMoving = false;
for (let step = 0; step < SUBSTEPS; step++) {
// 1. Advance positions & apply cloth friction + spin transfer
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potted) continue;
const speed = Math.hypot(b.vx, b.vy);
if (speed > 0.025 || Math.abs(b.spinY) > 0.04) {
anyMoving = true;
b.x += b.vx / SUBSTEPS;
b.y += b.vy / SUBSTEPS;
// Apply rolling/sliding friction per substep
const stepFriction = Math.pow(FRICTION, 1 / SUBSTEPS);
b.vx *= stepFriction;
b.vy *= stepFriction;
// Topspin / Backspin continuous cloth traction on cue ball
if (b.id === 0 && (Math.abs(b.spinY) > 0.02 || Math.abs(b.spinX) > 0.02)) {
b.spinY *= Math.pow(SPIN_DECAY, 1 / SUBSTEPS);
b.spinX *= Math.pow(SPIN_DECAY, 1 / SUBSTEPS);
}
b.rollPhase = (b.rollPhase + speed * 0.03) % (Math.PI * 2);
if (Math.hypot(b.vx, b.vy) < 0.028 && Math.abs(b.spinY) < 0.04) {
b.vx = 0;
b.vy = 0;
b.spinX = 0;
b.spinY = 0;
}
} else {
b.vx = 0;
b.vy = 0;
}
}
// 2. Pocket detection
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potted) continue;
for (let p = 0; p < POCKETS.length; p++) {
const pocket = POCKETS[p];
const dist = Math.hypot(b.x - pocket.x, b.y - pocket.y);
if (dist < pocket.radius) {
b.potted = true;
b.vx = 0;
b.vy = 0;
b.spinX = 0;
b.spinY = 0;
b.pocketAnim = { x: pocket.x, y: pocket.y, progress: 0 };
shotTracker.pottedBalls.push(b);
if (playAudio) soundFX.playPocketDrop();
break;
}
}
}
// 3. Cushion collisions (with pocket mouth gaps)
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potted) continue;
const r = b.radius;
// Check if ball is inside a pocket throat (allow entry into pocket)
const nearPocket = POCKETS.some(
(pk) => Math.hypot(b.x - pk.x, b.y - pk.y) < pk.radius + 9
);
if (nearPocket) continue;
let hitCushion = false;
const preSpeed = Math.hypot(b.vx, b.vy);
if (b.x - r < PLAY_LEFT) {
b.x = PLAY_LEFT + r;
b.vx = Math.abs(b.vx) * RESTITUTION_RAIL;
if (b.id === 0) {
b.vy += b.spinX * 1.4;
b.spinX *= 0.5;
}
hitCushion = true;
} else if (b.x + r > PLAY_RIGHT) {
b.x = PLAY_RIGHT - r;
b.vx = -Math.abs(b.vx) * RESTITUTION_RAIL;
if (b.id === 0) {
b.vy -= b.spinX * 1.4;
b.spinX *= 0.5;
}
hitCushion = true;
}
if (b.y - r < PLAY_TOP) {
b.y = PLAY_TOP + r;
b.vy = Math.abs(b.vy) * RESTITUTION_RAIL;
if (b.id === 0) {
b.vx -= b.spinX * 1.4;
b.spinX *= 0.5;
}
hitCushion = true;
} else if (b.y + r > PLAY_BOTTOM) {
b.y = PLAY_BOTTOM - r;
b.vy = -Math.abs(b.vy) * RESTITUTION_RAIL;
if (b.id === 0) {
b.vx += b.spinX * 1.4;
b.spinX *= 0.5;
}
hitCushion = true;
}
if (hitCushion) {
if (shotTracker.firstContactBall) {
shotTracker.cushionHitsAfterContact += 1;
}
shotTracker.totalCushionHits += 1;
if (playAudio && preSpeed > 0.6) {
soundFX.playCushionImpact(preSpeed);
}
}
}
// 4. Ball-to-Ball Elastic Collisions & Spin Action
for (let i = 0; i < balls.length; i++) {
const b1 = balls[i];
if (b1.potted) continue;
for (let j = i + 1; j < balls.length; j++) {
const b2 = balls[j];
if (b2.potted) continue;
const dx = b2.x - b1.x;
const dy = b2.y - b1.y;
const dist = Math.hypot(dx, dy);
const minDist = b1.radius + b2.radius;
if (dist < minDist && dist > 0.0001) {
// Record first object ball contacted by Cue Ball (id === 0)
if ((b1.id === 0 || b2.id === 0) && !shotTracker.firstContactBall) {
shotTracker.firstContactBall = b1.id === 0 ? b2 : b1;
}
// Resolve overlap immediately to prevent tunneling
const overlap = minDist - dist;
const nx = dx / dist;
const ny = dy / dist;
b1.x -= nx * (overlap * 0.5);
b1.y -= ny * (overlap * 0.5);
b2.x += nx * (overlap * 0.5);
b2.y += ny * (overlap * 0.5);
// Relative velocity along normal
const rvx = b1.vx - b2.vx;
const rvy = b1.vy - b2.vy;
const velAlongNormal = rvx * nx + rvy * ny;
if (velAlongNormal > 0) {
const impulse = velAlongNormal * RESTITUTION_BALL;
b1.vx -= nx * impulse;
b1.vy -= ny * impulse;
b2.vx += nx * impulse;
b2.vy += ny * impulse;
// Apply Cue Ball Topspin (follow) / Backspin (draw/screw) upon hitting an object ball!
if (b1.id === 0 && (Math.abs(b1.spinY) > 0.05 || Math.abs(b1.spinX) > 0.05)) {
const spinForce = impulse * 0.42;
// spinY > 0 drives cue ball forward along original collision normal; spinY < 0 draws it back
b1.vx += nx * b1.spinY * spinForce - ny * b1.spinX * spinForce * 0.35;
b1.vy += ny * b1.spinY * spinForce + nx * b1.spinX * spinForce * 0.35;
b1.spinY *= 0.25;
b1.spinX *= 0.4;
} else if (b2.id === 0 && (Math.abs(b2.spinY) > 0.05 || Math.abs(b2.spinX) > 0.05)) {
const spinForce = impulse * 0.42;
b2.vx -= nx * b2.spinY * spinForce - ny * b2.spinX * spinForce * 0.35;
b2.vy -= ny * b2.spinY * spinForce + nx * b2.spinX * spinForce * 0.35;
b2.spinY *= 0.25;
b2.spinX *= 0.4;
}
if (playAudio && velAlongNormal > 0.35) {
soundFX.playBallCollision(velAlongNormal);
}
}
}
}
}
}
return anyMoving;
}
// ============================================================================
// 6. COMPLETE RULE EVALUATION ENGINE (8-BALL, 9-BALL & SNOOKER)
// ============================================================================
function evaluateShotRules(prevState, ballsAfter, shotTracker) {
const {
mode,
currentTurn,
playerGroups,
scores,
currentBreak,
snookerTarget,
isBreakShot,
} = prevState;
const opponentTurn = currentTurn === 1 ? 2 : 1;
const nextBalls = ballsAfter.map((b) => ({ ...b }));
const cueBall = nextBalls.find((b) => b.id === 0);
const cuePotted = shotTracker.pottedBalls.some((b) => b.id === 0);
const objectPotted = shotTracker.pottedBalls.filter((b) => b.id !== 0);
const firstHit = shotTracker.firstContactBall;
let foulReason = null;
let turnContinues = false;
let winner = null;
let winReason = '';
let nextGroups = { ...playerGroups };
let nextScores = { ...scores };
let nextBreak = currentBreak;
let nextSnookerTarget = snookerTarget;
let ballInHand = false;
let bannerMessage = '';
// Helper to respot cue ball safely if scratched
const respotCueBall = () => {
if (!cueBall) return;
cueBall.potted = false;
cueBall.vx = 0;
cueBall.vy = 0;
cueBall.spinX = 0;
cueBall.spinY = 0;
let testX = HEAD_STRING_X - 35;
let testY = TABLE_HEIGHT / 2;
// Ensure no overlap with existing balls
for (let offset = 0; offset < 120; offset += 15) {
const candidateY = TABLE_HEIGHT / 2 + (offset % 2 === 0 ? offset : -offset);
const conflict = nextBalls.some(
(ob) =>
ob.id !== 0 &&
!ob.potted &&
Math.hypot(ob.x - testX, ob.y - candidateY) < ob.radius + cueBall.radius + 4
);
if (!conflict) {
testY = candidateY;
break;
}
}
cueBall.x = testX;
cueBall.y = testY;
};
// --------------------------------------------------------------------------
// MODE A: 8-BALL POOL RULES
// --------------------------------------------------------------------------
if (mode === '8ball') {
const eightPotted = objectPotted.some((b) => b.id === 8);
const activeGroup = nextGroups[currentTurn];
const remainingBeforeShot = prevState.balls.filter(
(b) => !b.potted && b.id !== 0 && b.id !== 8 && (!activeGroup || b.group === activeGroup)
).length;
// 1. Check 8-Ball Potted Conditions
if (eightPotted) {
if (isBreakShot) {
// 8-ball on break: respot 8-ball unless scratched
const eightBall = nextBalls.find((b) => b.id === 8);
if (eightBall) {
eightBall.potted = false;
eightBall.x = FOOT_SPOT_X;
eightBall.y = FOOT_SPOT_Y;
}
if (cuePotted) {
foulReason = 'Scratch on Break! 8-Ball respotted.';
} else {
turnContinues = true;
bannerMessage = '8-Ball potted on break — respotted on foot spot!';
}
} else if (!activeGroup || remainingBeforeShot > 0) {
// Illegally potted 8-ball early -> Immediate Loss!
winner = opponentTurn;
winReason = `Player ${currentTurn} potted the 8-Ball before clearing their group!`;
return {
balls: nextBalls,
currentTurn: opponentTurn,
playerGroups: nextGroups,
scores: nextScores,
currentBreak: 0,
snookerTarget: nextSnookerTarget,
ballInHand: false,
foulMessage: winReason,
bannerMessage: winReason,
winner,
winReason,
isBreakShot: false,
};
} else {
// Player was legally on the 8-ball!
if (cuePotted) {
winner = opponentTurn;
winReason = `Player ${currentTurn} scratched while potting the 8-Ball!`;
} else if (!firstHit || firstHit.id !== 8) {
winner = opponentTurn;
winReason = `Foul on the 8-Ball! Player ${ opponentTurn } wins.`;
} else {
winner = currentTurn;
winReason = `Player ${currentTurn} legally pocketed the 8-Ball!`;
nextScores[currentTurn] = (nextScores[currentTurn] || 0) + 1;
}
return {
balls: nextBalls,
currentTurn: winner,
playerGroups: nextGroups,
scores: nextScores,
currentBreak: 0,
snookerTarget: nextSnookerTarget,
ballInHand: false,
foulMessage: winner !== currentTurn ? winReason : null,
bannerMessage: winReason,
winner,
winReason,
isBreakShot: false,
};
}
}
// 2. Standard 8-Ball Foul Detection
if (cuePotted) {
foulReason = 'Scratch! Cue ball potted.';
} else if (!firstHit) {
foulReason = 'Foul! Cue ball failed to contact any ball.';
} else if (activeGroup) {
// Must hit own group first (or 8-ball if group is already cleared)
const mustHitEight = remainingBeforeShot === 0;
if (mustHitEight && firstHit.id !== 8) {
foulReason = `Foul! Must contact the 8-Ball first.`;
} else if (!mustHitEight && firstHit.group !== activeGroup) {
foulReason = `Foul! Must hit ${activeGroup.toUpperCase()} first.`;
}
} else if (!activeGroup && !isBreakShot && firstHit.id === 8) {
foulReason = 'Foul! Cannot hit the 8-Ball first on an open table.';
}
// Check cushion contact after hit if no ball was potted
if (!foulReason && objectPotted.length === 0 && shotTracker.cushionHitsAfterContact === 0) {
foulReason = 'Foul! No ball potted and no cushion contacted after impact.';
}
if (foulReason) {
if (cuePotted) respotCueBall();
ballInHand = true;
bannerMessage = `${foulReason} Ball-in-Hand to Player ${opponentTurn}.`;
} else {
// Legal shot! Check group assignment or continuation
const pottedSolids = objectPotted.filter((b) => b.group === 'solids');
const pottedStripes = objectPotted.filter((b) => b.group === 'stripes');
if (!activeGroup && !isBreakShot && (pottedSolids.length > 0 || pottedStripes.length > 0)) {
const chosenGroup =
pottedSolids.length >= pottedStripes.length ? 'solids' : 'stripes';
nextGroups[currentTurn] = chosenGroup;
nextGroups[opponentTurn] = chosenGroup === 'solids' ? 'stripes' : 'solids';
turnContinues = true;
bannerMessage = `Table Assigned! Player ${currentTurn} is ${chosenGroup.toUpperCase()}.`;
} else if (!activeGroup && isBreakShot && objectPotted.length > 0) {
turnContinues = true;
bannerMessage = 'Legal Break Pot! Table remains Open.';
} else if (activeGroup) {
const pottedOwnGroup = objectPotted.some((b) => b.group === activeGroup);
if (pottedOwnGroup) {
turnContinues = true;
bannerMessage = `Nice shot! Player ${currentTurn} continues.`;
} else {
bannerMessage = `Turn passes to Player ${opponentTurn}.`;
}
} else {
bannerMessage = `Turn passes to Player ${opponentTurn}.`;
}
// Update ball count score for HUD
nextScores[1] = nextGroups[1]
? 7 -
nextBalls.filter((b) => !b.potted && b.group === nextGroups[1]).length
: 0;
nextScores[2] = nextGroups[2]
? 7 -
nextBalls.filter((b) => !b.potted && b.group === nextGroups[2]).length
: 0;
}
}
// --------------------------------------------------------------------------
// MODE B: 9-BALL POOL RULES
// --------------------------------------------------------------------------
else if (mode === '9ball') {
const activeBefore = prevState.balls.filter((b) => !b.potted && b.id > 0);
const lowestId = activeBefore.length > 0 ? Math.min(...activeBefore.map((b) => b.id)) : 1;
const ninePotted = objectPotted.some((b) => b.id === 9);
if (cuePotted) {
foulReason = 'Scratch! Cue ball potted.';
} else if (!firstHit) {
foulReason = `Foul! Cue ball missed the ${lowestId}-Ball.`;
} else if (firstHit.id !== lowestId) {
foulReason = `Foul! Must strike the lowest ball (${lowestId}-Ball) first.`;
} else if (objectPotted.length === 0 && shotTracker.cushionHitsAfterContact === 0) {
foulReason = 'Foul! No ball potted and no rail contacted after impact.';
}
if (foulReason) {
if (cuePotted) respotCueBall();
// If 9-ball was potted on a foul, respot the 9-ball on the foot spot!
if (ninePotted) {
const nineBall = nextBalls.find((b) => b.id === 9);
if (nineBall) {
nineBall.potted = false;
nineBall.x = FOOT_SPOT_X;
nineBall.y = FOOT_SPOT_Y;
}
foulReason += ' 9-Ball respotted.';
}
ballInHand = true;
bannerMessage = `${foulReason} Ball-in-Hand to Player ${opponentTurn}.`;
} else {
// Legal shot! Did the 9-ball go in?
if (ninePotted) {
winner = currentTurn;
winReason = `Player ${currentTurn} legally pocketed the 9-Ball to win the rack!`;
nextScores[currentTurn] = (nextScores[currentTurn] || 0) + 1;
return {
balls: nextBalls,
currentTurn,
playerGroups: nextGroups,
scores: nextScores,
currentBreak: nextBreak + 1,
snookerTarget: nextSnookerTarget,
ballInHand: false,
foulMessage: null,
bannerMessage: winReason,
winner,
winReason,
isBreakShot: false,
};
}
if (objectPotted.length > 0) {
turnContinues = true;
nextScores[currentTurn] = (nextScores[currentTurn] || 0) + objectPotted.length;
bannerMessage = `Great pot! Player ${currentTurn} continues at the table.`;
} else {
bannerMessage = `Turn passes to Player ${opponentTurn}.`;
}
}
}
// --------------------------------------------------------------------------
// MODE C: SNOOKER RULES (15 REDS + 6 COLORS)
// --------------------------------------------------------------------------
else if (mode === 'snooker') {
const redsRemainingBefore = prevState.balls.filter(
(b) => !b.potted && b.snookerType === 'red'
).length;
const getExpectedPenalty = (ballVal = 0) => {
const targetVal =
typeof snookerTarget === 'number'
? snookerTarget
: snookerTarget === 'red'
? 1
: 4;
return Math.max(4, targetVal, ballVal);
};
// Helper to respot any potted colored ball
const respotColorBall = (colorBall) => {
const targetBall = nextBalls.find((b) => b.id === colorBall.id);
if (!targetBall) return;
targetBall.potted = false;
targetBall.vx = 0;
targetBall.vy = 0;
// Try its own spot first, then highest available spot
const spotCandidates = [
{ x: targetBall.spotX, y: targetBall.spotY },
...SNOOKER_COLORS_CONFIG.slice()
.reverse()
.map((c) => ({ x: c.spotX, y: c.spotY })),
];
let placed = false;
for (const sp of spotCandidates) {
const occupied = nextBalls.some(
(ob) =>
ob.id !== targetBall.id &&
!ob.potted &&
Math.hypot(ob.x - sp.x, ob.y - sp.y) < ob.radius + targetBall.radius + 2
);
if (!occupied) {
targetBall.x = sp.x;
targetBall.y = sp.y;
placed = true;
break;
}
}
if (!placed) {
targetBall.x = targetBall.spotX + 24;
targetBall.y = targetBall.spotY;
}
};
let foulPenalty = 0;
if (cuePotted) {
const maxPottedVal = objectPotted.reduce((m, b) => Math.max(m, b.points || 4), 4);
foulPenalty = getExpectedPenalty(maxPottedVal);
foulReason = `In-Off (Scratch)! ${foulPenalty} foul points to Player ${opponentTurn}.`;
} else if (!firstHit) {
foulPenalty = getExpectedPenalty(4);
foulReason = `Foul (Miss)! ${foulPenalty} penalty points to Player ${opponentTurn}.`;
} else {
// Check if first hit matched snookerTarget
const legalFirst = isBallLegalTarget(firstHit, prevState);
if (!legalFirst) {
foulPenalty = getExpectedPenalty(firstHit.points || 4);
foulReason = `Foul! Struck ${firstHit.name || 'wrong ball'} first (${foulPenalty} pts to opponent).`;
} else if (objectPotted.length > 0) {
// Verify all potted balls match target rule
if (snookerTarget === 'red') {
const pottedColor = objectPotted.find((b) => b.snookerType === 'color');
if (pottedColor) {
foulPenalty = getExpectedPenalty(pottedColor.points);
foulReason = `Foul! Potted ${pottedColor.name} while on Red (${foulPenalty} pts).`;
}
} else if (snookerTarget === 'color') {
const pottedRed = objectPotted.find((b) => b.snookerType === 'red');
const uniqueColors = new Set(objectPotted.map((b) => b.id));
if (pottedRed || uniqueColors.size > 1) {
const highest = Math.max(...objectPotted.map((b) => b.points || 4));
foulPenalty = getExpectedPenalty(highest);
foulReason = `Foul! Illegal ball potted while on a Color (${foulPenalty} pts).`;
}
} else if (typeof snookerTarget === 'number') {
if (objectPotted.length > 1 || objectPotted[0].points !== snookerTarget) {
const highest = Math.max(...objectPotted.map((b) => b.points || 4));
foulPenalty = getExpectedPenalty(highest);
foulReason = `Foul on the colors! ${foulPenalty} pts awarded to opponent.`;
}
}
}
}
const redsRemainingNow = nextBalls.filter(
(b) => !b.potted && b.snookerType === 'red'
).length;
if (foulReason) {
if (cuePotted) {
respotCueBall();
ballInHand = true; // Place in the D-zone
}
// Respot any colored balls potted during a foul
objectPotted
.filter((b) => b.snookerType === 'color')
.forEach((cb) => respotColorBall(cb));
nextScores[opponentTurn] = (nextScores[opponentTurn] || 0) + foulPenalty;
nextBreak = 0;
nextSnookerTarget =
redsRemainingNow > 0
? 'red'
: getLowestRemainingSnookerColor(nextBalls);
bannerMessage = foulReason;
} else if (objectPotted.length > 0) {
// Legal Snooker Pot!
const ptsScored = objectPotted.reduce((sum, b) => sum + (b.points || 1), 0);
nextScores[currentTurn] = (nextScores[currentTurn] || 0) + ptsScored;
nextBreak = currentBreak + ptsScored;
turnContinues = true;
if (snookerTarget === 'red') {
nextSnookerTarget = 'color';
bannerMessage = `Potted Red (+${ptsScored})! Break: ${nextBreak}. Nominate any Color.`;
} else if (snookerTarget === 'color') {
// Respot the potted color because this was the color after a red
objectPotted
.filter((b) => b.snookerType === 'color')
.forEach((cb) => respotColorBall(cb));
nextSnookerTarget =
redsRemainingNow > 0
? 'red'
: getLowestRemainingSnookerColor(nextBalls);
bannerMessage = `Potted ${objectPotted[0].name} (+${ptsScored})! Break: ${nextBreak}.`;
} else if (typeof snookerTarget === 'number') {
// Final clearance of colors (do NOT respot!)
const nextCol = getLowestRemainingSnookerColor(nextBalls);
nextSnookerTarget = nextCol;
bannerMessage = `Cleared ${objectPotted[0].name} (+${ptsScored})! Break: ${nextBreak}.`;
}
} else {
// No pot, legal safety shot
nextBreak = 0;
nextSnookerTarget =
redsRemainingNow > 0
? 'red'
: getLowestRemainingSnookerColor(nextBalls);
bannerMessage = `End of break. Player ${opponentTurn} to play (${formatSnookerTarget(nextSnookerTarget)}).`;
}
// Check if frame is complete (all balls potted)
const remainingObjectBalls = nextBalls.filter((b) => !b.potted && b.id !== 0);
if (remainingObjectBalls.length === 0) {
if (nextScores[1] === nextScores[2]) {
// Respotted Black tie-breaker!
const blackBall = nextBalls.find((b) => b.points === 7);
if (blackBall) respotColorBall(blackBall);
nextSnookerTarget = 7;
bannerMessage = 'Tied Frame! Respotted Black shootout!';
} else {
winner = nextScores[1] > nextScores[2] ? 1 : 2;
winReason = `Frame Complete! Player ${winner} wins ${nextScores[1]} – ${nextScores[2]}!`;
}
}
}
return {
balls: nextBalls,
currentTurn: turnContinues ? currentTurn : opponentTurn,
playerGroups: nextGroups,
scores: nextScores,
currentBreak: turnContinues ? nextBreak : 0,
snookerTarget: nextSnookerTarget,
ballInHand,
foulMessage: foulReason,
bannerMessage,
winner,
winReason,
isBreakShot: false,
};
}
function getLowestRemainingSnookerColor(balls) {
const activeColors = balls.filter((b) => !b.potted && b.snookerType === 'color');
if (activeColors.length === 0) return null;
return Math.min(...activeColors.map((b) => b.points));
}
function formatSnookerTarget(target) {
if (target === 'red') return 'Red (1 pt)';
if (target === 'color') return 'Any Color (2–7 pts)';
const map = {
2: 'Yellow (2 pts)',
3: 'Green (3 pts)',
4: 'Brown (4 pts)',
5: 'Blue (5 pts)',
6: 'Pink (6 pts)',
7: 'Black (7 pts)',
};
return map[target] || 'Clearance';
}
function calculateSnookerPointsRemaining(balls, snookerTarget) {
const reds = balls.filter((b) => !b.potted && b.snookerType === 'red').length;
const colorsSum = balls
.filter((b) => !b.potted && b.snookerType === 'color')
.reduce((acc, b) => acc + b.points, 0);
if (reds > 0) {
return reds * 8 + 27 + (snookerTarget === 'color' ? 7 : 0);
}
return colorsSum + (snookerTarget === 'color' ? 7 : 0);
}
// ============================================================================
// 7. AI BOT SHOT CALCULATOR (FOR LABELED AI MATCHES)
// ============================================================================
function calculateAIShot(gameState, difficulty = 'pro') {
const { balls } = gameState;
const cueBall = balls.find((b) => b.id === 0 && !b.potted);
if (!cueBall) return { angle: 0, power: 10, spinX: 0, spinY: 0 };
// Find all legal target balls
const legalBalls = balls.filter((b) => isBallLegalTarget(b, gameState));
const candidates = legalBalls.length > 0 ? legalBalls : balls.filter((b) => !b.potted && b.id !== 0);
let bestShot = null;
let bestScore = -Infinity;
// Evaluate each candidate target ball + pocket pair
candidates.forEach((target) => {
POCKETS.forEach((pocket) => {
const toPocketX = pocket.x - target.x;
const toPocketY = pocket.y - target.y;
const distToPocket = Math.hypot(toPocketX, toPocketY);
const normPKX = toPocketX / distToPocket;
const normPKY = toPocketY / distToPocket;
// Required ghost ball position behind target ball
const sumR = cueBall.radius + target.radius;
const ghostX = target.x - normPKX * sumR;
const ghostY = target.y - normPKY * sumR;
const toGhostX = ghostX - cueBall.x;
const toGhostY = ghostY - cueBall.y;
const distToGhost = Math.hypot(toGhostX, toGhostY);
const normGX = toGhostX / distToGhost;
const normGY = toGhostY / distToGhost;
// Cut angle cosine (1.0 = straight-in shot, <= 0 = impossible back-cut)
const cutCos = normGX * normPKX + normGY * normPKY;
if (cutCos <= 0.18) return;
const angle = Math.atan2(toGhostY, toGhostX);
// Verify raycast hits this target ball first!
const ray = computeAimRaycast(balls, angle, 0, 0);
if (!ray || ray.type !== 'ball' || ray.hitBall?.id !== target.id) return;
const score = cutCos * 120 - distToPocket * 0.08 - distToGhost * 0.05;
if (score > bestScore) {
bestScore = score;
const idealPower = Math.min(22, Math.max(7.5, (distToGhost + distToPocket) * 0.025 + 6));
bestShot = {
angle,
power: idealPower,
spinX: 0,
spinY: cutCos > 0.8 ? -0.35 : 0.15,
};
}
});
});
// Fallback direct hit on a legal ball if no clean pocket line exists
if (!bestShot && candidates.length > 0) {
const t = candidates[0];
bestShot = {
angle: Math.atan2(t.y - cueBall.y, t.x - cueBall.x),
power: 11.5,
spinX: 0,
spinY: 0,
};
}
if (!bestShot) {
bestShot = { angle: 0, power: 12, spinX: 0, spinY: 0 };
}
// Apply difficulty error margin
const maxErrorRad =
difficulty === 'grandmaster'
? 0.004
: difficulty === 'pro'
? 0.018
: 0.048;
const jitter = (Math.random() - 0.5) * 2 * maxErrorRad;
return {
angle: bestShot.angle + jitter,
power: bestShot.power,
spinX: bestShot.spinX,
spinY: bestShot.spinY,
};
}
// ============================================================================
// 8. MAIN APPLICATION COMPONENT
// ============================================================================
export default function App() {
// --- Auth & User Profile State ---
const [user, setUser] = useState(null);
const [authReady, setAuthReady] = useState(false);
const [profile, setProfile] = useState({
uid: '',
username: 'CueMaster',
avatar: '🎱',
isGuest: true,
elo: 1200,
xp: 0,
level: 1,
coins: 300,
wins: 0,
losses: 0,
streak: 0,
bestSnookerBreak: 0,
modeStats: {
'8ball': { wins: 0, losses: 0 },
'9ball': { wins: 0, losses: 0 },
snooker: { wins: 0, losses: 0 },
},
matchHistory: [],
ownedCues: ['classic_maple'],
ownedThemes: ['championship_green'],
equippedCue: 'classic_maple',
equippedTheme: 'championship_green',
});
// --- Navigation & Modals ---
const [activeTab, setActiveTab] = useState('lobby'); // 'lobby' | 'game' | 'leaderboards' | 'shop' | 'profile'
const [showSystemModal, setShowSystemModal] = useState(false);
const [showRulesModal, setShowRulesModal] = useState(false);
const [showSpinModal, setShowSpinModal] = useState(false);
const [editingProfile, setEditingProfile] = useState(false);
const [tempUsername, setTempUsername] = useState('');
const [tempAvatar, setTempAvatar] = useState('🎱');
// --- Audio & Visual Preferences ---
const [soundMuted, setSoundMuted] = useState(false);
const [soundVolume, setSoundVolume] = useState(0.75);
const [showTrajectoryGuide, setShowTrajectoryGuide] = useState(true);
// --- Online Multiplayer & Matchmaking State ---
const [allProfiles, setAllProfiles] = useState([]);
const [publicRooms, setPublicRooms] = useState([]);
const [activeRoomId, setActiveRoomId] = useState(null);
const [roomData, setRoomData] = useState(null);
const [joinCodeInput, setJoinCodeInput] = useState('');
const [copiedCode, setCopiedCode] = useState(false);
const [matchmakingState, setMatchmakingState] = useState({
searching: false,
mode: '8ball',
ranked: true,
elapsed: 0,
});
// --- Local Match Configuration (Lobby Selection) ---
const [selectedMode, setSelectedMode] = useState('8ball'); // '8ball' | '9ball' | 'snooker'
const [selectedRanked, setSelectedRanked] = useState(true);
const [selectedTurnTime, setSelectedTurnTime] = useState(45);
const [selectedAiSkill, setSelectedAiSkill] = useState('pro');
const [practiceDrill, setPracticeDrill] = useState('standard');
// --- Active Gameplay State ---
const [gameState, setGameState] = useState(() => ({
matchType: 'practice', // 'practice' | 'ai' | 'online' | 'pass_play'
mode: '8ball',
ranked: false,
turnLimit: 45,
turnTimeLeft: 45,
balls: createInitialBalls('8ball'),
currentTurn: 1, // 1 or 2
playerGroups: { 1: null, 2: null }, // 'solids' | 'stripes'
scores: { 1: 0, 2: 0 },
currentBreak: 0,
snookerTarget: 'red', // 'red' | 'color' | 2..7
ballInHand: true,
isBreakShot: true,
foulMessage: null,
bannerMessage: 'Break Shot! Place the cue ball behind the head string or aim and shoot.',
winner: null,
winReason: '',
shotSequenceId: 0,
player1: { uid: 'local', name: 'Player 1', avatar: '🎱', elo: 1200 },
player2: { uid: 'practice', name: 'Solo Practice', avatar: '🎯', elo: 1200, isAI: false },
}));
// --- Aiming, Power, Spin & Undo History Refs/State ---
const [aimAngle, setAimAngle] = useState(0);
const [shotPower, setShotPower] = useState(0); // 0 to 100
const [isPullingCue, setIsPullingCue] = useState(false);
const [spinOffset, setSpinOffset] = useState({ x: 0, y: 0 }); // -1 to 1
const [isMoving, setIsMoving] = useState(false);
const [isDraggingCueBall, setIsDraggingCueBall] = useState(false);
const [practiceHistory, setPracticeHistory] = useState([]);
const [toastMessage, setToastMessage] = useState(null);
const canvasRef = useRef(null);
const ballsRef = useRef(gameState.balls);
const gameStateRef = useRef(gameState);
const isMovingRef = useRef(false);
const shotTrackerRef = useRef({
firstContactBall: null,
pottedBalls: [],
cushionHitsAfterContact: 0,
totalCushionHits: 0,
});
const pocketAnimationsRef = useRef([]);
const lastProcessedSeqRef = useRef(0);
useEffect(() => {
gameStateRef.current = gameState;
}, [gameState]);
const showToast = useCallback((msg) => {
setToastMessage(msg);
setTimeout(() => {
setToastMessage((prev) => (prev === msg ? null : prev));
}, 3600);
}, []);
// ==========================================================================
// 9. FIREBASE AUTHENTICATION & REAL-TIME LISTENERS (RULES 1, 2, 3)
// ==========================================================================
useEffect(() => {
const initAuth = async () => {
try {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else {
await signInAnonymously(auth);
}
} catch (err) {
console.error('Firebase auth init error:', err);
}
};
initAuth();
const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
setUser(firebaseUser);
setAuthReady(true);
});
return () => unsubscribe();
}, []);
// Load & sync Player Profile + Public Leaderboards + Public Rooms
useEffect(() => {
if (!user) return;
const profileDocRef = doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid);
const unsubProfile = onSnapshot(
profileDocRef,
(snap) => {
if (snap.exists()) {
const data = snap.data();
setProfile((prev) => ({ ...prev, ...data, uid: user.uid }));
} else {
const randomSuffix = user.uid.slice(0, 4).toUpperCase();
const initialProfile = {
uid: user.uid,
username: `CuePlayer_${randomSuffix}`,
avatar: AVATAR_OPTIONS[Math.floor(Math.random() * AVATAR_OPTIONS.length)],
isGuest: true,
elo: 1200,
xp: 0,
level: 1,
coins: 350,
wins: 0,
losses: 0,
streak: 0,
bestSnookerBreak: 0,
modeStats: {
'8ball': { wins: 0, losses: 0 },
'9ball': { wins: 0, losses: 0 },
snooker: { wins: 0, losses: 0 },
},
matchHistory: [],
ownedCues: ['classic_maple'],
ownedThemes: ['championship_green'],
equippedCue: 'classic_maple',
equippedTheme: 'championship_green',
lastSeen: Date.now(),
};
setDoc(profileDocRef, initialProfile).catch(() => {});
setProfile(initialProfile);
}
},
(err) => console.error('Profile listener error:', err)
);
// Fetch all profiles for Live Leaderboard (Sorted in JS memory per Rule 2)
const profilesColRef = collection(db, 'artifacts', appId, 'public', 'data', 'profiles');
const unsubAllProfiles = onSnapshot(
profilesColRef,
(snap) => {
const list = [];
snap.forEach((d) => list.push(d.data()));
setAllProfiles(list);
},
(err) => console.error('All profiles listener error:', err)
);
// Fetch all public rooms & matchmaking rooms
const matchesColRef = collection(db, 'artifacts', appId, 'public', 'data', 'matches');
const unsubMatches = onSnapshot(
matchesColRef,
(snap) => {
const rooms = [];
snap.forEach((d) => {
const data = d.data();
if (data && Date.now() - (data.updatedAt || 0) < 1000 * 60 * 45) {
rooms.push(data);
}
});
setPublicRooms(rooms);
},
(err) => console.error('Matches listener error:', err)
);
return () => {
unsubProfile();
unsubAllProfiles();
unsubMatches();
};
}, [user]);
// Listen to Active Online Room Synchronization
useEffect(() => {
if (!user || !activeRoomId) return;
const roomDocRef = doc(db, 'artifacts', appId, 'public', 'data', 'matches', activeRoomId);
const unsubRoom = onSnapshot(
roomDocRef,
(snap) => {
if (!snap.exists()) return;
const data = snap.data();
setRoomData(data);
// If match just transitioned to active and we are in lobby, open game view
if (data.status === 'playing' && activeTab !== 'game') {
setActiveTab('game');
}
// Synchronize opponent shot execution or authoritative post-shot state
if (data.gameStateJson) {
try {
const parsed = JSON.parse(data.gameStateJson);
// If opponent just fired a live shot that we haven't simulated yet
if (
data.lastShot &&
data.lastShot.seq > lastProcessedSeqRef.current &&
data.lastShot.shooterUid !== user.uid
) {
lastProcessedSeqRef.current = data.lastShot.seq;
// Set pre-shot positions and trigger deterministic animation on our screen
if (data.lastShot.preBalls) {
ballsRef.current = data.lastShot.preBalls;
}
const cue = ballsRef.current.find((b) => b.id === 0);
if (cue) {
const speed = (data.lastShot.power / 100) * 24.5;
cue.vx = Math.cos(data.lastShot.angle) * speed;
cue.vy = Math.sin(data.lastShot.angle) * speed;
cue.spinX = data.lastShot.spinX || 0;
cue.spinY = data.lastShot.spinY || 0;
soundFX.playCueStrike(data.lastShot.power / 100);
isMovingRef.current = true;
setIsMoving(true);
}
}
// Apply authoritative synced state when balls aren't actively moving
if (!isMovingRef.current && parsed.shotSequenceId >= lastProcessedSeqRef.current) {
lastProcessedSeqRef.current = parsed.shotSequenceId;
ballsRef.current = parsed.balls;
setGameState((prev) => ({
...prev,
...parsed,
matchType: 'online',
player1: data.player1,
player2: data.player2,
}));
}
} catch (e) {
console.error('Failed to parse synced gameStateJson:', e);
}
}
},
(err) => console.error('Active room listener error:', err)
);
return () => unsubRoom();
}, [user, activeRoomId, activeTab]);
// ==========================================================================
// 10. MATCHMAKING QUEUE & PRIVATE ROOM HANDLERS
// ==========================================================================
const startMatchmaking = async (mode, ranked) => {
if (!user) return;
setMatchmakingState({ searching: true, mode, ranked, elapsed: 0 });
// Look for an existing waiting public room matching mode & ranked
const openMatch = publicRooms.find(
(r) =>
r.status === 'waiting' &&
r.isPublicQueue &&
r.mode === mode &&
Boolean(r.ranked) === Boolean(ranked) &&
r.player1?.uid !== user.uid
);
if (openMatch) {
await joinOnlineRoom(openMatch.roomId);
setMatchmakingState((prev) => ({ ...prev, searching: false }));
} else {
// Create a public waiting room in the matchmaking pool
await createOnlineRoom(mode, ranked, 45, true);
}
};
const cancelMatchmaking = async () => {
setMatchmakingState((prev) => ({ ...prev, searching: false, elapsed: 0 }));
if (activeRoomId && user && roomData?.status === 'waiting') {
try {
await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'matches', activeRoomId));
} catch (e) {}
setActiveRoomId(null);
setRoomData(null);
}
};
useEffect(() => {
if (!matchmakingState.searching) return;
const timer = setInterval(() => {
setMatchmakingState((prev) => ({ ...prev, elapsed: prev.elapsed + 1 }));
}, 1000);
return () => clearInterval(timer);
}, [matchmakingState.searching]);
const createOnlineRoom = async (
mode = selectedMode,
ranked = selectedRanked,
turnLimit = selectedTurnTime,
isPublicQueue = false
) => {
if (!user) return;
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let roomId = '';
for (let i = 0; i < 6; i++) {
roomId += chars[Math.floor(Math.random() * chars.length)];
}
const initialBalls = createInitialBalls(mode);
const p1 = {
uid: user.uid,
name: profile.username,
avatar: profile.avatar,
elo: profile.elo,
cue: profile.equippedCue,
};
const initialGameState = {
matchType: 'online',
mode,
ranked,
turnLimit,
turnTimeLeft: turnLimit,
balls: initialBalls,
currentTurn: 1,
playerGroups: { 1: null, 2: null },
scores: { 1: 0, 2: 0 },
currentBreak: 0,
snookerTarget: 'red',
ballInHand: true,
isBreakShot: true,
foulMessage: null,
bannerMessage: `Match started! ${p1.name} to break.`,
winner: null,
winReason: '',
shotSequenceId: 1,
};
const roomPayload = {
roomId,
status: 'waiting',
isPublicQueue,
mode,
ranked,
turnLimit,
player1: p1,
player2: null,
createdAt: Date.now(),
updatedAt: Date.now(),
gameStateJson: JSON.stringify(initialGameState),
};
await setDoc(
doc(db, 'artifacts', appId, 'public', 'data', 'matches', roomId),
roomPayload
);
lastProcessedSeqRef.current = 1;
setActiveRoomId(roomId);
setRoomData(roomPayload);
if (!isPublicQueue) {
showToast(`Room ${roomId} created! Share the code with your friend.`);
}
};
const joinOnlineRoom = async (codeToJoin) => {
if (!user) return;
const cleanCode = (codeToJoin || '').trim().toUpperCase();
if (!cleanCode) return;
const roomRef = doc(db, 'artifacts', appId, 'public', 'data', 'matches', cleanCode);
const snap = await getDoc(roomRef);
if (!snap.exists()) {
showToast(`Room ${cleanCode} not found.`);
return;
}
const data = snap.data();
const p2 = {
uid: user.uid,
name: profile.username,
avatar: profile.avatar,
elo: profile.elo,
cue: profile.equippedCue,
};
// Allow reconnection if already Player 1 or Player 2
if (data.player1?.uid === user.uid || data.player2?.uid === user.uid) {
setActiveRoomId(cleanCode);
setRoomData(data);
setActiveTab('game');
showToast(`Reconnected to Room ${cleanCode}!`);
return;
}
if (data.status !== 'waiting' && data.player2) {
showToast(`Room ${cleanCode} is already full.`);
return;
}
const parsedState = JSON.parse(data.gameStateJson);
parsedState.player1 = data.player1;
parsedState.player2 = p2;
await updateDoc(roomRef, {
player2: p2,
status: 'playing',
updatedAt: Date.now(),
gameStateJson: JSON.stringify(parsedState),
});
lastProcessedSeqRef.current = parsedState.shotSequenceId || 1;
ballsRef.current = parsedState.balls;
setGameState({
...parsedState,
matchType: 'online',
player1: data.player1,
player2: p2,
});
setActiveRoomId(cleanCode);
setMatchmakingState((prev) => ({ ...prev, searching: false }));
setActiveTab('game');
showToast(`Joined Room ${cleanCode} vs ${data.player1.name}!`);
};
// ==========================================================================
// 11. START PRACTICE, AI BOT MATCH, OR LOCAL PASS & PLAY
// ==========================================================================
const startLocalMatch = (matchType = 'practice', mode = selectedMode, drill = 'standard') => {
if (matchmakingState.searching) cancelMatchmaking();
setActiveRoomId(null);
setRoomData(null);
const initialBalls = createInitialBalls(mode, drill);
ballsRef.current = initialBalls.map((b) => ({ ...b }));
isMovingRef.current = false;
setIsMoving(false);
setPracticeHistory([]);
setSpinOffset({ x: 0, y: 0 });
setShotPower(0);
const aiNames = {
rookie: { name: 'Rookie Bot [AI]', elo: 1050, avatar: '🤖' },
pro: { name: 'Club Pro Bot [AI]', elo: 1420, avatar: '🦾' },
grandmaster: { name: 'Grandmaster AI [AI]', elo: 1850, avatar: '⚡' },
};
const chosenAI = aiNames[selectedAiSkill] || aiNames.pro;
const p1 = {
uid: user?.uid || 'local_p1',
name: profile.username,
avatar: profile.avatar,
elo: profile.elo,
};
const p2 =
matchType === 'ai'
? {
uid: 'ai_bot',
name: chosenAI.name,
avatar: chosenAI.avatar,
elo: chosenAI.elo,
isAI: true,
aiSkill: selectedAiSkill,
}
: matchType === 'pass_play'
? {
uid: 'local_p2',
name: 'Guest Player 2',
avatar: '🎯',
elo: 1200,
isAI: false,
}
: {
uid: 'practice',
name: 'Solo Practice Table',
avatar: '🎯',
elo: profile.elo,
isAI: false,
};
setGameState({
matchType,
mode,
ranked: matchType === 'ai' ? selectedRanked : false,
turnLimit: matchType === 'practice' ? 0 : selectedTurnTime,
turnTimeLeft: matchType === 'practice' ? 0 : selectedTurnTime,
balls: initialBalls,
currentTurn: 1,
playerGroups: { 1: null, 2: null },
scores: { 1: 0, 2: 0 },
currentBreak: 0,
snookerTarget: 'red',
ballInHand: true,
isBreakShot: drill === 'standard',
foulMessage: null,
bannerMessage:
matchType === 'practice'
? 'Practice Mode Active: Drag cue ball anywhere, test spin & undo shots freely!'
: `Break Shot! ${p1.name} to break.`,
winner: null,
winReason: '',
shotSequenceId: 1,
player1: p1,
player2: p2,
});
setActiveTab('game');
};
// ==========================================================================
// 12. EXECUTE SHOT & AUTHORITATIVE POST-SHOT RESOLUTION
// ==========================================================================
const canLocalPlayerShoot = useMemo(() => {
if (isMoving || gameState.winner) return false;
if (gameState.matchType === 'practice' || gameState.matchType === 'pass_play') {
return true;
}
if (gameState.matchType === 'ai') {
return gameState.currentTurn === 1;
}
if (gameState.matchType === 'online') {
const myTurnNum =
gameState.player1?.uid === user?.uid
? 1
: gameState.player2?.uid === user?.uid
? 2
: 0;
return gameState.currentTurn === myTurnNum;
}
return false;
}, [isMoving, gameState, user]);
const executeShot = useCallback(
async (angleToUse, powerPercent, spinXVal, spinYVal) => {
if (isMovingRef.current || gameStateRef.current.winner) return;
const currentGS = gameStateRef.current;
const currentBalls = ballsRef.current.map((b) => ({ ...b }));
const cueBall = currentBalls.find((b) => b.id === 0 && !b.potted);
if (!cueBall) return;
// Save snapshot for Practice Mode Undo
if (currentGS.matchType === 'practice') {
setPracticeHistory((prev) => [
...prev.slice(-9),
{
balls: currentBalls.map((b) => ({ ...b })),
scores: { ...currentGS.scores },
currentBreak: currentGS.currentBreak,
snookerTarget: currentGS.snookerTarget,
playerGroups: { ...currentGS.playerGroups },
},
]);
}
// Reset shot tracker
shotTrackerRef.current = {
firstContactBall: null,
pottedBalls: [],
cushionHitsAfterContact: 0,
totalCushionHits: 0,
};
const maxSpeed = 24.5;
const speed = Math.max(1.2, (powerPercent / 100) * maxSpeed);
const preBallsSnapshot = currentBalls.map((b) => ({ ...b }));
cueBall.vx = Math.cos(angleToUse) * speed;
cueBall.vy = Math.sin(angleToUse) * speed;
cueBall.spinX = spinXVal;
cueBall.spinY = spinYVal;
ballsRef.current = currentBalls;
isMovingRef.current = true;
setIsMoving(true);
setShotPower(0);
setIsPullingCue(false);
soundFX.playCueStrike(powerPercent / 100);
// If Online Match, broadcast the live shot vector immediately so opponent sees real-time animation
if (currentGS.matchType === 'online' && activeRoomId && user) {
const nextSeq = (currentGS.shotSequenceId || 1) + 1;
lastProcessedSeqRef.current = nextSeq;
try {
await updateDoc(
doc(db, 'artifacts', appId, 'public', 'data', 'matches', activeRoomId),
{
lastShot: {
seq: nextSeq,
shooterUid: user.uid,
angle: angleToUse,
power: powerPercent,
spinX: spinXVal,
spinY: spinYVal,
preBalls: preBallsSnapshot,
},
updatedAt: Date.now(),
}
);
} catch (err) {
console.error('Failed to broadcast live shot:', err);
}
}
},
[activeRoomId, user]
);
// Trigger AI Bot Shot automatically when it is Player 2 (AI)'s turn
useEffect(() => {
if (
activeTab === 'game' &&
gameState.matchType === 'ai' &&
gameState.currentTurn === 2 &&
!isMoving &&
!gameState.winner
) {
const timer = setTimeout(() => {
if (isMovingRef.current || gameStateRef.current.winner) return;
// If AI has ball-in-hand, place cue ball in an advantageous spot first
if (gameStateRef.current.ballInHand) {
const cue = ballsRef.current.find((b) => b.id === 0);
if (cue) {
cue.potted = false;
cue.x = HEAD_STRING_X - 25;
cue.y = TABLE_HEIGHT / 2 + (Math.random() - 0.5) * 70;
}
}
const aiShot = calculateAIShot(
{ ...gameStateRef.current, balls: ballsRef.current },
gameStateRef.current.player2?.aiSkill || 'pro'
);
setAimAngle(aiShot.angle);
setSpinOffset({ x: aiShot.spinX, y: aiShot.spinY });
setTimeout(() => {
executeShot(
aiShot.angle,
(aiShot.power / 24.5) * 100,
aiShot.spinX,
aiShot.spinY
);
}, 650);
}, 1100);
return () => clearTimeout(timer);
}
}, [activeTab, gameState.matchType, gameState.currentTurn, isMoving, gameState.winner, executeShot]);
// Turn Countdown Timer
useEffect(() => {
if (
activeTab !== 'game' ||
isMoving ||
gameState.winner ||
!gameState.turnLimit ||
gameState.matchType === 'practice'
) {
return;
}
const interval = setInterval(() => {
setGameState((prev) => {
if (prev.turnTimeLeft <= 1) {
// Turn Timeout Foul!
soundFX.playFoul();
const nextTurn = prev.currentTurn === 1 ? 2 : 1;
return {
...prev,
currentTurn: nextTurn,
turnTimeLeft: prev.turnLimit,
ballInHand: true,
foulMessage: `Shot Clock Expired! Ball-in-Hand awarded to Player ${nextTurn}.`,
bannerMessage: `Time Foul! Player ${nextTurn} has Ball-in-Hand.`,
};
}
return { ...prev, turnTimeLeft: prev.turnTimeLeft - 1 };
});
}, 1000);
return () => clearInterval(interval);
}, [activeTab, isMoving, gameState.winner, gameState.turnLimit, gameState.matchType, gameState.currentTurn]);
// ==========================================================================
// 13. RECORD VERIFIED MATCH OUTCOME, ELO RATING & PROGRESSION
// ==========================================================================
const recordMatchOutcome = useCallback(
async (finalState) => {
if (!user || finalState.matchType === 'practice') return;
const myPlayerNum =
finalState.matchType === 'online'
? finalState.player1?.uid === user.uid
? 1
: 2
: 1;
const didWin = finalState.winner === myPlayerNum;
const opponent = myPlayerNum === 1 ? finalState.player2 : finalState.player1;
const opponentElo = opponent?.elo || 1200;
// Standard Elo Calculation (K = 32)
const expectedScore = 1 / (1 + Math.pow(10, (opponentElo - profile.elo) / 400));
const eloDelta = finalState.ranked
? Math.round(32 * ((didWin ? 1 : 0) - expectedScore))
: 0;
const newElo = Math.max(800, profile.elo + eloDelta);
const xpGain = didWin ? 120 : 45;
const coinGain = didWin ? 85 : 20;
const newXp = (profile.xp || 0) + xpGain;
const newLevel = Math.floor(newXp / 300) + 1;
const modeKey = finalState.mode || '8ball';
const prevModeStat = profile.modeStats?.[modeKey] || { wins: 0, losses: 0 };
const historyEntry = {
id: `${Date.now()}`,
mode: finalState.mode,
opponentName: opponent?.name || 'Opponent',
result: didWin ? 'WIN' : 'LOSS',
score: `${finalState.scores[myPlayerNum]} - ${finalState.scores[myPlayerNum === 1 ? 2 : 1]}`,
eloDelta,
timestamp: Date.now(),
};
const updatedProfile = {
...profile,
elo: newElo,
xp: newXp,
level: newLevel,
coins: (profile.coins || 0) + coinGain,
wins: (profile.wins || 0) + (didWin ? 1 : 0),
losses: (profile.losses || 0) + (didWin ? 0 : 1),
streak: didWin ? (profile.streak || 0) + 1 : 0,
bestSnookerBreak: Math.max(
profile.bestSnookerBreak || 0,
finalState.mode === 'snooker' ? finalState.currentBreak || 0 : 0
),
modeStats: {
...(profile.modeStats || {}),
[modeKey]: {
wins: prevModeStat.wins + (didWin ? 1 : 0),
losses: prevModeStat.losses + (didWin ? 0 : 1),
},
},
matchHistory: [historyEntry, ...(profile.matchHistory || []).slice(0, 14)],
lastSeen: Date.now(),
};
setProfile(updatedProfile);
try {
await setDoc(
doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid),
updatedProfile
);
} catch (err) {
console.error('Error saving match stats:', err);
}
},
[user, profile]
);
// ==========================================================================
// 14. MAIN 60FPS CANVAS RENDERING & PHYSICS ANIMATION LOOP
// ==========================================================================
useEffect(() => {
if (activeTab !== 'game') return;
let animationFrameId;
const renderLoop = () => {
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
// 1. Step Physics if balls are moving
if (isMovingRef.current) {
const stillMoving = stepPhysicsSimulation(
ballsRef.current,
shotTrackerRef.current,
true
);
if (!stillMoving) {
isMovingRef.current = false;
setIsMoving(false);
// Evaluate rules after all balls come to rest
const prevGS = gameStateRef.current;
const postEval = evaluateShotRules(
prevGS,
ballsRef.current,
shotTrackerRef.current
);
// In Practice mode, never lock out turns or end the session unless all balls cleared
if (prevGS.matchType === 'practice') {
const cue = postEval.balls.find((b) => b.id === 0);
if (cue && cue.potted) {
cue.potted = false;
cue.x = HEAD_STRING_X - 35;
cue.y = TABLE_HEIGHT / 2;
}
ballsRef.current = postEval.balls;
setGameState((prev) => ({
...prev,
balls: postEval.balls,
scores: postEval.scores,
currentBreak: postEval.currentBreak,
snookerTarget: postEval.snookerTarget,
foulMessage: postEval.foulMessage,
bannerMessage:
postEval.foulMessage ||
postEval.bannerMessage ||
'Practice Mode — Take your next shot or drag the cue ball.',
isBreakShot: false,
}));
} else {
ballsRef.current = postEval.balls;
const nextSeq = (prevGS.shotSequenceId || 1) + 1;
const updatedGS = {
...prevGS,
...postEval,
turnTimeLeft: prevGS.turnLimit,
shotSequenceId: nextSeq,
};
if (postEval.foulMessage) {
soundFX.playFoul();
}
if (postEval.winner) {
soundFX.playWinFanfare();
recordMatchOutcome(updatedGS);
}
setGameState(updatedGS);
// Sync authoritative post-shot state to Firestore if Online Match
if (prevGS.matchType === 'online' && activeRoomId && user) {
lastProcessedSeqRef.current = nextSeq;
updateDoc(
doc(db, 'artifacts', appId, 'public', 'data', 'matches', activeRoomId),
{
gameStateJson: JSON.stringify(updatedGS),
status: postEval.winner ? 'completed' : 'playing',
updatedAt: Date.now(),
}
).catch((e) => console.error('Failed to sync post-shot state:', e));
}
}
}
}
// 2. Render Table, Cushions, Pockets, Markings, Balls, Aiming Guide & Cue Stick
drawBilliardsTable(ctx);
}
animationFrameId = requestAnimationFrame(renderLoop);
};
const drawBilliardsTable = (ctx) => {
const theme =
TABLE_THEMES.find((t) => t.id === profile.equippedTheme) || TABLE_THEMES[0];
const equippedCueObj =
CUE_CATALOG.find((c) => c.id === profile.equippedCue) || CUE_CATALOG[0];
ctx.clearRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT);
// A. Outer Hardwood Rails
const railGrad = ctx.createLinearGradient(0, 0, TABLE_WIDTH, TABLE_HEIGHT);
railGrad.addColorStop(0, theme.railColor);
railGrad.addColorStop(0.5, '#271206');
railGrad.addColorStop(1, theme.railColor);
ctx.fillStyle = railGrad;
ctx.beginPath();
ctx.roundRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT, 24);
ctx.fill();
// Gold / Chrome Outer Trim
ctx.strokeStyle = theme.trimColor;
ctx.lineWidth = 3;
ctx.strokeRect(6, 6, TABLE_WIDTH - 12, TABLE_HEIGHT - 12);
// B. Felt Playing Surface with Radial Lighting
const feltGrad = ctx.radialGradient
? ctx.createRadialGradient(
TABLE_WIDTH / 2,
TABLE_HEIGHT / 2,
40,
TABLE_WIDTH / 2,
TABLE_HEIGHT / 2,
TABLE_WIDTH * 0.65
)
: null;
if (feltGrad) {
feltGrad.addColorStop(0, theme.feltCenter);
feltGrad.addColorStop(1, theme.feltEdge);
ctx.fillStyle = feltGrad;
} else {
ctx.fillStyle = theme.feltCenter;
}
ctx.fillRect(PLAY_LEFT - 8, PLAY_TOP - 8, PLAY_W + 16, PLAY_H + 16);
// Cushion Rubber Bevels & Shadow
ctx.strokeStyle = 'rgba(0,0,0,0.42)';
ctx.lineWidth = 8;
ctx.strokeRect(PLAY_LEFT, PLAY_TOP, PLAY_W, PLAY_H);
// Diamond Sights on Rails
ctx.fillStyle = '#E2E8F0';
for (let i = 1; i <= 7; i++) {
if (i === 4) continue; // Center pocket
const dx = PLAY_LEFT + (PLAY_W / 8) * i;
ctx.beginPath();
ctx.arc(dx, RAIL_SIZE / 2, 3, 0, Math.PI * 2);
ctx.arc(dx, TABLE_HEIGHT - RAIL_SIZE / 2, 3, 0, Math.PI * 2);
ctx.fill();
}
for (let j = 1; j <= 3; j++) {
const dy = PLAY_TOP + (PLAY_H / 4) * j;
ctx.beginPath();
ctx.arc(RAIL_SIZE / 2, dy, 3, 0, Math.PI * 2);
ctx.arc(TABLE_WIDTH - RAIL_SIZE / 2, dy, 3, 0, Math.PI * 2);
ctx.fill();
}
// C. Table Markings (Baulk Line, Snooker D-Zone, Foot Spot)
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(HEAD_STRING_X, PLAY_TOP);
ctx.lineTo(HEAD_STRING_X, PLAY_BOTTOM);
ctx.stroke();
if (gameStateRef.current.mode === 'snooker') {
// Draw the Snooker "D" Semicircle
ctx.beginPath();
ctx.arc(
HEAD_STRING_X,
TABLE_HEIGHT / 2,
SNOOKER_D_RADIUS,
Math.PI * 0.5,
Math.PI * 1.5
);
ctx.stroke();
// Color Spot Crosses
SNOOKER_COLORS_CONFIG.forEach((sc) => {
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.beginPath();
ctx.arc(sc.spotX, sc.spotY, 2.5, 0, Math.PI * 2);
ctx.fill();
});
} else {
// Foot spot dot
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.arc(FOOT_SPOT_X, FOOT_SPOT_Y, 3, 0, Math.PI * 2);
ctx.fill();
}
// D. Render Pockets
POCKETS.forEach((pk) => {
// Brass/Chrome casting rim
ctx.beginPath();
ctx.arc(pk.x, pk.y, pk.radius + 5, 0, Math.PI * 2);
ctx.fillStyle = '#1E293B';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = theme.trimColor;
ctx.stroke();
// Deep pocket well
const pkGrad = ctx.createRadialGradient(
pk.x,
pk.y,
2,
pk.x,
pk.y,
pk.radius
);
pkGrad.addColorStop(0, '#020617');
pkGrad.addColorStop(1, '#0F172A');
ctx.beginPath();
ctx.arc(pk.x, pk.y, pk.radius, 0, Math.PI * 2);
ctx.fillStyle = pkGrad;
ctx.fill();
});
// E. Render Balls
const currentBalls = ballsRef.current;
const gs = gameStateRef.current;
// Highlight lowest ball in 9-Ball or legal group balls
let lowest9BallId = null;
if (gs.mode === '9ball') {
const rem = currentBalls.filter((b) => !b.potted && b.id > 0);
if (rem.length > 0) lowest9BallId = Math.min(...rem.map((b) => b.id));
}
currentBalls.forEach((b) => {
if (b.potted) return;
// Subtle pulse ring around the required 9-ball lowest target
if (gs.mode === '9ball' && b.id === lowest9BallId && !isMovingRef.current) {
ctx.save();
ctx.strokeStyle = 'rgba(250, 204, 21, 0.75)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius + 5, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// Drop shadow under ball
ctx.beginPath();
ctx.arc(b.x + 2.5, b.y + 3, b.radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 0, 0, 0.38)';
ctx.fill();
// Base Ball Sphere
ctx.save();
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.clip();
if (b.isStripe) {
// Ivory base with vibrant colored stripe band
ctx.fillStyle = '#F8FAFC';
ctx.fillRect(b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
ctx.fillStyle = b.color;
const bandOffset = Math.sin(b.rollPhase || 0) * 2.5;
ctx.fillRect(
b.x - b.radius,
b.y - b.radius * 0.55 + bandOffset,
b.radius * 2,
b.radius * 1.1
);
} else {
ctx.fillStyle = b.color;
ctx.fillRect(b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
}
// Number circle for pool balls
if (b.label && gs.mode !== 'snooker') {
ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius * 0.46, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#0F172A';
ctx.font = `bold ${Math.round(b.radius * 0.62)}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(b.label, b.x, b.y + 0.5);
}
// Red dot on cue ball to visualize spin & roll
if (b.id === 0) {
const dotX = b.x + Math.cos(b.rollPhase || 0) * (b.radius * 0.35);
const dotY = b.y + Math.sin(b.rollPhase || 0) * (b.radius * 0.35);
ctx.fillStyle = '#EF4444';
ctx.beginPath();
ctx.arc(dotX, dotY, 2.2, 0, Math.PI * 2);
ctx.fill();
// Ball-in-hand placement halo
if ((gs.ballInHand || gs.matchType === 'practice') && !isMovingRef.current) {
ctx.strokeStyle = 'rgba(56, 189, 248, 0.8)';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius - 1, 0, Math.PI * 2);
ctx.stroke();
}
}
// 3D Glossy Specular Highlight
const shineGrad = ctx.createRadialGradient(
b.x - b.radius * 0.35,
b.y - b.radius * 0.35,
1,
b.x - b.radius * 0.2,
b.y - b.radius * 0.2,
b.radius
);
shineGrad.addColorStop(0, 'rgba(255, 255, 255, 0.65)');
shineGrad.addColorStop(0.35, 'rgba(255, 255, 255, 0.12)');
shineGrad.addColorStop(1, 'rgba(0, 0, 0, 0.28)');
ctx.fillStyle = shineGrad;
ctx.fillRect(b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
ctx.restore();
});
// F. Render Aiming Raycast Line, Ghost Ball & Cue Stick when stationary
const cueBall = currentBalls.find((b) => b.id === 0 && !b.potted);
if (cueBall && !isMovingRef.current && !gs.winner && canLocalPlayerShoot) {
const ray = computeAimRaycast(
currentBalls,
aimAngle,
spinOffset.x,
spinOffset.y
);
if (ray && showTrajectoryGuide) {
const isLegalHit =
ray.type === 'ball' ? isBallLegalTarget(ray.hitBall, gs) : true;
// Primary Aiming Line from Cue Ball to Ghost Ball
ctx.save();
ctx.beginPath();
ctx.moveTo(cueBall.x, cueBall.y);
ctx.lineTo(ray.ghostX, ray.ghostY);
ctx.strokeStyle = isLegalHit
? equippedCueObj.trailColor
: 'rgba(239, 68, 68, 0.85)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 5]);
ctx.stroke();
// Ghost Ball Circle
ctx.beginPath();
ctx.arc(ray.ghostX, ray.ghostY, cueBall.radius, 0, Math.PI * 2);
ctx.strokeStyle = isLegalHit ? '#38BDF8' : '#EF4444';
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.stroke();
ctx.fillStyle = isLegalHit
? 'rgba(56, 189, 248, 0.18)'
: 'rgba(239, 68, 68, 0.25)';
ctx.fill();
// Object Ball Target Line & Cue Ball Deflection Tangent
if (ray.type === 'ball' && ray.hitBall) {
const guideLen = gs.matchType === 'practice' ? 160 : 85;
ctx.beginPath();
ctx.moveTo(ray.hitBall.x, ray.hitBall.y);
ctx.lineTo(
ray.hitBall.x + ray.targetDirX * guideLen,
ray.hitBall.y + ray.targetDirY * guideLen
);
ctx.strokeStyle = isLegalHit
? 'rgba(34, 197, 94, 0.9)'
: 'rgba(239, 68, 68, 0.9)';
ctx.lineWidth = 2.5;
ctx.stroke();
// Cue Ball Post-Impact Tangent (affected by Topspin/Backspin!)
ctx.beginPath();
ctx.moveTo(ray.ghostX, ray.ghostY);
ctx.lineTo(
ray.ghostX + ray.cueDeflectX * (guideLen * 0.65),
ray.ghostY + ray.cueDeflectY * (guideLen * 0.65)
);
ctx.strokeStyle = 'rgba(250, 204, 21, 0.75)';
ctx.lineWidth = 1.8;
ctx.setLineDash([4, 4]);
ctx.stroke();
}
ctx.restore();
}
// Render Animated Cue Stick behind the Cue Ball
ctx.save();
ctx.translate(cueBall.x, cueBall.y);
ctx.rotate(aimAngle + Math.PI);
const pullBackDist = 18 + (shotPower / 100) * 68;
const cueLength = 250;
// Cue Shadow
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(pullBackDist, 3, cueLength, 6);
// Brass Ferrule & Blue Chalk Tip
ctx.fillStyle = '#38BDF8';
ctx.fillRect(pullBackDist, -2.5, 4, 5);
ctx.fillStyle = '#FACC15';
ctx.fillRect(pullBackDist + 4, -2.5, 5, 5);
// Shaft (Maple / Carbon)
ctx.fillStyle = equippedCueObj.shaftColor;
ctx.beginPath();
ctx.moveTo(pullBackDist + 9, -2.5);
ctx.lineTo(pullBackDist + cueLength * 0.55, -4);
ctx.lineTo(pullBackDist + cueLength * 0.55, 4);
ctx.lineTo(pullBackDist + 9, 2.5);
ctx.closePath();
ctx.fill();
// Butt & Decorative Wrap
ctx.fillStyle = equippedCueObj.buttColor;
ctx.beginPath();
ctx.moveTo(pullBackDist + cueLength * 0.55, -4);
ctx.lineTo(pullBackDist + cueLength, -6);
ctx.lineTo(pullBackDist + cueLength, 6);
ctx.lineTo(pullBackDist + cueLength * 0.55, 4);
ctx.closePath();
ctx.fill();
// Accent Rings
ctx.fillStyle = equippedCueObj.accentColor;
ctx.fillRect(pullBackDist + cueLength * 0.55, -4, 6, 8);
ctx.fillRect(pullBackDist + cueLength - 10, -6, 6, 12);
ctx.restore();
}
};
animationFrameId = requestAnimationFrame(renderLoop);
return () => cancelAnimationFrame(animationFrameId);
}, [
activeTab,
aimAngle,
shotPower,
spinOffset,
showTrajectoryGuide,
canLocalPlayerShoot,
profile.equippedTheme,
profile.equippedCue,
activeRoomId,
user,
recordMatchOutcome,
]);
// ==========================================================================
// 15. CANVAS MOUSE / TOUCH INPUT (AIMING & BALL-IN-HAND PLACEMENT)
// ==========================================================================
const getCanvasLogicalCoords = (e) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const scaleX = TABLE_WIDTH / rect.width;
const scaleY = TABLE_HEIGHT / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY,
};
};
const handleCanvasPointerDown = (e) => {
if (!canLocalPlayerShoot || isMovingRef.current) return;
const pt = getCanvasLogicalCoords(e);
const cueBall = ballsRef.current.find((b) => b.id === 0 && !b.potted);
if (!cueBall) return;
const distToCue = Math.hypot(pt.x - cueBall.x, pt.y - cueBall.y);
const canMoveCueBall =
gameState.ballInHand || gameState.matchType === 'practice';
if (canMoveCueBall && distToCue < cueBall.radius * 2.4) {
setIsDraggingCueBall(true);
return;
}
// Aim towards clicked/touched point
const newAngle = Math.atan2(pt.y - cueBall.y, pt.x - cueBall.x);
setAimAngle(newAngle);
};
const handleCanvasPointerMove = (e) => {
if (!canLocalPlayerShoot || isMovingRef.current) return;
const pt = getCanvasLogicalCoords(e);
const cueBall = ballsRef.current.find((b) => b.id === 0 && !b.potted);
if (!cueBall) return;
if (isDraggingCueBall) {
const r = cueBall.radius;
let nx = Math.max(PLAY_LEFT + r, Math.min(PLAY_RIGHT - r, pt.x));
let ny = Math.max(PLAY_TOP + r, Math.min(PLAY_BOTTOM - r, pt.y));
// Enforce behind head-string on break shot, or inside D-zone in Snooker
if (gameState.matchType !== 'practice') {
if (gameState.mode === 'snooker') {
nx = Math.min(nx, HEAD_STRING_X);
const distFromDCenter = Math.hypot(nx - HEAD_STRING_X, ny - TABLE_HEIGHT / 2);
if (distFromDCenter > SNOOKER_D_RADIUS) {
const ang = Math.atan2(ny - TABLE_HEIGHT / 2, nx - HEAD_STRING_X);
nx = HEAD_STRING_X + Math.cos(ang) * SNOOKER_D_RADIUS;
ny = TABLE_HEIGHT / 2 + Math.sin(ang) * SNOOKER_D_RADIUS;
}
} else if (gameState.isBreakShot) {
nx = Math.min(nx, HEAD_STRING_X);
}
}
// Check collision with other balls
const overlapsOther = ballsRef.current.some(
(ob) =>
ob.id !== 0 &&
!ob.potted &&
Math.hypot(ob.x - nx, ob.y - ny) < ob.radius + r + 2
);
if (!overlapsOther) {
cueBall.x = nx;
cueBall.y = ny;
}
return;
}
// Only update aim on drag if mouse button is pressed or touch event
if (e.buttons === 1 || e.touches) {
const newAngle = Math.atan2(pt.y - cueBall.y, pt.x - cueBall.x);
setAimAngle(newAngle);
}
};
const handleCanvasPointerUp = () => {
if (isDraggingCueBall) {
setIsDraggingCueBall(false);
}
};
// Undo last shot in Practice Mode
const handlePracticeUndo = () => {
if (gameState.matchType !== 'practice' || practiceHistory.length === 0 || isMoving) return;
const lastSnap = practiceHistory[practiceHistory.length - 1];
ballsRef.current = lastSnap.balls.map((b) => ({ ...b }));
setPracticeHistory((prev) => prev.slice(0, -1));
setGameState((prev) => ({
...prev,
balls: lastSnap.balls,
scores: lastSnap.scores,
currentBreak: lastSnap.currentBreak,
snookerTarget: lastSnap.snookerTarget,
playerGroups: lastSnap.playerGroups,
bannerMessage: 'Shot Undone! Ready to retry.',
}));
};
// Buy or Equip Cosmetic Item
const handleCueAction = async (cueItem) => {
const isOwned = profile.ownedCues?.includes(cueItem.id);
if (isOwned) {
const updated = { ...profile, equippedCue: cueItem.id };
setProfile(updated);
if (user) {
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated);
}
showToast(`Equipped ${cueItem.name}!`);
return;
}
if ((profile.coins || 0) < cueItem.cost) {
showToast(`Not enough coins! Need ${cueItem.cost} coins.`);
return;
}
const updated = {
...profile,
coins: profile.coins - cueItem.cost,
ownedCues: [...(profile.ownedCues || []), cueItem.id],
equippedCue: cueItem.id,
};
setProfile(updated);
if (user) {
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated);
}
showToast(`Unlocked & Equipped ${cueItem.name}!`);
};
const handleThemeAction = async (themeItem) => {
const isOwned = profile.ownedThemes?.includes(themeItem.id);
if (isOwned) {
const updated = { ...profile, equippedTheme: themeItem.id };
setProfile(updated);
if (user) {
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated);
}
showToast(`Applied ${themeItem.name}!`);
return;
}
if ((profile.coins || 0) < themeItem.cost) {
showToast(`Not enough coins! Need ${themeItem.cost} coins.`);
return;
}
const updated = {
...profile,
coins: profile.coins - themeItem.cost,
ownedThemes: [...(profile.ownedThemes || []), themeItem.id],
equippedTheme: themeItem.id,
};
setProfile(updated);
if (user) {
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated);
}
showToast(`Unlocked & Applied ${themeItem.name}!`);
};
const handleSaveProfileIdentity = async () => {
if (!tempUsername.trim() || !user) return;
const updated = {
...profile,
username: tempUsername.trim().slice(0, 18),
avatar: tempAvatar,
isGuest: false,
};
setProfile(updated);
setEditingProfile(false);
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated);
showToast('Player profile saved to cloud!');
};
const rankBadge = getRankTier(profile.elo);
// ==========================================================================
// 16. RENDER UI: TOP NAVIGATION, LOBBY, GAME HUD, SHOP, LEADERBOARDS
// ==========================================================================
return (
{/* Top Club Header Bar */}
{/* Mobile Bottom/Sub Navigation Bar */}
{[
{ id: 'lobby', label: 'Lobby', icon: Globe },
{ id: 'game', label: 'Table', icon: Play },
{ id: 'leaderboards', label: 'Ranks', icon: Trophy },
{ id: 'shop', label: 'Cues', icon: Sparkles },
{ id: 'profile', label: 'Stats', icon: User },
].map((item) => {
const Icon = item.icon;
return (
setActiveTab(item.id)}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold ${
activeTab === item.id ? 'bg-emerald-600 text-white' : 'text-slate-400'
}`}
>
{item.label}
);
})}
{/* Floating Toast Notification */}
{toastMessage && (
{toastMessage}
)}
{/* ==================================================================== */}
{/* TAB 1: MULTIPLAYER LOBBY & MODE SELECTOR */}
{/* ==================================================================== */}
{activeTab === 'lobby' && (
{/* Active Room Waiting Banner */}
{activeRoomId && roomData && roomData.status === 'waiting' && (
Waiting for Opponent
Mode: {roomData.mode.toUpperCase()} • {roomData.ranked ? 'Ranked' : 'Casual'}
Room Code:{' '}
{activeRoomId}
Share this code with a friend or wait for an online player to join from the lobby.
{
navigator.clipboard?.writeText(activeRoomId);
setCopiedCode(true);
setTimeout(() => setCopiedCode(false), 2000);
}}
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-white border border-slate-700 transition"
>
{copiedCode ? : }
{copiedCode ? 'Copied!' : 'Copy Code'}
startLocalMatch('ai', roomData.mode)}
className="px-3.5 py-2 rounded-xl bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 text-xs font-semibold transition"
>
Play vs AI Bot Instead [AI]
Cancel Room
)}
{/* Game Mode Selection Cards (8-Ball, 9-Ball, Snooker) */}
{[
{
id: '8ball',
title: '8-Ball Pool',
subtitle: 'Solids vs Stripes Championship',
badge: 'Most Popular',
desc: 'Standard 15-ball rack. Call your group after the break, clear your balls, and legally sink the 8-Ball to win.',
gradient: 'from-emerald-600/25 via-slate-900 to-slate-950',
border: 'border-emerald-500/40',
ballIcon: '8',
ballColor: 'bg-slate-950 text-white border-2 border-white/80',
},
{
id: '9ball',
title: '9-Ball Pool',
subtitle: 'Fast-Paced Diamond Rack',
badge: 'Pro Circuit',
desc: 'Balls 1–9. Always strike the lowest-numbered ball on the table first. Sink the 9-Ball on any legal shot to triumph!',
gradient: 'from-amber-600/25 via-slate-900 to-slate-950',
border: 'border-amber-500/40',
ballIcon: '9',
ballColor: 'bg-amber-400 text-slate-950 border-2 border-white',
},
{
id: 'snooker',
title: 'Championship Snooker',
subtitle: '15 Reds & 6 Colors Frame',
badge: '147 Max Break',
desc: 'Full 22-ball Crucible rules. Alternate Red (1 pt) and Colors (2–7 pts), build massive breaks, and clear the colors in order.',
gradient: 'from-rose-600/25 via-slate-900 to-slate-950',
border: 'border-rose-500/40',
ballIcon: '147',
ballColor: 'bg-rose-600 text-white border-2 border-rose-300',
},
].map((m) => {
const isSelected = selectedMode === m.id;
return (
setSelectedMode(m.id)}
className={`relative rounded-2xl p-5 cursor-pointer transition-all bg-gradient-to-br ${
m.gradient
} border-2 ${
isSelected
? `${m.border} shadow-xl scale-[1.01]`
: 'border-slate-800/80 opacity-80 hover:opacity-100'
}`}
>
{m.badge}
{m.title}
{m.subtitle}
{m.ballIcon}
{m.desc}
Your Record:{' '}
{profile.modeStats?.[m.id]?.wins || 0}W - {profile.modeStats?.[m.id]?.losses || 0}L
{isSelected ? '✓ Selected Mode' : 'Click to Select'}
);
})}
{/* Matchmaking & Mode Launch Panel */}
{/* Left 7 Cols: Quick Match, AI Match, Practice & Private Room Creator */}
Play {selectedMode === '8ball' ? '8-Ball Pool' : selectedMode === '9ball' ? '9-Ball Pool' : 'Snooker'}
Choose real-time online multiplayer, private friend room, AI bot challenge, or solo practice.
{/* Match Settings Toggles */}
setSelectedRanked(!selectedRanked)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition ${
selectedRanked
? 'bg-amber-500/20 border-amber-500/50 text-amber-300'
: 'bg-slate-800 border-slate-700 text-slate-400'
}`}
>
{selectedRanked ? '🏆 Ranked (Elo)' : 'Casual Match'}
setSelectedTurnTime(Number(e.target.value))}
className="bg-slate-900 border border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-200 font-semibold"
>
⏱️ 30s Clock
⏱️ 45s Clock
⏱️ 60s Clock
{/* Action Grid */}
{/* 1. Online Quick Match */}
Online Multiplayer
Quick Match Queue
Automatically pairs you with a waiting real player online in synchronized rooms.
startMatchmaking(selectedMode, selectedRanked)}
className="mt-4 w-full py-2.5 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white font-bold text-xs shadow-lg shadow-emerald-950/60 flex items-center justify-center gap-2 transition"
>
Find Online Match
{/* 2. Create or Join Private Friend Room */}
createOnlineRoom(selectedMode, selectedRanked, selectedTurnTime, false)}
className="mt-3 w-full py-2 rounded-xl bg-sky-500/20 hover:bg-sky-500/30 border border-sky-500/40 text-sky-300 font-bold text-xs flex items-center justify-center gap-1.5 transition"
>
Create Room & Get Code
{/* 3. Play vs AI Bot (Clearly Labeled as AI) */}
Single Player vs AI [BOT]
AI Championship Bot
{['rookie', 'pro', 'grandmaster'].map((lvl) => (
setSelectedAiSkill(lvl)}
className={`flex-1 py-1 rounded-lg text-[11px] font-bold capitalize border transition ${
selectedAiSkill === lvl
? 'bg-amber-500/25 border-amber-400 text-amber-200'
: 'bg-slate-950 border-slate-800 text-slate-400'
}`}
>
{lvl}
))}
startLocalMatch('ai', selectedMode)}
className="mt-3 w-full py-2.5 rounded-xl bg-amber-600 hover:bg-amber-500 text-slate-950 font-extrabold text-xs flex items-center justify-center gap-1.5 transition"
>
Start vs {selectedAiSkill.toUpperCase()} AI [BOT]
{/* 4. Practice Mode & Pass-and-Play */}
Training & Local 2P
Solo Practice & Local
Unlimited time, free cue-ball placement, extended trajectory lines & shot undo.
startLocalMatch('practice', selectedMode, 'standard')}
className="py-2 rounded-xl bg-purple-600 hover:bg-purple-500 text-white font-bold text-xs transition"
>
Solo Practice
startLocalMatch('pass_play', selectedMode, 'standard')}
className="py-2 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 font-bold text-xs transition"
>
2P Pass & Play
{/* Right 5 Cols: Live Public Rooms & Online Players Browser */}
Live Club Rooms & Lobby
Join an open table or spectate active matches in real time.
{Math.max(1, allProfiles.length)} Players Registered
{publicRooms.length === 0 ? (
No Open Public Tables Right Now
Click “Create Room & Get Code” or “Find Online Match” to host a live table!
) : (
{publicRooms.map((rm) => (
{rm.player1?.avatar || '🎱'}
{rm.player1?.name || 'Host'}
#{rm.roomId}
{rm.mode?.toUpperCase()} • {rm.ranked ? 'Ranked' : 'Casual'} •{' '}
{rm.status === 'waiting' ? 'Open to Join' : `vs ${rm.player2?.name || 'Player 2'}`}
{rm.status === 'waiting' && rm.player1?.uid !== user?.uid ? (
joinOnlineRoom(rm.roomId)}
className="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold transition"
>
Join Match
) : rm.player1?.uid === user?.uid || rm.player2?.uid === user?.uid ? (
joinOnlineRoom(rm.roomId)}
className="px-3 py-1.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-xs font-bold transition"
>
Resume
) : (
In Progress
)}
))}
)}
Equipped Cue: {CUE_CATALOG.find((c) => c.id === profile.equippedCue)?.name}
setActiveTab('shop')}
className="text-amber-400 hover:underline font-semibold"
>
Customize Table & Cue →
)}
{/* ==================================================================== */}
{/* TAB 2: ACTIVE GAME TABLE, HUD, SPIN WIDGET & POWER SLIDER */}
{/* ==================================================================== */}
{activeTab === 'game' && (
{/* Top Match HUD Bar (Players, Scores, Ball Groups / Snooker Break, Shot Clock) */}
{/* Player 1 Card */}
{gameState.player1?.avatar || '🎱'}
{gameState.player1?.name || 'Player 1'}
{gameState.currentTurn === 1 && !gameState.winner && (
)}
{gameState.mode === '8ball' && (
{gameState.playerGroups[1] || 'Open Table'}
)}
{gameState.mode === 'snooker' && (
Frame Pts: {gameState.scores[1]}
)}
{gameState.mode === '9ball' && (
Balls Potted: {gameState.scores[1]}
)}
{gameState.scores[1]}
{/* Center Match Status, Snooker Break / 9-Ball Target & Shot Timer */}
{gameState.mode === '8ball'
? '8-Ball Pool'
: gameState.mode === '9ball'
? '9-Ball Pool'
: 'Snooker'}
{gameState.matchType === 'online' && activeRoomId && (
Room #{activeRoomId}
)}
{gameState.turnLimit > 0 && gameState.matchType !== 'practice' && !gameState.winner && (
{gameState.turnTimeLeft}s
)}
{/* Mode-Specific Live Rule Target Indicator */}
{gameState.mode === 'snooker' ? (
Ball On:{' '}
{formatSnookerTarget(gameState.snookerTarget)}
{' '}
• Break: {gameState.currentBreak} •
Remaining:{' '}
{calculateSnookerPointsRemaining(gameState.balls, gameState.snookerTarget)}
) : gameState.mode === '9ball' ? (
Lowest Ball On:{' '}
#
{Math.min(
...gameState.balls
.filter((b) => !b.potted && b.id > 0)
.map((b) => b.id),
9
)}{' '}
Ball
) : (
{gameState.bannerMessage}
)}
{/* Player 2 Card */}
{gameState.scores[2]}
{gameState.currentTurn === 2 && !gameState.winner && (
)}
{gameState.player2?.name || 'Player 2'}
{gameState.mode === '8ball' && (
{gameState.playerGroups[2] || 'Open Table'}
)}
{gameState.mode === 'snooker' && (
Frame Pts: {gameState.scores[2]}
)}
{gameState.mode === '9ball' && (
Balls Potted: {gameState.scores[2]}
)}
{gameState.player2?.avatar || '🎯'}
{/* Foul / Status Notification Banner */}
{(gameState.foulMessage || gameState.ballInHand) && !gameState.winner && (
{gameState.foulMessage ||
'Ball-in-Hand Active: Drag the cue ball directly on the table to position it.'}
{gameState.ballInHand ? 'Drag Cue Ball' : 'Rule Notice'}
)}
{/* Middle Section: Interactive Table Canvas + Tactile Cue Power Slider */}
{/* Responsive Billiards Table Canvas Container */}
{/* Winner Overlay Modal inside Table */}
{gameState.winner && (
{gameState.winner === 1
? `${gameState.player1?.name} Wins!`
: `${gameState.player2?.name} Wins!`}
{gameState.winReason}
startLocalMatch(gameState.matchType, gameState.mode)}
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs sm:text-sm flex items-center gap-2 shadow-lg"
>
Play Rematch
setActiveTab('lobby')}
className="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 font-bold text-xs sm:text-sm border border-slate-700"
>
Return to Club Lobby
)}
{/* Right-Side Tactile Cue Power Pull-Back & Shoot Control */}
{/* Cue Ball Spin (English) Selector Button */}
setShowSpinModal(true)}
disabled={!canLocalPlayerShoot}
className="relative w-12 h-12 rounded-full bg-gradient-to-br from-white via-slate-100 to-slate-300 border-2 border-emerald-400 shadow-inner flex items-center justify-center disabled:opacity-40"
title="Adjust Cue Ball Spin (English)"
>
{/* Red Contact Point Dot */}
Spin
{/* Power Slider Bar */}
{/* Direct Shoot Button */}
executeShot(
aimAngle,
shotPower > 5 ? shotPower : 55,
spinOffset.x,
spinOffset.y
)
}
className="px-4 py-2.5 lg:w-full rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 disabled:opacity-40 text-white font-black text-xs shadow-lg transition"
>
SHOOT
{/* Bottom Precision Fine-Aim Controls, Practice Tools & Rack Controls */}
{/* Fine-Aim Micro Adjusters */}
Fine Aim:
setAimAngle((a) => a - 0.025)}
className="px-2.5 py-1.5 rounded-lg bg-slate-900 hover:bg-slate-800 border border-slate-700 text-xs font-bold text-slate-200 disabled:opacity-40"
>
-1.5°
setAimAngle((a) => a - 0.0035)}
className="px-2.5 py-1.5 rounded-lg bg-slate-900 hover:bg-slate-800 border border-slate-700 text-xs font-bold text-emerald-300 disabled:opacity-40"
>
Fine
setAimAngle((a) => a + 0.0035)}
className="px-2.5 py-1.5 rounded-lg bg-slate-900 hover:bg-slate-800 border border-slate-700 text-xs font-bold text-emerald-300 disabled:opacity-40"
>
Fine
setAimAngle((a) => a + 0.025)}
className="px-2.5 py-1.5 rounded-lg bg-slate-900 hover:bg-slate-800 border border-slate-700 text-xs font-bold text-slate-200 disabled:opacity-40"
>
+1.5°
{/* Remaining Object Balls Mini Tray */}
Potted:
{gameState.balls.filter((b) => b.potted && b.id !== 0).length === 0 ? (
None yet
) : (
gameState.balls
.filter((b) => b.potted && b.id !== 0)
.slice(0, 15)
.map((b) => (
{b.label}
))
)}
{/* Right Controls: Aiming Guide Toggle, Practice Undo, Reset / Forfeit */}
setShowTrajectoryGuide(!showTrajectoryGuide)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border flex items-center gap-1.5 transition ${
showTrajectoryGuide
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-300'
: 'bg-slate-900 border-slate-700 text-slate-400'
}`}
>
Aim Guide
{gameState.matchType === 'practice' && (
<>
Undo Shot
startLocalMatch(
'practice',
gameState.mode,
practiceDrill === 'standard' ? 'clearance_drill' : 'standard'
)
}
className="px-3 py-1.5 rounded-xl bg-purple-500/15 hover:bg-purple-500/25 border border-purple-500/40 text-purple-300 text-xs font-bold"
>
{gameState.mode === '8ball' ? 'Drill Rack' : 'Reset Table'}
>
)}
startLocalMatch(gameState.matchType, gameState.mode)}
className="px-3 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 text-xs font-bold flex items-center gap-1"
>
New Rack
)}
{/* ==================================================================== */}
{/* TAB 3: GLOBAL ELO LEADERBOARDS */}
{/* ==================================================================== */}
{activeTab === 'leaderboards' && (
Global Cue Masters Rankings
Real-time Elo leaderboard synced across all registered online players.
Your Rating:
{profile.elo} ELO
{rankBadge.name}
Rank
Player
Tier
Elo Rating
Wins / Losses
High Snooker Break
{allProfiles
.slice()
.sort((a, b) => (b.elo || 1200) - (a.elo || 1200))
.map((p, idx) => {
const tier = getRankTier(p.elo || 1200);
const isMe = p.uid === user?.uid;
return (
{idx === 0 ? '🥇 1' : idx === 1 ? '🥈 2' : idx === 2 ? '🥉 3' : `#${idx + 1}`}
{p.avatar || '🎱'}
{p.username}
{isMe && (
YOU
)}
{tier.name}
{p.elo || 1200}
{p.wins || 0}W – {p.losses || 0}L
{p.bestSnookerBreak || 0} pts
);
})}
)}
{/* ==================================================================== */}
{/* TAB 4: CUSTOMIZATION LOCKER (CUES & TABLE FELT THEMES) */}
{/* ==================================================================== */}
{activeTab === 'shop' && (
Cue & Table Customization Locker
All cosmetics are strictly visual — competitive physics remain 100% fair for every player.
🪙 Balance: {profile.coins || 0} Match Coins
{/* Cues Section */}
Handcrafted Cue Sticks
{CUE_CATALOG.map((cue) => {
const owned = profile.ownedCues?.includes(cue.id);
const equipped = profile.equippedCue === cue.id;
return (
{cue.tier}
{owned ? 'Owned' : `🪙 ${cue.cost}`}
{cue.name}
{/* Visual Preview of Cue Stick */}
handleCueAction(cue)}
className={`w-full py-2 rounded-xl text-xs font-bold transition ${
equipped
? 'bg-emerald-600 text-white'
: owned
? 'bg-slate-800 hover:bg-slate-700 text-slate-200'
: 'bg-amber-500 hover:bg-amber-400 text-slate-950'
}`}
>
{equipped ? '✓ Equipped' : owned ? 'Equip Cue' : `Unlock for 🪙 ${cue.cost}`}
);
})}
{/* Table Felt Themes Section */}
Championship Table Felt Themes
{TABLE_THEMES.map((th) => {
const owned = profile.ownedThemes?.includes(th.id);
const equipped = profile.equippedTheme === th.id;
return (
{th.name}
{owned ? 'Unlocked' : `🪙 ${th.cost} Coins`}
handleThemeAction(th)}
className={`mt-3 w-full py-2 rounded-xl text-xs font-bold transition ${
equipped
? 'bg-sky-600 text-white'
: owned
? 'bg-slate-800 hover:bg-slate-700 text-slate-200'
: 'bg-amber-500 hover:bg-amber-400 text-slate-950'
}`}
>
{equipped ? '✓ Active Felt' : owned ? 'Apply Felt' : `Unlock (🪙 ${th.cost})`}
);
})}
)}
{/* ==================================================================== */}
{/* TAB 5: PLAYER CAREER PROFILE & MATCH HISTORY */}
{/* ==================================================================== */}
{activeTab === 'profile' && (
{profile.avatar}
{profile.username}
{rankBadge.name}
Account ID: {user?.uid || 'Local'}
{
setTempUsername(profile.username);
setTempAvatar(profile.avatar);
setEditingProfile(true);
}}
className="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold transition"
>
Customize Identity
{/* Key Career Metrics */}
{[
{ label: 'Elo Rating', value: profile.elo, color: 'text-emerald-400' },
{
label: 'Win Rate',
value:
profile.wins + profile.losses > 0
? `${Math.round((profile.wins / (profile.wins + profile.losses)) * 100)}%`
: '0%',
color: 'text-sky-400',
},
{ label: 'Total Wins', value: profile.wins || 0, color: 'text-amber-400' },
{
label: 'Best Snooker Break',
value: `${profile.bestSnookerBreak || 0} pts`,
color: 'text-rose-400',
},
].map((stat, i) => (
{stat.label}
{stat.value}
))}
{/* Recent Match History */}
Verified Match History
{(!profile.matchHistory || profile.matchHistory.length === 0) ? (
No completed competitive matches recorded yet. Complete an Online or AI match to log your history!
) : (
{profile.matchHistory.map((mh) => (
{mh.result}
{mh.mode}
vs {mh.opponentName}
Score: {mh.score}
{mh.eloDelta !== 0 && (
0 ? 'text-emerald-400' : 'text-rose-400'
}`}
>
{mh.eloDelta > 0 ? `+${mh.eloDelta}` : mh.eloDelta} ELO
)}
))}
)}
)}
{/* ==================================================================== */}
{/* MODAL A: INTERACTIVE CUE BALL SPIN (ENGLISH) SELECTOR */}
{/* ==================================================================== */}
{showSpinModal && (
Cue Ball Spin (English)
Click or drag on the cue ball face to apply Topspin (Follow), Backspin (Draw/Screw), or Left/Right Side Spin.
{
const rect = e.currentTarget.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
const dx = (e.clientX - rect.left - cx) / (cx * 0.85);
const dy = -(e.clientY - rect.top - cy) / (cy * 0.85);
const mag = Math.hypot(dx, dy);
if (mag > 1) {
setSpinOffset({ x: dx / mag, y: dy / mag });
} else {
setSpinOffset({ x: dx, y: dy });
}
}}
className="relative w-44 h-44 mx-auto rounded-full bg-gradient-to-br from-white via-slate-100 to-slate-400 border-4 border-emerald-500 shadow-2xl cursor-pointer flex items-center justify-center"
>
{/* Crosshairs */}
FOLLOW
DRAW
L
R
{/* Tip Contact Indicator */}
setSpinOffset({ x: 0, y: 0 })}
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-bold text-slate-200"
>
Center Ball
setShowSpinModal(false)}
className="px-5 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-xs font-bold text-white"
>
Confirm Spin
)}
{/* ==================================================================== */}
{/* MODAL B: EDIT PROFILE & ACCOUNT IDENTITY */}
{/* ==================================================================== */}
{editingProfile && (
Player Account Profile
Display Username
setTempUsername(e.target.value)}
maxLength={18}
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3.5 py-2 text-sm text-white focus:outline-none focus:border-emerald-500"
/>
Select Club Avatar
{AVATAR_OPTIONS.map((av) => (
setTempAvatar(av)}
className={`text-2xl py-2 rounded-xl border ${
tempAvatar === av
? 'bg-emerald-500/20 border-emerald-400'
: 'bg-slate-900 border-slate-800'
}`}
>
{av}
))}
setEditingProfile(false)}
className="px-4 py-2 rounded-xl bg-slate-800 text-xs font-bold text-slate-300"
>
Cancel
Save Account
)}
{/* ==================================================================== */}
{/* MODAL C: OFFICIAL RULES & CONTROLS GUIDE */}
{/* ==================================================================== */}
{showRulesModal && (
Cue Masters — Rules & Controls Guide
setShowRulesModal(false)}
className="text-xs font-bold text-slate-400 hover:text-white"
>
✕ Close
🎯 Aiming, Shooting & Spin Controls
• Aiming: Click/touch and drag anywhere on the table to orient the aiming line and ghost ball preview. Use the Fine Aim buttons at the bottom for precision 0.2° micro-adjustments.
• Shooting: Drag the PWR Slider on the right and release to strike, or click the SHOOT button.
• Spin (English): Click the Cue Ball icon above the power slider to apply Topspin (follow), Backspin (draw), or Sidespin.
• Ball-in-Hand: When a foul occurs (or anytime in Practice Mode), drag the cue ball directly on the felt.
🎱 8-Ball, 9-Ball & Snooker Rules Enforced
• 8-Ball Pool: Table is Open after the break until a legal pot assigns Solids (1–7) or Stripes (9–15). Must hit your own group first and either pot a ball or drive a ball to a cushion. Legally sink the 8-ball after clearing your group to win.
• 9-Ball Pool: Always contact the lowest-numbered ball on the table first (highlighted with a gold ring). Legally potting the 9-ball at any point wins the rack.
• Snooker: Alternate potting a Red (1 pt) followed by any Color (2–7 pts). Colors respot while Reds remain. Once all Reds are cleared, pot the colors in ascending order: Yellow (2), Green (3), Brown (4), Blue (5), Pink (6), Black (7).
)}
{/* ==================================================================== */}
{/* MODAL D: SYSTEM STATUS, ARCHITECTURE & DEPLOYMENT REPORT */}
{/* ==================================================================== */}
{showSystemModal && (
System Architecture & Multiplayer Status Report
setShowSystemModal(false)}
className="text-xs font-bold text-slate-400 hover:text-white"
>
✕ Close
Network Transport
{authReady ? 'Connected (Firestore Live)' : 'Initializing...'}
Physics Engine
Deterministic 6x Substep
Audio Engine
Web Audio Synthesizer
1. Real-Time Multiplayer & Anti-Cheat Validation
Matches are synchronized in real time via persistent Firestore document listeners under{' '}
/artifacts/{appId}/public/data/matches/{roomId}.
Each shot increments a monotonic shotSequenceId, preventing out-of-turn or duplicate shot submissions. Both clients execute the identical fixed-substep impulse simulation and commit the verified post-shot state.
2. Standalone Node.js + Socket.IO + MySQL Deployment
To deploy with a dedicated Node.js WebSocket server and MySQL database outside the browser sandbox, set{' '}
VITE_WS_SERVER_URL, DATABASE_URL, and{' '}
JWT_SECRET in your environment and run the physics & rule evaluators (stepPhysicsSimulation and evaluateShotRules) directly inside the Node.js room worker.
)}
);
}