/* ShinyPoker — LIVE lobby. Design look (lobby.css) driven by on-chain tables
via window.SP. Cash tab is live; other tabs are flagged coming-soon. */
const { useState: uS, useEffect: uE, useRef: uR } = React;
const Ln = (wei) => Number(SP.fmt(wei, 6));
const lshort = (a) => (a && a !== "0x0000000000000000000000000000000000000000" ? a.slice(0, 6) + "…" + a.slice(-4) : "");
const stakeOf = (bb) => (bb <= 0.05 ? "micro" : bb <= 1 ? "low" : bb <= 5 ? "mid" : "high");
function LobbyApp() {
const [tab, setTab] = uS("cash");
const [rows, setRows] = uS([]);
const [loaded, setLoaded] = uS(false);
const [connected, setConnected] = uS(false);
const [addr, setAddr] = uS(null);
const [bal, setBal] = uS(0);
const [stake, setStake] = uS("all");
const [size, setSize] = uS("all");
const [trnCount, setTrnCount] = uS(0);
const [showCreate, setShowCreate] = uS(false);
const [theme] = uS(() => localStorage.getItem("sp_theme") || "b");
const canvasRef = uR(null);
// ambient grid
uE(() => {
if (!canvasRef.current || !window.GridField) return;
const f = new GridField(canvasRef.current, { cell: 20, gap: 6, speed: 0.4, density: 0.5, accent: "#6E6EED", accent2: "#9B9BF4", maxAlpha: 0.7, minBright: 0.01, shape: "square" });
f.start();
const r = setTimeout(() => f._resize(), 300);
return () => { clearTimeout(r); f.destroy(); };
}, []);
// restore Privy session
uE(() => {
const restore = () => SP.sdk.tryRestorePrivy().then((a) => { if (a) { setAddr(a); setConnected(true); refreshBal(); } }).catch(() => {});
restore();
const on = (ev) => { if (ev.detail && ev.detail.authenticated && ev.detail.address) restore(); };
document.addEventListener("shinyluck:auth-state", on);
return () => document.removeEventListener("shinyluck:auth-state", on);
}, []);
async function refreshBal() { if (SP.sdk.address) { try { setBal(Ln(await SP.sdk.balanceOf(SP.sdk.address))); } catch {} } }
// poll live tables
uE(() => {
let stop = false;
async function load() {
try {
const n = await SP.sdk.tableCount();
const out = [];
for (let t = 0; t < n; t++) {
const [cfg, hand, seats] = await Promise.all([SP.sdk.getTable(t), SP.sdk.getHand(t), SP.sdk.getSeats(t)]);
out.push({
id: t, sb: Ln(cfg.smallBlind), bb: Ln(cfg.bigBlind), size: cfg.maxSeats,
seated: seats.filter((s) => !s.empty).length, pot: Ln(hand.pot), inHand: hand.inProgress,
rake: (cfg.rakeBps / 100), cap: Ln(cfg.rakeCap), stake: stakeOf(Ln(cfg.bigBlind)),
});
}
if (!stop) { setRows(out); setLoaded(true); }
if (SP.sdk.hasTournaments()) {
try { const ts = await SP.sdk.tournaments(); if (!stop) setTrnCount(ts.filter((t) => t.status <= 1).length); } catch {}
}
} catch (e) { if (!stop) setLoaded(true); }
}
load();
const iv = setInterval(load, 4000);
return () => { stop = true; clearInterval(iv); };
}, []);
async function connect() {
try { const a = await SP.sdk.connect(); setAddr(a); setConnected(true); refreshBal(); }
catch (e) { if (e && e.message !== "cancelled") alert(e.message || "connect failed"); }
}
let cash = rows;
if (stake !== "all") cash = cash.filter((r) => r.stake === stake);
if (size !== "all") cash = cash.filter((r) => String(r.size) === size);
const totalSeated = rows.reduce((a, r) => a + r.seated, 0);
const running = rows.filter((r) => r.inHand).length;
const biggest = rows.reduce((m, r) => Math.max(m, r.pot), 0);
const sym = SP.NETWORK.currency.symbol;
const COLS = "1.1fr 0.7fr 0.9fr 1.2fr 0.8fr 1.1fr";
return (
{[["cash", "Cash"], ["tournaments", "Tournaments"], ["sng", "Sit & Go"], ["clubs", "Clubs"]].map(([k, l]) => (
setTab(k)}>{l}{k === "cash" ? cash.length : (k === "tournaments" || k === "sng") && SP.sdk.hasTournaments() ? trnCount : "soon"}
))}
Players seated{totalSeated}
Tables {rows.length}
Hands running {running}
Biggest pot now {biggest.toFixed(2)}{sym}
{tab === "cash" ? (
Stakes
{[["all", "All"], ["micro", "Micro"], ["low", "Low"], ["mid", "Mid"], ["high", "High"]].map(([k, l]) => setStake(k)}>{l} )}
Size
{[["all", "All"], ["6", "6-max"], ["9", "9-max"], ["2", "Heads-up"]].map(([k, l]) => setSize(k)}>{l} )}
Provably fair · commit-reveal
) : tab === "tournaments" && SP.sdk.hasTournaments() ? (
Single-table tournaments · buy-in or sponsored pools
(connected ? setShowCreate(true) : connect())}>+ Create tournament
) :
{tab === "tournaments" ? "Scheduled tournaments" : tab === "sng" ? "Sit & Go / Spin" : "Clubs & private tables"} }
{tab === "tournaments" && SP.sdk.hasTournaments() ? (
setShowCreate(false)} />
) : tab === "sng" && SP.sdk.hasTournaments() ? (
) : tab !== "cash" ? (
♠
{tab === "tournaments" ? "Tournaments" : tab === "sng" ? "Sit & Go" : "Clubs"} — coming soon
Cash NLHE is live now. Scheduled tournaments, Sit&Go and clubs land next.
) : !loaded ? Loading tables…
: cash.length === 0 ? No tables match your filters.
: (
Stakes · Rake Game Size Seats Pot now Action
{cash.map((r) => {
const full = r.seated >= r.size;
const sizeLabel = r.size === 2 ? "Heads-up" : r.size + "-max";
return (
{r.sb} / {r.bb}{sym} rake {r.rake}% · cap {r.cap} · no flop no drop
NLHE
{sizeLabel}
{Array.from({ length: r.size }).map((_, i) => )}{r.seated}/{r.size}
{r.pot > 0 ? r.pot.toFixed(2) : "—"}{r.pot > 0 ? sym : ""}
);
})}
)}
);
}
/* ---------------- Tournaments (live) ---------------- */
const TRN_COLS = "1.5fr 1.2fr 1.2fr 0.9fr 0.9fr 1.2fr";
function TournamentsTab({ connected, connect, addr, onCount, showCreate, closeCreate }) {
const [list, setList] = uS(null);
const [mine, setMine] = uS({}); // id -> isRegistered
const [busy, setBusy] = uS(false);
const [msg, setMsg] = uS(null);
const sym = SP.NETWORK.currency.symbol;
function flash(m) { setMsg(m); setTimeout(() => setMsg(null), 4000); }
async function load() {
try {
const ts = await SP.sdk.tournaments();
ts.reverse(); // newest first
setList(ts);
onCount(ts.filter((t) => t.status <= 1).length);
if (SP.sdk.address) {
const m = {};
for (const t of ts) m[t.id] = await SP.sdk.isRegisteredIn(t.id);
setMine(m);
}
} catch (e) { console.warn("trn load:", e.message); setList([]); }
}
uE(() => { load(); const iv = setInterval(load, 4000); return () => clearInterval(iv); }, [connected]);
async function act(label, fn) {
setBusy(true);
try { await fn(); flash(label + " ✓"); await load(); }
catch (e) { flash(label + " ✗ " + (e?.shortMessage || e?.reason || e?.message || "").replace(/execution reverted:?/i, "").slice(0, 70)); console.error(e); }
finally { setBusy(false); }
}
const fmtSplit = (bps) => bps.map((b) => (b / 100) + "%").join(" / ");
return (
{msg && {msg}
}
{showCreate && { closeCreate(); load(); }} act={act} busy={busy} />}
{list == null ? (
Loading tournaments…
) : list.length === 0 ? (
♠
No tournaments yet
Be the first — create one with your own buy-in, prize pool and payout split.
) : (
Tournament Buy-in · split Prize pool
Entrants Status Action
{list.map((t) => {
const cost = t.buyIn + t.fee;
const statusCls = ["registering", "running", "finished", "finished"][t.status];
const reg = mine[t.id];
const isCreator = addr && t.creator.toLowerCase() === addr.toLowerCase();
return (
SNG #{t.id} · {t.maxPlayers}-max
{Number(t.startStack)} chips by {t.creator.slice(0, 6)}… {t.pool > t.buyIn * BigInt(t.registered) && ◆ sponsored }
{Number(SP.fmt(cost, 4))}{sym}
payout {fmtSplit(t.payoutBps)}
{Number(SP.fmt(t.pool, 4))}{sym}
✓ secured on-chain
{t.registered}/{t.maxPlayers}{t.status === 1 && {t.remaining} left }
{SP.TRN_STATUS[t.status]}
{t.status === 0 && !connected &&
Connect }
{t.status === 0 && connected && !reg &&
act("Register", () => SP.sdk.registerTournament(t.id, cost))}>Register {Number(SP.fmt(cost, 4))} }
{t.status === 0 && connected && reg &&
act("Unregister", () => SP.sdk.unregisterTournament(t.id))}>Unregister }
{t.status === 0 && connected && isCreator && t.registered >= 2 &&
act("Start", () => SP.sdk.startTournament(t.id))}>Start now }
{t.status === 1 &&
{reg ? "Play →" : "Observe"} }
{t.status >= 2 &&
— }
);
})}
)}
);
}
/* ---------------- Sit & Go (live, one-click presets on the tournament engine) ---------------- */
const SNG_PRESETS = [
{ name: "Heads-Up Duel", structure: "turbo", players: 2, buyIn: "0.5", fee: "0.05", stack: 1500, sb: 10, bb: 20, levelMin: 4, split: [10000] },
{ name: "Hyper 6-Max", structure: "hyper", players: 6, buyIn: "0.5", fee: "0.05", stack: 1000, sb: 25, bb: 50, levelMin: 3, split: [6500, 3500] },
{ name: "9-Max Standard", structure: "regular", players: 9, buyIn: "0.3", fee: "0.03", stack: 1500, sb: 10, bb: 20, levelMin: 5, split: [5000, 3000, 2000] },
];
const SPIN_MULTIS = [{ x: 2 }, { x: 3 }, { x: 5 }, { x: 10 }, { x: 25 }, { x: 120 }, { x: 1000 }];
function SngTab({ connected, connect, addr }) {
const [open, setOpen] = uS(null); // registering tournaments
const [mine, setMine] = uS({});
const [busy, setBusy] = uS(false);
const [msg, setMsg] = uS(null);
const [display, setDisplay] = uS(SPIN_MULTIS[2]);
const sym = SP.NETWORK.currency.symbol;
function flash(m) { setMsg(m); setTimeout(() => setMsg(null), 4000); }
async function load() {
try {
const ts = (await SP.sdk.tournaments()).filter((t) => t.status === 0).reverse();
setOpen(ts);
if (SP.sdk.address) { const m = {}; for (const t of ts) m[t.id] = await SP.sdk.isRegisteredIn(t.id); setMine(m); }
} catch (e) { setOpen([]); }
}
uE(() => { load(); const iv = setInterval(load, 4000); return () => clearInterval(iv); }, [connected]);
// spin reel — pure eye-candy teaser while Spin SNG awaits on-chain randomness
uE(() => { const iv = setInterval(() => setDisplay(SPIN_MULTIS[Math.floor(Math.random() * SPIN_MULTIS.length)]), 1400); return () => clearInterval(iv); }, []);
async function act(label, fn) {
setBusy(true);
try { await fn(); flash(label + " ✓"); await load(); }
catch (e) { flash(label + " ✗ " + (e?.shortMessage || e?.reason || e?.message || "").replace(/execution reverted:?/i, "").slice(0, 70)); console.error(e); }
finally { setBusy(false); }
}
/// One click: create the SNG from the preset AND take the first seat.
const quickStart = (p) => act("Create & join " + p.name, async () => {
await SP.sdk.createTournament({
buyInEth: p.buyIn, feeEth: p.fee, maxPlayers: p.players, startStack: p.stack,
sbStart: p.sb, bbStart: p.bb, levelDur: p.levelMin * 60, payoutBps: p.split, sponsorEth: 0,
});
const id = (await SP.sdk.tournamentCount()) - 1;
const t = await SP.sdk.tournamentInfo(id);
await SP.sdk.registerTournament(id, t.buyIn + t.fee);
});
return (
Quick Sit & Go Buy-in Format Action
{SNG_PRESETS.map((p) => (
{p.name}
{p.structure} {p.stack} chips payout {p.split.map((b) => b / 100 + "%").join("/")}
{(parseFloat(p.buyIn) + parseFloat(p.fee)).toFixed(2)}{sym}
{p.buyIn} +{p.fee} fee
{p.players === 2 ? "Heads-up" : p.players + "-max"}
(connected ? quickStart(p) : connect())}>{connected ? "Create & join" : "Connect"}
))}
Open seats — join now Buy-in Seats Action
{open == null ?
Loading…
: open.length === 0 ?
No open Sit & Go right now — start one above.
: open.map((t) => {
const cost = t.buyIn + t.fee;
return (
SNG #{t.id}
{Number(t.startStack)} chips pool {Number(SP.fmt(t.pool, 4))} {sym}
{Number(SP.fmt(cost, 4))}{sym}
{Array.from({ length: t.maxPlayers }).map((_, i) => )}
{t.registered}/{t.maxPlayers}
{!connected ? Connect
: mine[t.id] ? act("Unregister", () => SP.sdk.unregisterTournament(t.id))}>Unregister
: act("Register", () => SP.sdk.registerTournament(t.id, cost))}>Register }
);
})}
◆ Spin & Go
3-handed hyper turbo with a random prize multiplier revealed at seating — powered by the same provably-fair on-chain randomness as the deck. Coming soon.
{msg &&
{msg}
}
);
}
function CreateTournamentModal({ close, onDone, act, busy }) {
const sym = SP.NETWORK.currency.symbol;
const [f, setF] = uS({ buyIn: "0.5", fee: "0.05", maxPlayers: "6", startStack: "1500", sb: "10", bb: "20", levelMin: "5", split: "65/35", sponsor: "0" });
const set = (k) => (e) => setF((s) => ({ ...s, [k]: e.target.value }));
const splitBps = f.split.split("/").map((s) => Math.round(parseFloat(s.trim()) * 100)).filter((n) => !isNaN(n));
const splitOk = splitBps.length > 0 && splitBps.reduce((a, b) => a + b, 0) === 10000;
const stop = (e) => e.stopPropagation();
return (
Create tournament
Players' buy-ins build the prize pool — or sponsor the pool yourself (or both). Winners split it exactly how you set below.
Payout split, % (e.g. 65/35 or 50/30/20)
{!splitOk &&
Percentages must add up to exactly 100.
}
Cancel
act("Create tournament", async () => {
await SP.sdk.createTournament({
buyInEth: f.buyIn || 0, feeEth: f.fee || 0, maxPlayers: parseInt(f.maxPlayers, 10),
startStack: parseInt(f.startStack, 10), sbStart: parseInt(f.sb, 10), bbStart: parseInt(f.bb, 10),
levelDur: Math.max(30, Math.round(parseFloat(f.levelMin) * 60)), payoutBps: splitBps, sponsorEth: f.sponsor || 0,
});
}).then(onDone)}>Create
);
}
function mountScaleLobby() {
const scaler = document.getElementById("scaler"); if (!scaler) return;
const app = scaler.querySelector(".app"); if (!app) return;
const fit = () => {
const s = Math.min((window.innerWidth - 24) / 1600, (window.innerHeight - 84) / 1000);
app.style.transform = `scale(${s})`; app.style.transformOrigin = "top left"; app.style.position = "absolute"; app.style.top = "0"; app.style.left = "0";
scaler.style.width = 1600 * s + "px"; scaler.style.height = 1000 * s + "px";
};
fit(); window.addEventListener("resize", fit);
}
function bootLobby() { ReactDOM.createRoot(document.getElementById("root")).render( ); setInterval(mountScaleLobby, 400); setTimeout(mountScaleLobby, 80); }
if (window.SP) bootLobby(); else window.addEventListener("sp:ready", bootLobby, { once: true });