const { useState, useEffect, useCallback, useRef } = React; // Reusable Chart.js wrapper — takes a config and re-renders on data change. function ChartCanvas({ config, height = 220 }) { const canvasRef = useRef(null); const chartRef = useRef(null); useEffect(() => { if (!canvasRef.current || !window.Chart) return; if (chartRef.current) chartRef.current.destroy(); chartRef.current = new window.Chart(canvasRef.current, config); return () => { if (chartRef.current) { chartRef.current.destroy(); chartRef.current = null; } }; }, [JSON.stringify(config)]); return
; } const CHART_COLORS = { grid: "rgba(38, 50, 65, 0.6)", text: "#8b9aaf", accent: "#4ade80", accentFill: "rgba(74, 222, 128, 0.15)", alt: "#60a5fa", altFill: "rgba(96, 165, 250, 0.2)", warn: "#fbbf24", danger: "#f87171", }; function baseChartOpts(extra = {}) { return { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: CHART_COLORS.text } }, tooltip: { backgroundColor: "#131a22", borderColor: "#263241", borderWidth: 1 }, }, scales: { x: { ticks: { color: CHART_COLORS.text }, grid: { color: CHART_COLORS.grid } }, y: { ticks: { color: CHART_COLORS.text }, grid: { color: CHART_COLORS.grid } }, }, ...extra, }; } // The Go server now serves both the SPA and the API on the same origin, // so relative URLs just work — regardless of whether we're on 8080 or 8001. const API_BASE = ""; const PLAYER_ID_KEY = "marketbrawl.playerId"; function api(path, opts = {}) { const playerId = localStorage.getItem(PLAYER_ID_KEY); const headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; // Send X-Player-ID for guests. The server prefers the session cookie over // this header, so it's safely ignored once the user signs in with Google. if (playerId) headers["X-Player-ID"] = playerId; return fetch(`${API_BASE}${path}`, { ...opts, headers, credentials: "same-origin", // ensure the session cookie travels with every request }).then(async (r) => { const body = await r.json().catch(() => ({})); if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`); return body; }); } function fmtVP(n) { if (n == null) return "—"; return `${Number(n).toFixed(2)} VP`; } function Login({ onLogin }) { const [name, setName] = useState(""); const [err, setErr] = useState(""); const [loading, setLoading] = useState(false); const submit = async (e) => { e.preventDefault(); if (!name.trim()) return; setLoading(true); setErr(""); try { const user = await api("/api/players", { method: "POST", body: JSON.stringify({ display_name: name.trim() }), }); localStorage.setItem(PLAYER_ID_KEY, user.id); onLogin(user); } catch (e) { setErr(e.message); } finally { setLoading(false); } }; return (

Predict the markets. Win Victory Points. No real money.

Sign in with Google
— or continue as guest —
{err &&
{err}
} setName(e.target.value)} placeholder="Player 1" autoFocus />
); } function Dashboard({ player }) { const [season, setSeason] = useState(null); const [round, setRound] = useState(null); const [portfolios, setPortfolios] = useState([]); const [markets, setMarkets] = useState([]); const [leaderboard, setLeaderboard] = useState([]); const [history, setHistory] = useState(null); const [err, setErr] = useState(""); const load = useCallback(async () => { try { const [s, r, p, m, lb] = await Promise.all([ api("/api/seasons/current").catch(() => null), api("/api/rounds/current").catch(() => null), api("/api/portfolios").catch(() => []), api("/api/markets").catch(() => []), api("/api/leaderboard").catch(() => []), ]); setSeason(s); setRound(r); setPortfolios(p || []); setMarkets(m || []); setLeaderboard(lb || []); if (p && p[0]) { api(`/api/portfolios/${p[0].id}/history`).then(setHistory).catch(() => setHistory(null)); } } catch (e) { setErr(e.message); } }, []); useEffect(() => { load(); }, [load]); const portfolio = portfolios[0]; return (
{err &&
{err}
}

Season

{season ? ( <>
{season.name}
{season.status} · {season.num_rounds} rounds
) :
No active season. Create one from Admin.
}

Current Round

{round ? ( <>
Round #{round.round_number}
{round.status}
) :
No round yet.
}

Your Portfolio

{portfolio ? ( <>
{fmtVP(portfolio.balance)}
Wagered: {fmtVP(portfolio.total_wagered)} · Won: {fmtVP(portfolio.total_won)} · Lost: {fmtVP(portfolio.total_lost)}
) :
No portfolio yet. Season must be active.
}

Portfolio Valuation

Leaderboard — Current Balances

Market Prices

Open Markets

{markets.length === 0 ? (
No markets available. Ask admin to open a round.
) : (
{markets.map((m) => (

{m.name || m.ticker}

{m.category}
{m.current_price ? `${m.unit || "$"}${Number(m.current_price).toFixed(2)}` : "—"}
Min {fmtVP(m.min_bet)} · Max {fmtVP(m.max_bet)}
))}
)}
); } function Markets({ player, onBetPlaced }) { const [markets, setMarkets] = useState([]); const [portfolios, setPortfolios] = useState([]); const [err, setErr] = useState(""); const load = useCallback(async () => { try { const [m, p] = await Promise.all([ api("/api/markets").catch(() => []), api("/api/portfolios").catch(() => []), ]); setMarkets(m || []); setPortfolios(p || []); } catch (e) { setErr(e.message); } }, []); useEffect(() => { load(); }, [load]); return (
{err &&
{err}
} {markets.length === 0 &&
No markets available.
} {markets.map((m) => ( { load(); onBetPlaced?.(); }} /> ))}
); } function MarketBet({ market, portfolio, onPlaced }) { const [direction, setDirection] = useState("over"); const [target, setTarget] = useState((market.current_price * 1.05).toFixed(2)); const [wager, setWager] = useState("10"); const [status, setStatus] = useState({}); const [submitting, setSubmitting] = useState(false); const placeBet = async (e) => { e.preventDefault(); if (!portfolio) { setStatus({ err: "No portfolio — enrol in active season first." }); return; } setSubmitting(true); setStatus({}); try { await api("/api/bets", { method: "POST", body: JSON.stringify({ market_id: market.id, portfolio_id: portfolio.id, direction, target_price: Number(target), wager_amount: Number(wager), }), }); setStatus({ ok: `Bet placed: ${direction.toUpperCase()} ${target} for ${wager} VP` }); onPlaced?.(); } catch (e) { setStatus({ err: e.message }); } finally { setSubmitting(false); } }; return (

{market.name || market.ticker}

{market.category} · {market.ticker}
{market.current_price ? `${market.unit || "$"}${Number(market.current_price).toFixed(2)}` : "—"}
Current price
{status.err &&
{status.err}
} {status.ok &&
{status.ok}
}
setTarget(e.target.value)} />
setWager(e.target.value)} />
); } function Portfolio({ player }) { const [bets, setBets] = useState([]); const [portfolios, setPortfolios] = useState([]); const [history, setHistory] = useState(null); const [err, setErr] = useState(""); const load = useCallback(async () => { try { const [b, p] = await Promise.all([ api("/api/bets").catch(() => []), api("/api/portfolios").catch(() => []), ]); setBets(b || []); setPortfolios(p || []); if (p && p[0]) { api(`/api/portfolios/${p[0].id}/history`).then(setHistory).catch(() => setHistory(null)); } } catch (e) { setErr(e.message); } }, []); useEffect(() => { load(); }, [load]); const portfolio = portfolios[0]; return (
{err &&
{err}
} {portfolio && (

Balance

{fmtVP(portfolio.balance)}
Total Wagered
{fmtVP(portfolio.total_wagered)}
Total Won
{fmtVP(portfolio.total_won)}
Total Lost
{fmtVP(portfolio.total_lost)}
)}

Balance Over Time

Round P&L

Your Bets (Current Round)

{bets.length === 0 ?
No bets placed yet.
: ( {bets.map((b) => ( ))}
DirectionTargetWagerOddsStatusPayout
{b.direction.toUpperCase()} {Number(b.target_price).toFixed(2)} {fmtVP(b.wager_amount)} {Number(b.odds_multiplier).toFixed(2)}x {b.status} {b.payout ? fmtVP(b.payout) : "—"}
)}
); } function Leaderboard() { const [entries, setEntries] = useState([]); const [err, setErr] = useState(""); useEffect(() => { api("/api/leaderboard").then(setEntries).catch((e) => setErr(e.message)); }, []); return (

Season Leaderboard

{err &&
{err}
} {entries.length === 0 ?
No entries yet.
: ( {entries.map((e, i) => ( ))}
RankPlayerBalanceTotal WonTotal Lost
#{i + 1} {e.display_name} {fmtVP(e.balance)} {fmtVP(e.total_won)} {fmtVP(e.total_lost)}
)}
); } function Admin({ onChange }) { const [msg, setMsg] = useState({}); const [seasonName, setSeasonName] = useState("Season 1"); const [numRounds, setNumRounds] = useState(4); const post = async (path, body) => { setMsg({}); try { await api(path, { method: "POST", body: body ? JSON.stringify(body) : undefined }); setMsg({ ok: `${path} succeeded` }); onChange?.(); } catch (e) { setMsg({ err: e.message }); } }; return (

Admin Controls

Auth is disabled in closed beta — anyone can drive the game loop.

{msg.err &&
{msg.err}
} {msg.ok &&
{msg.ok}
}

Create + Activate Season

setSeasonName(e.target.value)} placeholder="Season name" /> setNumRounds(Number(e.target.value))} />

Round Lifecycle

); } function ValuationChart({ history }) { if (!history || !history.points || history.points.length === 0) { return
No round history yet — valuation chart appears after the first round resolves.
; } const initial = history.initial_balance || 10000; let running = initial; const labels = ["Start"]; const values = [initial]; for (const p of history.points) { running = running + (Number(p.net_pnl) || 0) + (Number(p.bonus_earned) || 0); labels.push(`R${p.round_number}`); values.push(Number(running.toFixed(2))); } const config = { type: "line", data: { labels, datasets: [{ label: "Balance (VP)", data: values, borderColor: CHART_COLORS.accent, backgroundColor: CHART_COLORS.accentFill, fill: true, tension: 0.25, pointRadius: 4, pointBackgroundColor: CHART_COLORS.accent, }], }, options: baseChartOpts({ plugins: { legend: { labels: { color: CHART_COLORS.text } }, tooltip: { callbacks: { label: (ctx) => `${Number(ctx.parsed.y).toFixed(2)} VP` }, backgroundColor: "#131a22", borderColor: "#263241", borderWidth: 1, }, }, }), }; return ; } function LeaderboardChart({ entries, currentUserId }) { if (!entries || entries.length === 0) { return
No players yet.
; } const top = entries.slice(0, 10); const config = { type: "bar", data: { labels: top.map((e) => e.display_name + (e.is_bot ? " 🤖" : "")), datasets: [{ label: "Balance (VP)", data: top.map((e) => Number(e.balance)), backgroundColor: top.map((e) => e.user_id === currentUserId ? CHART_COLORS.accent : e.is_bot ? CHART_COLORS.altFill : CHART_COLORS.alt ), borderColor: top.map((e) => e.user_id === currentUserId ? CHART_COLORS.accent : CHART_COLORS.alt ), borderWidth: 1, }], }, options: baseChartOpts({ indexAxis: "y", plugins: { legend: { display: false } }, }), }; return ; } function RoundPnLChart({ history }) { if (!history || !history.points || history.points.length === 0) { return
No resolved rounds yet.
; } const points = history.points; const pnl = points.map((p) => Number(p.net_pnl) + Number(p.bonus_earned)); const config = { type: "bar", data: { labels: points.map((p) => `R${p.round_number}`), datasets: [{ label: "Net P&L (VP)", data: pnl, backgroundColor: pnl.map((v) => v >= 0 ? CHART_COLORS.accentFill : "rgba(248, 113, 113, 0.2)"), borderColor: pnl.map((v) => v >= 0 ? CHART_COLORS.accent : CHART_COLORS.danger), borderWidth: 1, }], }, options: baseChartOpts({ plugins: { legend: { display: false } } }), }; return ; } function MarketPriceChart({ markets }) { if (!markets || markets.length === 0) { return
No markets yet.
; } const config = { type: "bar", data: { labels: markets.map((m) => m.name || m.ticker), datasets: [ { label: "Opening Price", data: markets.map((m) => Number(m.opening_price) || 0), backgroundColor: CHART_COLORS.altFill, borderColor: CHART_COLORS.alt, borderWidth: 1, }, { label: "Current Price", data: markets.map((m) => Number(m.current_price) || 0), backgroundColor: CHART_COLORS.accentFill, borderColor: CHART_COLORS.accent, borderWidth: 1, }, ], }, options: baseChartOpts(), }; return ; } function App() { const [player, setPlayer] = useState(null); const [checking, setChecking] = useState(true); const [tab, setTab] = useState("dashboard"); const [refresh, setRefresh] = useState(0); useEffect(() => { // Session cookie takes priority: if Google OAuth set one, use that user. api("/api/auth/me") .then((res) => { if (res && res.user) { setPlayer(res.user); setChecking(false); return; } // Fallback: guest/local account via X-Player-ID header. const id = localStorage.getItem(PLAYER_ID_KEY); if (!id) { setChecking(false); return; } api(`/api/players/${id}`) .then((u) => setPlayer(u)) .catch(() => localStorage.removeItem(PLAYER_ID_KEY)) .finally(() => setChecking(false)); }) .catch(() => { // /api/auth/me errored — try local fallback anyway. const id = localStorage.getItem(PLAYER_ID_KEY); if (!id) { setChecking(false); return; } api(`/api/players/${id}`) .then((u) => setPlayer(u)) .catch(() => localStorage.removeItem(PLAYER_ID_KEY)) .finally(() => setChecking(false)); }); }, []); if (checking) return
Loading…
; if (!player) return ; const logout = async () => { // Clear both the local guest identity and the server-side session cookie. localStorage.removeItem(PLAYER_ID_KEY); try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch (_) { /* best effort */ } setPlayer(null); }; const tabs = [ ["dashboard", "Dashboard"], ["markets", "Markets"], ["portfolio", "Portfolio"], ["leaderboard", "Leaderboard"], ["admin", "Admin"], ]; return (
Welcome, {player.display_name}
VICTORY POINTS ONLY — NOT REAL CURRENCY
{tab === "dashboard" && } {tab === "markets" && setRefresh((r) => r + 1)} />} {tab === "portfolio" && } {tab === "leaderboard" && } {tab === "admin" && setRefresh((r) => r + 1)} />}
); } ReactDOM.createRoot(document.getElementById("root")).render();