import { useState, useEffect, useRef, useCallback } from "react"; /* ============================================================ CONSTANTS ============================================================ */ const DENOMS = [1, 5, 10, 25, 50, 100, 500, 1000, 5000, 10000]; const MAX_STACK = 10000; const DEFAULT_RATIO = { chips: 100000, rs: 100 }; // 1000 chips = 1 rs const DEFAULT_STACK = 10000; const ROUND_LABELS = { 1: "Round 1 · Pre-flop", 2: "Round 2 · Flop", 3: "Round 3 · Turn", 4: "Round 4 · River", }; const POLL_MS = 1800; const CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; /* ============================================================ UTILITIES ============================================================ */ function genId() { return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4); } function genRoomCode(len = 5) { let out = ""; for (let i = 0; i < len; i++) out += CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]; return out; } function chipsToRs(chips, ratio) { const r = ratio || DEFAULT_RATIO; if (!r.chips) return 0; return (chips * r.rs) / r.chips; } function fmtChips(n) { const v = Number.isFinite(n) ? n : 0; return v.toLocaleString("en-IN"); } function fmtRs(chips, ratio) { const rs = chipsToRs(chips, ratio); return rs.toLocaleString("en-IN", { maximumFractionDigits: 2 }); } /* ============================================================ STORAGE HELPERS (window.storage — never browser localStorage) ============================================================ */ async function loadGame(roomCode) { if (!roomCode) return null; try { const res = await window.storage.get(`game:${roomCode}`, true); return res && res.value ? JSON.parse(res.value) : null; } catch (e) { return null; } } async function saveGame(game) { const toSave = { ...game, updatedAt: Date.now() }; try { await window.storage.set(`game:${toSave.roomCode}`, JSON.stringify(toSave), true); } catch (e) { console.error("Failed to save game", e); } return toSave; } async function deleteGame(roomCode) { try { await window.storage.delete(`game:${roomCode}`, true); } catch (e) { /* ignore */ } } async function loadMySession() { try { const res = await window.storage.get("my-session", false); return res && res.value ? JSON.parse(res.value) : null; } catch (e) { return null; } } async function saveMySession(session) { try { await window.storage.set("my-session", JSON.stringify(session), false); } catch (e) { /* ignore */ } } async function clearMySession() { try { await window.storage.delete("my-session", false); } catch (e) { /* ignore */ } } /* ============================================================ PURE GAME-LOGIC FUNCTIONS ============================================================ */ function defaultConfig() { return { numPlayers: 4, chipMode: "flat", // 'flat' | 'denoms' flatStack: DEFAULT_STACK, denomCombo: [], // [{value,count}] startingStackValue: DEFAULT_STACK, ratio: { ...DEFAULT_RATIO }, preFlopEnabled: true, smallBlind: Math.max(1, Math.round(DEFAULT_STACK / 200)), bigBlind: Math.max(2, Math.round(DEFAULT_STACK / 200) * 2), }; } function buildInitialGame({ roomCode, adminName, config }) { const adminPlayer = { id: genId(), name: adminName.trim(), isAdmin: true, stack: config.startingStackValue, startingStackAtJoin: config.startingStackValue, isDealer: false, isSB: false, isBB: false, isFolded: false, currentBet: 0, position: 0, connected: true, }; return { roomCode, createdAt: Date.now(), config, players: [adminPlayer], status: "lobby", // lobby -> playing -> showdown -> hand-ended -> ended pot: 0, currentRound: 0, turnPlayerId: null, dealerIndex: 0, handNumber: 0, actedThisRound: [], autoWinner: null, handHistory: [], updatedAt: Date.now(), }; } function getNextActivePlayerId(game, fromId) { const players = game.players; const n = players.length; const fromIdx = players.findIndex((p) => p.id === fromId); if (fromIdx === -1) return fromId; for (let i = 1; i <= n; i++) { const idx = (fromIdx + i) % n; if (!players[idx].isFolded) return players[idx].id; } return fromId; } function startHand(game, isFirst) { let players = game.players.map((p) => ({ ...p, isFolded: false, currentBet: 0, isDealer: false, isSB: false, isBB: false, })); const n = players.length; const dealerIndex = isFirst ? Math.floor(Math.random() * n) : (game.dealerIndex + 1) % n; players[dealerIndex].isDealer = true; const { smallBlind, bigBlind, preFlopEnabled } = game.config; let pot = 0; let turnPlayerId; if (preFlopEnabled) { // Heads-up (2 players) is a special case: the dealer posts the small blind // and acts first preflop. 3+ players use the standard SB/BB-after-dealer order. const sbIdx = n === 2 ? dealerIndex : (dealerIndex + 1) % n; const bbIdx = n === 2 ? (dealerIndex + 1) % n : (dealerIndex + 2) % n; players[sbIdx].isSB = true; players[bbIdx].isBB = true; const sbAmt = Math.min(smallBlind, players[sbIdx].stack); players[sbIdx].stack -= sbAmt; players[sbIdx].currentBet = sbAmt; pot += sbAmt; const bbAmt = Math.min(bigBlind, players[bbIdx].stack); players[bbIdx].stack -= bbAmt; players[bbIdx].currentBet += bbAmt; pot += bbAmt; const firstIdx = (bbIdx + 1) % n; turnPlayerId = players[firstIdx].id; } else { players = players.map((p) => { const amt = Math.min(smallBlind, p.stack); return { ...p, stack: p.stack - amt, currentBet: amt }; }); pot = players.reduce((s, p) => s + p.currentBet, 0); const firstIdx = (dealerIndex + 1) % n; turnPlayerId = players[firstIdx].id; } return { ...game, players, pot, currentRound: 1, status: "playing", dealerIndex, turnPlayerId, actedThisRound: [], autoWinner: null, handNumber: game.handNumber + 1, }; } function checkAutoWin(game, players) { const active = players.filter((p) => !p.isFolded); if (active.length === 1) { return { ...game, players, status: "showdown", autoWinner: active[0].id, turnPlayerId: null, }; } return null; } function applyFold(game, playerId) { if (game.turnPlayerId !== playerId) return game; const players = game.players.map((p) => (p.id === playerId ? { ...p, isFolded: true } : p)); const auto = checkAutoWin(game, players); if (auto) return auto; const actedThisRound = Array.from(new Set([...game.actedThisRound, playerId])); const next = { ...game, players, actedThisRound }; next.turnPlayerId = getNextActivePlayerId(next, playerId); return next; } function applyCallCheck(game, playerId) { if (game.turnPlayerId !== playerId) return game; const active = game.players.filter((p) => !p.isFolded); const maxBet = Math.max(0, ...active.map((p) => p.currentBet)); let added = 0; const players = game.players.map((p) => { if (p.id !== playerId) return p; const owe = Math.max(0, Math.min(maxBet - p.currentBet, p.stack)); added = owe; return { ...p, stack: p.stack - owe, currentBet: p.currentBet + owe }; }); const pot = game.pot + added; const actedThisRound = Array.from(new Set([...game.actedThisRound, playerId])); const next = { ...game, players, pot, actedThisRound }; next.turnPlayerId = getNextActivePlayerId(next, playerId); return next; } function applyRaise(game, playerId, raiseToAmount) { if (game.turnPlayerId !== playerId) return game; const player = game.players.find((p) => p.id === playerId); if (!player) return game; const cappedTo = Math.min(raiseToAmount, player.currentBet + player.stack); const addAmt = Math.max(0, cappedTo - player.currentBet); if (addAmt <= 0) return game; const players = game.players.map((p) => p.id === playerId ? { ...p, stack: p.stack - addAmt, currentBet: p.currentBet + addAmt } : p ); const pot = game.pot + addAmt; const next = { ...game, players, pot, actedThisRound: [playerId] }; next.turnPlayerId = getNextActivePlayerId(next, playerId); return next; } function isRoundComplete(game) { const active = game.players.filter((p) => !p.isFolded); if (active.length <= 1) return true; const maxBet = Math.max(0, ...active.map((p) => p.currentBet)); return active.every((p) => (p.currentBet === maxBet || p.stack === 0) && game.actedThisRound.includes(p.id)); } function advanceRound(game) { if (game.currentRound >= 4) return game; const players = game.players.map((p) => ({ ...p, currentBet: 0 })); const nextRound = game.currentRound + 1; const n = players.length; let firstIdx = null; for (let i = 1; i <= n; i++) { const idx = (game.dealerIndex + i) % n; if (!players[idx].isFolded) { firstIdx = idx; break; } } return { ...game, players, currentRound: nextRound, actedThisRound: [], turnPlayerId: players[firstIdx].id, }; } function goToShowdown(game) { return { ...game, status: "showdown", turnPlayerId: null }; } function orderByClockwiseFromDealer(game, ids) { const n = game.players.length; const seq = []; for (let i = 1; i <= n; i++) { const idx = (game.dealerIndex + i) % n; if (ids.includes(game.players[idx].id)) seq.push(game.players[idx].id); } return seq; } function distributePot(game, winnerIds, manualAmounts) { const pot = game.pot; let amounts = {}; if (manualAmounts) { amounts = manualAmounts; } else { const share = Math.floor(pot / winnerIds.length); let remainder = pot - share * winnerIds.length; winnerIds.forEach((id) => (amounts[id] = share)); const order = orderByClockwiseFromDealer(game, winnerIds); let i = 0; while (remainder > 0 && order.length > 0) { amounts[order[i % order.length]] += 1; remainder--; i++; } } const players = game.players.map((p) => winnerIds.includes(p.id) ? { ...p, stack: p.stack + (amounts[p.id] || 0) } : p ); const handHistory = [ ...game.handHistory, { handNumber: game.handNumber, pot, winners: winnerIds.map((id) => ({ id, name: game.players.find((p) => p.id === id)?.name || "?", amount: amounts[id] || 0, })), endedAt: Date.now(), }, ]; return { ...game, players, pot: 0, status: "hand-ended", handHistory, turnPlayerId: null, autoWinner: null, }; } /* ============================================================ SMALL PRESENTATIONAL PIECES ============================================================ */ function GlobalStyle() { return ( ); } function ChipBadge({ chips, ratio, size = "md", tone = "amber" }) { const sizes = { sm: { outer: "w-12 h-12", text: "text-xs", rs: "text-xs" }, md: { outer: "w-16 h-16", text: "text-sm", rs: "text-xs" }, lg: { outer: "w-24 h-24", text: "text-lg", rs: "text-xs" }, }; const s = sizes[size]; return (
{fmtChips(chips)} ₹{fmtRs(chips, ratio)}
); } function Tag({ children, tone = "amber" }) { const tones = { amber: "bg-amber-400 text-emerald-950", sky: "bg-sky-400 text-emerald-950", rose: "bg-rose-500 text-stone-50", stone: "bg-stone-600 text-stone-100", }; return ( {children} ); } function Button({ children, onClick, variant = "primary", className = "", disabled, type = "button" }) { const variants = { primary: "bg-amber-400 hover:bg-amber-300 text-emerald-950 disabled:bg-stone-700 disabled:text-stone-500", secondary: "bg-emerald-800 hover:bg-emerald-700 text-stone-100 border border-emerald-600 disabled:opacity-40", danger: "bg-rose-600 hover:bg-rose-500 text-stone-50 disabled:opacity-40", ghost: "bg-transparent hover:bg-emerald-800 text-stone-200 border border-emerald-700 disabled:opacity-40", }; return ( ); } function Field({ label, children, hint }) { return ( ); } function TextInput(props) { return ( ); } function Modal({ title, onClose, children, wide }) { return (

{title}

{onClose && ( )}
{children}
); } function CardsReminder() { return (

🂠 Cards stay on the table — this app only tracks chips, blinds & the pot.

); } /* ============================================================ LANDING ============================================================ */ function LandingScreen({ onChooseCreate, onChooseJoin }) { return (

Chip Rail

Chips, blinds and the pot — tracked live for your home game. The cards stay on the table.

); } /* ============================================================ JOIN ============================================================ */ function JoinScreen({ onBack, onJoined }) { const [name, setName] = useState(""); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); async function handleJoin(e) { e.preventDefault(); setError(""); const roomCode = code.trim().toUpperCase(); const trimmedName = name.trim(); if (!trimmedName || !roomCode) { setError("Enter your name and the room code."); return; } setBusy(true); const game = await loadGame(roomCode); if (!game) { setBusy(false); setError("No game found with that room code."); return; } const existing = game.players.find((p) => p.name.toLowerCase() === trimmedName.toLowerCase()); if (existing) { const players = game.players.map((p) => (p.id === existing.id ? { ...p, connected: true } : p)); const updated = await saveGame({ ...game, players }); const session = { roomCode, playerId: existing.id, name: existing.name }; await saveMySession(session); setBusy(false); onJoined(session, updated); return; } if (game.status !== "lobby") { setBusy(false); setError("This game is already in progress. Only players already at the table can rejoin."); return; } if (game.players.length >= game.config.numPlayers) { setBusy(false); setError("This table is full."); return; } const newPlayer = { id: genId(), name: trimmedName, isAdmin: false, stack: game.config.startingStackValue, startingStackAtJoin: game.config.startingStackValue, isDealer: false, isSB: false, isBB: false, isFolded: false, currentBet: 0, position: game.players.length, connected: true, }; const updated = await saveGame({ ...game, players: [...game.players, newPlayer] }); const session = { roomCode, playerId: newPlayer.id, name: newPlayer.name }; await saveMySession(session); setBusy(false); onJoined(session, updated); } return (

Join a game

setName(e.target.value)} placeholder="e.g. Priya" maxLength={20} autoFocus /> setCode(e.target.value.toUpperCase())} placeholder="e.g. K7QPL" maxLength={8} className="tracking-widest text-center text-lg" /> {error &&

{error}

}
); } /* ============================================================ CONFIG (admin sets up the game, then creates the room) ============================================================ */ function ConfigScreen({ onBack, onCreated }) { const [adminName, setAdminName] = useState(""); const [numPlayers, setNumPlayers] = useState(4); const [chipMode, setChipMode] = useState("flat"); const [flatStack, setFlatStack] = useState(DEFAULT_STACK); const [combo, setCombo] = useState(() => DENOMS.map((v) => ({ value: v, count: 0 }))); const [customDenom, setCustomDenom] = useState(""); const [ratioChips, setRatioChips] = useState(DEFAULT_RATIO.chips); const [ratioRs, setRatioRs] = useState(DEFAULT_RATIO.rs); const [preFlopEnabled, setPreFlopEnabled] = useState(true); const [smallBlind, setSmallBlind] = useState(50); const [bigBlind, setBigBlind] = useState(100); const [blindsTouched, setBlindsTouched] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const comboTotal = combo.reduce((s, d) => s + d.value * d.count, 0); const startingStackValue = chipMode === "flat" ? flatStack : comboTotal; // keep suggested blinds in sync with the stack until the admin edits them directly useEffect(() => { if (blindsTouched) return; if (chipMode === "flat") { const sb = Math.max(1, Math.round(flatStack / 200)); setSmallBlind(sb); setBigBlind(sb * 2); } else { const used = combo.filter((d) => d.count > 0).map((d) => d.value); const smallest = used.length ? Math.min(...used) : 1; setSmallBlind(smallest); setBigBlind(smallest * 2); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [chipMode, flatStack, combo, blindsTouched]); function updateCount(value, delta) { setCombo((prev) => prev.map((d) => { if (d.value !== value) return d; const nextCount = Math.max(0, d.count + delta); const nextTotal = comboTotal - d.value * d.count + d.value * nextCount; if (nextTotal > MAX_STACK) return d; return { ...d, count: nextCount }; }) ); } function addCustomDenom() { const v = parseInt(customDenom, 10); if (!v || v <= 0) return; if (combo.some((d) => d.value === v)) { setCustomDenom(""); return; } setCombo((prev) => [...prev, { value: v, count: 0 }].sort((a, b) => a.value - b.value)); setCustomDenom(""); } async function handleCreate() { setError(""); const trimmedName = adminName.trim(); if (!trimmedName) { setError("Enter your name."); return; } if (numPlayers < 2 || numPlayers > 10) { setError("Number of players must be between 2 and 10."); return; } if (startingStackValue <= 0) { setError(chipMode === "flat" ? "Set a starting stack." : "Add at least one chip to the starting stack."); return; } if (startingStackValue > MAX_STACK) { setError(`Starting stack can't exceed ${fmtChips(MAX_STACK)} chips.`); return; } if (!ratioChips || !ratioRs) { setError("Set a valid conversion ratio."); return; } if (smallBlind <= 0 || bigBlind <= 0) { setError("Blind amounts must be greater than zero."); return; } setBusy(true); const config = { numPlayers, chipMode, flatStack, denomCombo: combo.filter((d) => d.count > 0), startingStackValue, ratio: { chips: ratioChips, rs: ratioRs }, preFlopEnabled, smallBlind, bigBlind, }; let roomCode = genRoomCode(); // avoid an unlikely collision with an existing room for (let i = 0; i < 5; i++) { const existing = await loadGame(roomCode); if (!existing) break; roomCode = genRoomCode(); } const game = buildInitialGame({ roomCode, adminName: trimmedName, config }); await saveGame(game); const session = { roomCode, playerId: game.players[0].id, name: game.players[0].name }; await saveMySession(session); setBusy(false); onCreated(session, game); } return (

Set up the table

You're the admin — you'll also be a player at the table.

setAdminName(e.target.value)} placeholder="e.g. Arjun" maxLength={20} />
{numPlayers}
{chipMode === "flat" ? ( setFlatStack(Math.min(MAX_STACK, Math.max(0, parseInt(e.target.value, 10) || 0)))} /> ) : (
{combo.map((d) => (
{fmtChips(d.value)} chip
{d.count}
))}
setCustomDenom(e.target.value)} className="text-sm py-1.5" />
)}
Per player MAX_STACK ? "text-rose-400" : "text-amber-300"}`}> {fmtChips(startingStackValue)} chips · ₹{fmtRs(startingStackValue, { chips: ratioChips, rs: ratioRs })}

Cap is {fmtChips(MAX_STACK)} chips per player.

setRatioChips(parseInt(e.target.value, 10) || 0)} /> chips = setRatioRs(parseInt(e.target.value, 10) || 0)} />
{ setBlindsTouched(true); setSmallBlind(parseInt(e.target.value, 10) || 0); }} /> {preFlopEnabled && ( <> / { setBlindsTouched(true); setBigBlind(parseInt(e.target.value, 10) || 0); }} /> )}
{error &&

{error}

}
); } /* ============================================================ LOBBY ============================================================ */ function LobbyScreen({ game, session, onStart, onLeave, onSaveConfig, onKick, onMakeAdmin, onEndGame }) { const isAdmin = session.playerId === game.players.find((p) => p.isAdmin)?.id; const [copied, setCopied] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const ready = game.players.length >= game.config.numPlayers; async function copyCode() { try { await navigator.clipboard.writeText(game.roomCode); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch (e) { /* ignore */ } } return (

Room code

{copied ? "Copied!" : "Tap to copy · share with the table"}

Starting stack {fmtChips(game.config.startingStackValue)} · ₹{fmtRs(game.config.startingStackValue, game.config.ratio)}
{game.config.preFlopEnabled ? "Blinds" : "Ante"} {game.config.preFlopEnabled ? `${fmtChips(game.config.smallBlind)} / ${fmtChips(game.config.bigBlind)}` : fmtChips(game.config.smallBlind)}
Mode {game.config.preFlopEnabled ? "Standard pre-flop" : "No pre-flop (ante)"}

Players ({game.players.length}/{game.config.numPlayers})

{game.players.map((p) => (
{p.name} {p.id === session.playerId && (you)} {p.isAdmin && ADMIN}
))} {Array.from({ length: Math.max(0, game.config.numPlayers - game.players.length) }).map((_, i) => (
Waiting for player…
))}
{isAdmin ? (
) : (

Waiting for the admin to start the game…

)}
{settingsOpen && ( setSettingsOpen(false)} onSave={(cfg) => { setSettingsOpen(false); onSaveConfig(cfg); }} onKick={onKick} onMakeAdmin={onMakeAdmin} onEndGame={() => { setSettingsOpen(false); onEndGame(); }} /> )}
); } /* ============================================================ RAISE MODAL ============================================================ */ function RaiseModal({ me, maxBet, onClose, onConfirm, ratio }) { const allInTo = me.currentBet + me.stack; const floor = Math.min(allInTo, Math.max(maxBet + 1, me.currentBet + 1)); const [amount, setAmount] = useState(floor); function clamp(v) { return Math.max(floor, Math.min(allInTo, v)); } return (

You have {fmtChips(me.stack)} chips left (₹{fmtRs(me.stack, ratio)}). Enter the total amount you want your bet to be this round.

setAmount(clamp(parseInt(e.target.value, 10) || floor))} className="text-center text-xl" />

≈ ₹{fmtRs(amount, ratio)}

); } /* ============================================================ HISTORY MODAL ============================================================ */ function HistoryModal({ game, onClose }) { return ( {game.handHistory.length === 0 ? (

No hands completed yet.

) : (
{[...game.handHistory].reverse().map((h) => (
Hand #{h.handNumber} {fmtChips(h.pot)} pot
{h.winners.map((w) => (

🏆 {w.name} won {fmtChips(w.amount)} chips

))}
))}
)}
); } /* ============================================================ SETTINGS DRAWER (admin) ============================================================ */ function SettingsModal({ game, locked, onClose, onSave, onKick, onMakeAdmin, onEndGame, session }) { const [ratioChips, setRatioChips] = useState(game.config.ratio.chips); const [ratioRs, setRatioRs] = useState(game.config.ratio.rs); const [preFlopEnabled, setPreFlopEnabled] = useState(game.config.preFlopEnabled); const [smallBlind, setSmallBlind] = useState(game.config.smallBlind); const [bigBlind, setBigBlind] = useState(game.config.bigBlind); function save() { onSave({ ...game.config, ratio: { chips: ratioChips || 1, rs: ratioRs || 0 }, preFlopEnabled: locked ? game.config.preFlopEnabled : preFlopEnabled, smallBlind: locked ? game.config.smallBlind : smallBlind, bigBlind: locked ? game.config.bigBlind : bigBlind, }); } return (
setRatioChips(parseInt(e.target.value, 10) || 0)} /> chips = setRatioRs(parseInt(e.target.value, 10) || 0)} />
{locked && (

Player count, starting stack and chip combo lock once a game is underway.

)}
setSmallBlind(parseInt(e.target.value, 10) || 0)} /> {preFlopEnabled && ( <> / setBigBlind(parseInt(e.target.value, 10) || 0)} /> )}

Players

{game.players.map((p) => (
{p.name} {p.isAdmin && ADMIN} {!p.isAdmin && (
{(game.status === "lobby" || game.status === "hand-ended") && ( )}
)}
))}
); } /* ============================================================ SHOWDOWN PANEL ============================================================ */ function ShowdownPanel({ game, isAdmin, onAward }) { const active = game.players.filter((p) => !p.isFolded); const [selected, setSelected] = useState(game.autoWinner ? [game.autoWinner] : []); const [manual, setManual] = useState(null); const share = selected.length ? Math.floor(game.pot / selected.length) : 0; const order = selected.length ? orderByClockwiseFromDealer(game, selected) : []; const remainder = selected.length ? game.pot - share * selected.length : 0; function toggle(id) { if (game.autoWinner) return; setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); setManual(null); } function computedAmounts() { if (manual) return manual; const amounts = {}; selected.forEach((id) => (amounts[id] = share)); let rem = remainder; let i = 0; while (rem > 0 && order.length) { amounts[order[i % order.length]] += 1; rem--; i++; } return amounts; } function adjust(id, delta) { const base = computedAmounts(); setManual({ ...base, [id]: Math.max(0, (base[id] || 0) + delta) }); } const amounts = computedAmounts(); const allocated = Object.values(amounts).reduce((s, v) => s + v, 0); return (

{game.autoWinner ? "Everyone else folded" : "Showdown — declare winner(s)"}

Pot: {fmtChips(game.pot)} chips · ₹{fmtRs(game.pot, game.config.ratio)}

{isAdmin ? ( <>
{active.map((p) => ( ))}
{allocated !== game.pot && selected.length > 0 && (

Allocated {fmtChips(allocated)} of {fmtChips(game.pot)} — adjust to match the pot.

)} ) : (

Waiting for the admin to declare the winner…

)}
); } /* ============================================================ TABLE (main game) SCREEN ============================================================ */ function PlayerSeat({ p, isMe, isTurn, ratio }) { return (
{p.name} {isMe && (you)} {p.isDealer && D} {p.isSB && SB} {p.isBB && BB} {p.isAdmin && ADM}

{p.isFolded ? "Folded" : p.currentBet > 0 ? `Bet ${fmtChips(p.currentBet)} this round` : "No bet yet"}

); } function TableScreen({ game, session, onAction, onAdvanceRound, onGoShowdown, onAward, onStartNextHand, onSaveConfig, onKick, onMakeAdmin, onEndGame, onLeave }) { const [raiseOpen, setRaiseOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const me = game.players.find((p) => p.id === session.playerId); const isAdmin = !!me?.isAdmin; const isMyTurn = game.status === "playing" && game.turnPlayerId === session.playerId; const active = game.players.filter((p) => !p.isFolded); const maxBet = Math.max(0, ...active.map((p) => p.currentBet)); const roundDone = game.status === "playing" && isRoundComplete(game); const lastHand = game.handHistory[game.handHistory.length - 1]; return (

Hand #{game.handNumber} · Room {game.roomCode}

{game.status === "playing" ? ROUND_LABELS[game.currentRound] : game.status === "showdown" ? "Showdown" : "Between hands"}

{isAdmin && ( )}

Pot

{game.status === "hand-ended" && lastHand && (

🏆 {lastHand.winners.map((w) => `${w.name} (+${fmtChips(w.amount)})`).join(", ")}

)} {game.status === "showdown" && (
)}
{game.players.map((p) => ( ))}
{game.status === "playing" && isAdmin && (
{game.currentRound < 4 ? ( ) : ( )}
)} {game.status === "hand-ended" && isAdmin && (
)} {game.status === "hand-ended" && !isAdmin &&

Waiting for the admin to start the next hand…

}
{game.status === "playing" && (
{isMyTurn ? (
) : (

Waiting on {game.players.find((p) => p.id === game.turnPlayerId)?.name}

)}
)} {raiseOpen && me && ( setRaiseOpen(false)} onConfirm={(amt) => { setRaiseOpen(false); onAction("raise", amt); }} /> )} {historyOpen && setHistoryOpen(false)} />} {settingsOpen && ( setSettingsOpen(false)} onSave={(cfg) => { setSettingsOpen(false); onSaveConfig(cfg); }} onKick={onKick} onMakeAdmin={onMakeAdmin} onEndGame={() => { setSettingsOpen(false); onEndGame(); }} /> )}
); } /* ============================================================ END GAME SCREEN ============================================================ */ function EndGameScreen({ game, onReturnHome, isAdmin, onDeleteRoom }) { const standings = [...game.players].sort((a, b) => b.stack - a.stack); return (

Game over

Final standings

{standings.map((p, i) => { const net = p.stack - p.startingStackAtJoin; return (
#{i + 1} {p.name} {fmtChips(p.stack)} = 0 ? "text-emerald-400" : "text-rose-400"}`}> {net >= 0 ? "+" : ""} {fmtChips(net)}
); })}
{isAdmin && ( )}
); } /* ============================================================ ROOT APP ============================================================ */ export default function App() { const [screen, setScreen] = useState("loading"); // loading, landing, join, config, lobby, table, ended const [session, setSession] = useState(null); const [game, setGame] = useState(null); const pollRef = useRef(null); useEffect(() => { (async () => { const sess = await loadMySession(); if (sess) { const g = await loadGame(sess.roomCode); if (g && g.players.find((p) => p.id === sess.playerId)) { setSession(sess); setGame(g); setScreen(g.status === "lobby" ? "lobby" : g.status === "ended" ? "ended" : "table"); return; } else { await clearMySession(); } } setScreen("landing"); })(); }, []); useEffect(() => { if (!session?.roomCode) return; pollRef.current = setInterval(async () => { const g = await loadGame(session.roomCode); if (g) { setGame(g); setScreen((prev) => { if (prev === "table" || prev === "lobby" || prev === "ended") { return g.status === "lobby" ? "lobby" : g.status === "ended" ? "ended" : "table"; } return prev; }); } }, POLL_MS); return () => clearInterval(pollRef.current); }, [session?.roomCode]); const runAction = useCallback( async (updaterFn) => { if (!session?.roomCode) return; const latest = (await loadGame(session.roomCode)) || game; if (!latest) return; const updated = updaterFn(latest); if (!updated) return; const saved = await saveGame(updated); setGame(saved); }, [session, game] ); function handleCreated(sess, g) { setSession(sess); setGame(g); setScreen("lobby"); } function handleJoined(sess, g) { setSession(sess); setGame(g); setScreen(g.status === "lobby" ? "lobby" : g.status === "ended" ? "ended" : "table"); } async function handleStart() { runAction((g) => startHand(g, true)); setScreen("table"); } function handleAction(type, amount) { if (type === "fold") runAction((g) => applyFold(g, session.playerId)); if (type === "call") runAction((g) => applyCallCheck(g, session.playerId)); if (type === "raise") runAction((g) => applyRaise(g, session.playerId, amount)); } function handleAdvanceRound() { runAction((g) => (isRoundComplete(g) ? advanceRound(g) : g)); } function handleGoShowdown() { runAction((g) => (isRoundComplete(g) ? goToShowdown(g) : g)); } function handleAward(winnerIds, amounts) { runAction((g) => distributePot(g, winnerIds, amounts)); } function handleStartNextHand() { runAction((g) => startHand(g, false)); } function handleSaveConfig(cfg) { runAction((g) => ({ ...g, config: cfg })); } function handleKick(playerId) { runAction((g) => ({ ...g, players: g.players.filter((p) => p.id !== playerId).map((p, i) => ({ ...p, position: i })) })); } function handleMakeAdmin(playerId) { runAction((g) => ({ ...g, players: g.players.map((p) => ({ ...p, isAdmin: p.id === playerId })) })); } async function handleEndGame() { await runAction((g) => ({ ...g, status: "ended" })); setScreen("ended"); } async function handleLeave() { await clearMySession(); setSession(null); setGame(null); setScreen("landing"); } async function handleDeleteRoom() { if (session?.roomCode) await deleteGame(session.roomCode); await handleLeave(); } if (screen === "loading") { return (

Loading…

); } if (screen === "landing") { return setScreen("config")} onChooseJoin={() => setScreen("join")} />; } if (screen === "join") { return setScreen("landing")} onJoined={handleJoined} />; } if (screen === "config") { return setScreen("landing")} onCreated={handleCreated} />; } if (screen === "lobby" && game && session) { return ( ); } if (screen === "table" && game && session) { return ( ); } if (screen === "ended" && game && session) { const isAdmin = game.players.find((p) => p.id === session.playerId)?.isAdmin; return ; } return (

Something went wrong — please refresh.

); }