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 (
);
}
/* ============================================================
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 && (
)}
{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 (
);
}
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.
);
}