/* ============================================================================= * app.jsx โ€” React rendering layer. * * This file contains ZERO game rules. It only: * - holds the engine state in a React hook, * - draws the menu / lobby / board / tokens / dice from that state, * - forwards user clicks (roll, pick token) to the engine, * - drives AI turns on a timer, * - animates what the engine already decided (token hops, dice, capture), and * - in online mode, relays intents/actions over LudoNet (net.js). * * Online model (host-authoritative): the HOST is the only one who calls the * engine with authority. Every action is broadcast as a tiny message * ({kind:'roll', value} or {kind:'move', token}); every client โ€” host included โ€” * replays it through the SAME animate-then-commit path, so all screens stay in * lockstep. Each action also carries a state snapshot for hard resync. * ========================================================================== */ const { useState, useEffect, useCallback, useRef } = React; const E = window.LudoEngine; const HEX = { red: '#ff0002', green: '#049645', yellow: '#ffde15', blue: '#1295e7' }; // matches board.svg / Ludo King palette const GRID = 15; // board.svg is a 15x15 grid; positions map to (unit / 15) * 100% const HOP_MS = 180; // per-cell token hop duration const RELEASE_MS = 340; // yard -> start square: one springy glide out (matches .releasing CSS) const RETURN_MS = 50; // captured token racing home: per-cell pace (matches .returning CSS) const DICE_SPIN_MS = 800; // dice tumble duration โ€” fast flips that decelerate, like a real die (~1s total) const AI_THINK_MS = 700; // pause before an AI rolls, so the turn hand-off is readable const MOVE_PAUSE_MS = 1000; // hold on the settled dice so the value is clearly seen before the token moves const GUEST_COLORS = ['green', 'yellow', 'blue']; // host is always red /* ---- Theme (dark / light) ------------------------------------------------- * index.html applies the saved (or system) theme pre-paint via * ; this just flips and persists it. */ const Theme = { get current() { return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'; }, toggle() { const t = Theme.current === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = t; const m = document.querySelector('meta[name="theme-color"]'); if (m) m.setAttribute('content', t === 'light' ? '#c7d2e0' : '#060a18'); localStorage.setItem('ludo-theme', t); return t; }, }; function ThemeToggle({ floating }) { const [theme, setTheme] = useState(Theme.current); return ( ); } /* ---- Sounds (synthesized with WebAudio โ€” no audio assets needed) --------- */ const Sound = (() => { let ctx = null; let muted = localStorage.getItem('ludo-muted') === '1'; function ac() { if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state === 'suspended') ctx.resume(); return ctx; } function tone(freq, dur, opts) { if (muted) return; opts = opts || {}; try { const c = ac(); const t0 = c.currentTime + (opts.when || 0); const o = c.createOscillator(); const g = c.createGain(); o.type = opts.type || 'sine'; o.frequency.setValueAtTime(freq, t0); if (opts.slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + opts.slide), t0 + dur); g.gain.setValueAtTime(opts.gain || 0.15, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + dur); o.connect(g).connect(c.destination); o.start(t0); o.stop(t0 + dur + 0.02); } catch (e) { /* audio unavailable โ€” play silently */ } } return { get muted() { return muted; }, toggle() { muted = !muted; localStorage.setItem('ludo-muted', muted ? '1' : '0'); return muted; }, dice() { // Rattle that slows with the visual tumble (see executeRoll's easing). let when = 0, gap = 0.065; for (let i = 0; i < 8; i++) { tone(170 + Math.random() * 170, 0.05, { type: 'square', gain: 0.045, when }); when += gap; gap = Math.min(gap * 1.22, 0.23); } }, hop() { tone(430 + Math.random() * 40, 0.06, { type: 'triangle', gain: 0.11 }); }, release() { tone(320, 0.14, { type: 'triangle', gain: 0.16, slide: 220 }); }, capture() { tone(520, 0.28, { type: 'sawtooth', gain: 0.11, slide: -340 }); }, finish() { tone(660, 0.12, { gain: 0.16 }); tone(880, 0.16, { gain: 0.16, when: 0.11 }); }, win() { [523, 659, 784, 1047, 784, 1047].forEach((f, i) => tone(f, 0.22, { gain: 0.17, when: i * 0.13 })); }, }; })(); /* ---- Pawn artwork (libreludo token.svg) inlined so the head recolors via * the CSS custom property --fill-colour ----------------------------------- */ const TOKEN_SVG = ` `; function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } function nameOf(state, color) { return state.players.find((p) => p.color === color).name; } // Convert a seat to AI mid-game (guest disconnected). Pure state-in/state-out. function seatToAI(state, color) { const s = JSON.parse(JSON.stringify(state)); const p = s.players.find((x) => x.color === color); if (p) { p.isAI = true; s.message = p.name + ' left โ€” AI plays on'; } return s; } /* ---- Gameplay profiles: per-name stats, XP and achievements --------------- * Stored in localStorage under one key, keyed by lower-cased player name, so * stats survive reloads and follow the name across local and online games. * A game is recorded once, when it finishes (abandoned games don't count). */ const ACHIEVEMENTS = [ { id: 'first-game', icon: '๐ŸŽฒ', title: 'First Roll', desc: 'Play your first game', test: (p) => p.games >= 1 }, { id: 'first-win', icon: '๐Ÿ†', title: 'Winner', desc: 'Win a game', test: (p) => p.wins >= 1 }, { id: 'hat-trick', icon: '๐ŸŽฉ', title: 'Hat-trick', desc: 'Win 3 games', test: (p) => p.wins >= 3 }, { id: 'champion', icon: '๐Ÿ‘‘', title: 'Champion', desc: 'Win 10 games', test: (p) => p.wins >= 10 }, { id: 'veteran', icon: '๐ŸŽ–๏ธ', title: 'Veteran', desc: 'Play 25 games', test: (p) => p.games >= 25 }, { id: 'first-blood', icon: 'โš”๏ธ', title: 'First Blood', desc: "Capture an opponent's token", test: (p) => p.captures >= 1 }, { id: 'hunter', icon: '๐Ÿน', title: 'Hunter', desc: 'Capture 25 tokens in total', test: (p) => p.captures >= 25 }, { id: 'home-run', icon: '๐Ÿ ', title: 'Home Run', desc: 'Bring all 4 tokens home in one game', test: (p, g) => g && g.home >= 4 }, { id: 'untouchable', icon: '๐Ÿ›ก๏ธ', title: 'Untouchable', desc: 'Win without losing a single token', test: (p, g) => g && g.won && g.suffered === 0 }, ]; // XP for one finished game: base for playing, bonus by finishing rank, plus // per-capture and per-token-home rewards. const XP_RULES = { played: 40, rank: [120, 60, 30, 0], capture: 10, tokenHome: 15 }; // Total XP needed to reach level `l` (L1 100, L2 300, L3 600, L4 1000, ...). const xpForLevel = (l) => 50 * l * (l + 1); const levelOf = (xp) => { let l = 0; while (xp >= xpForLevel(l + 1)) l++; return l; }; const Profiles = { load() { try { return JSON.parse(localStorage.getItem('ludo-profiles')) || {}; } catch (e) { return {}; } }, get(name) { return name ? this.load()[name.trim().toLowerCase()] || null : null; }, // The profile the menu shows: the name that most recently finished a game. last() { return this.get(localStorage.getItem('ludo-last-profile') || ''); }, /* Record one finished game. `g`: { won, rank, captures, suffered, home }. * Returns { profile, gained, levelsUp, unlocked } for the win-overlay report. */ record(name, g) { const all = this.load(); const key = name.trim().toLowerCase(); const p = all[key] || { name: name.trim(), games: 0, wins: 0, captures: 0, tokensHome: 0, xp: 0, ach: [] }; const levelBefore = levelOf(p.xp); p.games += 1; if (g.won) p.wins += 1; p.captures += g.captures; p.tokensHome += g.home; const gained = XP_RULES.played + (XP_RULES.rank[g.rank] || 0) + XP_RULES.capture * g.captures + XP_RULES.tokenHome * g.home; p.xp += gained; const unlocked = ACHIEVEMENTS.filter((a) => !p.ach.includes(a.id) && a.test(p, g)); p.ach.push(...unlocked.map((a) => a.id)); all[key] = p; try { localStorage.setItem('ludo-profiles', JSON.stringify(all)); localStorage.setItem('ludo-last-profile', key); } catch (e) { /* storage blocked โ€” play on without tracking */ } return { profile: p, gained, levelsUp: levelOf(p.xp) > levelBefore, unlocked }; }, }; /* ---- Global leaderboard (server-backed) ------------------------------------ * Talks to the mcqmojo.com API with ABSOLUTE paths (the hosted page sets a * that would misroute relative ones). Served anywhere else โ€” * localhost, GitHub Pages โ€” the endpoints don't exist: submit() fails silently * and the overlay shows a friendly "not available here" note instead. */ const Leaderboard = { // Scores that couldn't reach the server wait here (offline, or the API was // mid-deploy) and are re-sent on the next visit โ€” a finished game is never // lost to a transient outage. Capped so a permanently API-less host // (localhost, GitHub Pages) tops out at a few KB instead of growing forever. QKEY: 'ludo-score-queue', QCAP: 20, _queue() { try { return JSON.parse(localStorage.getItem(this.QKEY)) || []; } catch (e) { return []; } }, _store(q) { try { localStorage.setItem(this.QKEY, JSON.stringify(q.slice(-this.QCAP))); } catch (e) {} }, _flushing: false, // One finished game for one human seat. Queue-then-flush, fire-and-forget: // the game never waits on (or surfaces) the network โ€” the XP report already // showed locally. submit(entry) { this._store(this._queue().concat([entry])); this.flush(); }, // Send queued entries in order; stop (and keep the rest) on the first // failure. Entries added mid-flush survive: only the sent prefix is removed. flush() { if (this._flushing) return; const q = this._queue(); if (!q.length) return; this._flushing = true; const done = (sent) => { this._store(this._queue().slice(sent)); this._flushing = false; }; const step = (i) => { if (i >= q.length) { done(q.length); return; } let p; try { p = fetch('/api/ludo/score', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(q[i]), }); } catch (e) { p = Promise.reject(e); } p.then((r) => { // 400/422 = the server understood and REJECTED this entry (bad name, // stale payload shape) โ€” a retry can never succeed, and keeping it // would block everything behind it forever. Drop it and move on. // Everything else (mid-deploy 404/405, 5xx, network) keeps the tail // for the next visit โ€” that's what saves scores through an outage. if (!r.ok && r.status !== 400 && r.status !== 422) throw new Error('score http ' + r.status); step(i + 1); }).catch(() => done(i)); }; step(0); }, // period: 'day' | 'week' | 'month' | 'all'. cache:'no-store' dodges the // Cloudflare edge cache โ€” a leaderboard must not be hours stale. async top(period) { const r = await fetch('/api/ludo/leaderboard?period=' + period, { cache: 'no-store' }); if (!r.ok) throw new Error('leaderboard http ' + r.status); return r.json(); // { rows: [{ name, xp, wins, games }] } }, }; // Re-send scores stranded by an earlier outage, once the page has settled. setTimeout(() => Leaderboard.flush(), 4000); /* ---- MCQ Mojo site links + result sharing ---------------------------------- * The hosted game page is standalone (no site header/footer โ€” see * server/ludo.html), so the menu renders these links as its stand-in chrome. * Absolute URLs on purpose: the hosted page sets , which would * misroute relative ones, and from localhost the site pages don't exist. */ const SITE_HOME = 'https://mcqmojo.com/'; const SITE_GAMES = 'https://mcqmojo.com/games/'; const SITE_GAME = 'https://mcqmojo.com/ludo-king-game/'; const SITE_LEADERBOARD = 'https://mcqmojo.com/ludo-king-game-leaderboard/'; const SITE_ACHIEVEMENTS = 'https://mcqmojo.com/ludo-king-game-achievements/'; const onMobile = () => (navigator.userAgentData && navigator.userAgentData.mobile) || /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent); /* Share text + url: native sheet on phones (WhatsApp and friends live there), * clipboard on desktop โ€” same split as the room-invite share button. Returns * 'shared' | 'copied' | null (aborted / storage blocked) for button feedback. */ async function shareText(title, text, url) { if (navigator.share && onMobile()) { try { await navigator.share({ title, text, url }); return 'shared'; } catch (e) { if (e && e.name === 'AbortError') return null; /* else fall through to copy */ } } const full = text + ' ' + url; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(full); } else { const ta = document.createElement('textarea'); ta.value = full; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } return 'copied'; } catch (e) { return null; } } // wa.me works everywhere (desktop opens WhatsApp Web), so it gets its own button. const waShareHref = (text, url) => 'https://wa.me/?text=' + encodeURIComponent(text + ' ' + url); /* ---- Local-game autosave --------------------------------------------------- * The engine state is one plain JSON object, so a local (pass-and-play / vs AI) * game is saved to localStorage on every committed action and restored on page * load โ€” a reload drops you right back into the game instead of the menu. * Online games are NOT saved: restoring one needs the other peers back, not * just the local state (see README "Not included"). */ const LocalSave = { KEY: 'ludo-save', load() { try { const s = JSON.parse(localStorage.getItem(this.KEY)); return s && s.state && s.state.phase && s.state.phase !== 'gameover' && Array.isArray(s.state.players) && s.state.players.length === E.COLORS.length ? s : null; } catch (e) { return null; } }, write(state, stats) { try { localStorage.setItem(this.KEY, JSON.stringify({ state, stats, savedAt: Date.now() })); } catch (e) { /* storage blocked โ€” the game just won't survive a reload */ } }, clear() { try { localStorage.removeItem(this.KEY); } catch (e) {} }, }; /* ---- Dice ---------------------------------------------------------------- */ function Dice({ face, spinning, clickable, onRoll }) { // Spring-settle when the tumble stops on a value (.landed in styles.css). const wasSpinning = useRef(false); const [landed, setLanded] = useState(false); useEffect(() => { const was = wasSpinning.current; wasSpinning.current = spinning; if (was && !spinning && face != null) { setLanded(true); const t = setTimeout(() => setLanded(false), 340); return () => clearTimeout(t); } }, [spinning, face]); // Cube rotation that brings each value to the front face. Faces are laid out // like a real die (opposite sides sum to 7): 1 front, 6 back, 3 right, // 4 left, 2 top, 5 bottom โ€” see the .df1โ€ฆ.df6 rules in styles.css. const ORIENT = { 1: '', 2: 'rotateX(-90deg)', 3: 'rotateY(-90deg)', 4: 'rotateY(90deg)', 5: 'rotateX(90deg)', 6: 'rotateY(180deg)', }; return ( ); } /* ---- A single token, absolutely positioned over board.svg ---------------- */ function Token({ color, tokenIndex, center, movable, stackIndex, stackSize, lifted, releasing, returning, onClick }) { const [row, col] = center; // grid-unit centers (0..15) // Fan out multiple tokens sharing one cell so they don't fully overlap. const spread = stackSize > 1 ? (stackIndex - (stackSize - 1) / 2) * 0.35 : 0; const style = { left: ((col + spread) / GRID) * 100 + '%', top: (row / GRID) * 100 + '%', '--fill-colour': HEX[color], // Movable tokens stack ABOVE everything else in the cell: an opponent // sharing the square (safe cell / fresh capture landing) must not cover // the token the player has to click. A token racing home after a capture // flies over resting tokens but under the (lifted) one that captured it. zIndex: lifted ? 40 : returning ? 35 : (movable ? 20 : 10) + stackIndex, }; return (
); } /* ---- Player pod: name, home progress, rank medal, and the dice when it's * this player's turn -------------------------------------------------------- */ const MEDALS = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰', '4th']; function PlayerPod({ player, isCurrent, rank, diceProps, youBadge }) { if (!player.active) return
; return (
= 0 ? ' finished' : '')} style={{ '--c': HEX[player.color] }}>
{player.name} {player.isAI && AI} {!player.isAI && youBadge && You} {rank >= 0 && {MEDALS[rank] || rank + 1 + 'th'}}
{player.tokens.map((p, i) => ( ))}
{isCurrent && diceProps ? : null}
); } /* ============================================================================ * Menu โ€” local setup (seat types + names) and online create/join * ========================================================================== */ const SEAT_CYCLE = { human: 'ai', ai: 'off', off: 'human' }; const SEAT_LABEL = { human: '๐Ÿง‘ Human', ai: '๐Ÿค– AI', off: 'โœ• Off' }; const PRESETS = [ { name: 'You vs 3 AI', seats: ['human', 'ai', 'ai', 'ai'] }, { name: '1 vs 1 AI', seats: ['human', 'off', 'ai', 'off'] }, { name: '2 Players', seats: ['human', 'off', 'human', 'off'] }, { name: '4 Players', seats: ['human', 'human', 'human', 'human'] }, ]; /* Full-screen achievements gallery for one profile (opened from the menu). */ function AchievementsOverlay({ profile, onClose }) { const lvl = levelOf(profile.xp); const [copied, setCopied] = useState(false); const shareMsg = "I'm Level " + lvl + ' with ' + profile.ach.length + '/' + ACHIEVEMENTS.length + ' achievements and ' + profile.xp + ' XP in Ludo on MCQ Mojo! ๐ŸŽฒ Can you beat me?'; const doShare = async () => { const how = await shareText('Ludo โ€” my achievements', shareMsg, SITE_GAME); if (how === 'copied') { setCopied(true); setTimeout(() => setCopied(false), 2000); } }; return (
e.stopPropagation()}>

{profile.name}

Level {lvl} ยท {profile.xp} XP ({xpForLevel(lvl + 1) - profile.xp} to next level)
{profile.games} games ยท {profile.wins} wins ยท {profile.captures} captures ยท {profile.tokensHome} tokens home

{ACHIEVEMENTS.map((a) => (
{a.icon} {a.title} {a.desc}
))}
๐Ÿ’ฌ WhatsApp Full page โ†—
); } /* Global leaderboard overlay โ€” top players by XP from the mcqmojo.com API. */ const LB_PERIODS = [['day', 'Today'], ['week', 'Week'], ['month', 'Month'], ['all', 'All time']]; function LeaderboardOverlay({ onClose }) { const [period, setPeriod] = useState('week'); const [rows, setRows] = useState(null); // null = loading const [error, setError] = useState(false); // Highlight rows that are "you": the online name plus the local profile name. const mine = new Set([ (localStorage.getItem('ludo-name') || '').trim().toLowerCase(), ((Profiles.last() || {}).name || '').trim().toLowerCase(), ]); useEffect(() => { let live = true; setRows(null); setError(false); Leaderboard.top(period) .then((d) => { if (live) setRows(d.rows || []); }) .catch(() => { if (live) setError(true); }); return () => { live = false; }; }, [period]); return (
e.stopPropagation()}>

๐Ÿ† Leaderboard

{LB_PERIODS.map(([id, label]) => ( ))}
{error ? (

Couldn't load the leaderboard โ€” it's available when playing at {' '}mcqmojo.com with an internet connection.

) : rows === null ? (

Loading

) : rows.length === 0 ? (

No games recorded yet โ€” finish a game to get on the board!

) : (
    {rows.map((r, i) => (
  1. {i < 3 ? MEDALS[i] : i + 1} {r.name} L{levelOf(r.xp)} ยท {r.wins}W {r.xp} XP
  2. ))}
)} Open the full leaderboard page โ†—
); } /* First-visit welcome: a friendly "here's what this is โ€” let's play" card. * Shown once per device (localStorage flag); invited friends skip it โ€” the * invite notice on the join form is their welcome. */ function WelcomeOverlay({ onClose }) { return (
e.stopPropagation()}>
๐ŸŽฒ

Welcome to Ludo!

Play the classic board game free โ€” no sign-up.

  • ๐Ÿค– Solo against a smart AI, or pass-and-play
  • ๐ŸŒ Private online rooms โ€” share a code with friends
  • ๐Ÿ† Earn XP, unlock achievements, climb the leaderboard
); } function Menu({ notice, inviteCode, onStartLocal, onHost, onJoin }) { // Arriving via an invite link (?room=CODE) lands straight on the join form. const [tab, setTab] = useState(inviteCode ? 'online' : 'local'); const [showAch, setShowAch] = useState(false); const [showLB, setShowLB] = useState(false); const [showWelcome, setShowWelcome] = useState(() => { try { return !inviteCode && !localStorage.getItem('ludo-welcomed'); } catch (e) { return false; } }); const closeWelcome = () => { setShowWelcome(false); try { localStorage.setItem('ludo-welcomed', '1'); } catch (e) {} }; const profile = Profiles.last(); const [types, setTypes] = useState(PRESETS[0].seats); const [names, setNames] = useState(['', '', '', '']); const [myName, setMyName] = useState(localStorage.getItem('ludo-name') || ''); const [roomSize, setRoomSize] = useState(4); // seats in a hosted room (incl. host) const [joinCode, setJoinCode] = useState(inviteCode || ''); const netOK = window.LudoNet && window.LudoNet.available(); const activeCount = types.filter((t) => t !== 'off').length; const cycle = (i) => setTypes(types.map((t, j) => (j === i ? SEAT_CYCLE[t] : t))); const setName = (i, v) => setNames(names.map((n, j) => (j === i ? v : n))); const startLocal = () => { const missing = types .map((t, i) => (t === 'human' && !names[i].trim() ? capitalize(E.COLORS[i]) : null)) .filter(Boolean); if (missing.length) { alert('Please enter a name for: ' + missing.join(', ')); return; } onStartLocal(types.map((t, i) => ({ type: t, name: names[i].trim() || undefined }))); }; // Blank names are not allowed online โ€” the name is what friends see. const requireName = () => { const n = myName.trim(); if (!n) { alert('Please enter your name first.'); return null; } localStorage.setItem('ludo-name', n); return n; }; // How many seats (host included) the room opens with. Rendered above every // "Create room" button; join flows never see it โ€” only the host decides. const sizePicker = (
Room size: {[2, 3, 4].map((n) => ( ))}
); return (
{['L', 'U', 'D', 'O'].map((ch, i) => ( {ch} ))}
{notice &&
{notice}
} {profile && (() => { const lvl = levelOf(profile.xp); const pct = Math.round(100 * (profile.xp - xpForLevel(lvl)) / (xpForLevel(lvl + 1) - xpForLevel(lvl))); return ( ); })()} {showAch && profile && setShowAch(false)} />} {showLB && setShowLB(false)} />} {showWelcome && }
{tab === 'local' ? (

Tap a color to cycle Human / AI / Off. Humans can enter a name.

{/* Ordered to mirror the board corners: red TL, green TR, blue BL, yellow BR. */} {['red', 'green', 'blue', 'yellow'].map((color) => { const i = E.COLORS.indexOf(color); return (
cycle(i)}>
{color} {SEAT_LABEL[types[i]]}
{types[i] === 'human' && ( e.stopPropagation()} onChange={(e) => setName(i, e.target.value)} /> )}
); })}
{PRESETS.map((p) => ( ))}
) : (

One friend creates a room and shares the code; the others join with it.

{!netOK &&
Online play needs an internet connection (PeerJS failed to load). Refresh and try again.
} {inviteCode && (
๐ŸŽ‰ You're invited to room {inviteCode} โ€” enter your name and tap Join.
)} setMyName(e.target.value)} /> {/* Invited via link: lead with Join. Otherwise: lead with Create. */} {inviteCode ? (
setJoinCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} />
or start your own room
{sizePicker}
) : (
{sizePicker}
or join a friend's room
setJoinCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} />
)}
)} {/* Stand-in for the site header/footer (this page is standalone โ€” no * base.html chrome), matching what every other mcqmojo game shows. */}
); } /* ============================================================================ * Online lobbies * ========================================================================== */ function Roster({ roster, myColor }) { return (
{roster.map((p) => (
{p.name} {p.host && Host} {p.color === myColor && You}
))}
); } /* ============================================================================ * Leave-game confirmation โ€” quitting mid-game is destructive (for the host it * ends the game for the whole room), so it always asks first. * ========================================================================== */ function ConfirmLeave({ mode, onStay, onLeave }) { const warning = mode === 'host' ? "You're the host โ€” leaving ends the game for everyone in the room." : mode === 'guest' ? 'The AI will take over your seat, and you cannot rejoin this game.' : 'The game is not finished โ€” all progress will be lost.'; return (

Leave the game?

{warning}

); } /* ============================================================================ * Win overlay with rankings and confetti * ========================================================================== */ function WinOverlay({ state, canRestart, onRestart, onMenu, xpReport }) { const [copied, setCopied] = useState(false); // Brag line: lead with the local player's own XP haul when a human profile // was recorded, else just the result. (xpReport[0] is the first human seat.) const rep = xpReport && xpReport[0]; const winnerName = nameOf(state, state.winner); const iWon = rep && winnerName.trim().toLowerCase() === rep.profile.name.trim().toLowerCase(); const shareMsg = (rep ? (iWon ? 'I just WON a Ludo game' : winnerName + ' beat me at Ludo') + ' and I earned +' + rep.gained + ' XP (Level ' + levelOf(rep.profile.xp) + ')' : winnerName + ' won our Ludo game') + ' on MCQ Mojo! ๐ŸŽฒ Play free:'; const doShare = async () => { const how = await shareText('Ludo', shareMsg, SITE_GAME); if (how === 'copied') { setCopied(true); setTimeout(() => setCopied(false), 2000); } }; const confetti = useRef(null); if (!confetti.current) { confetti.current = Array.from({ length: 70 }, (_, i) => ({ left: Math.random() * 100, delay: Math.random() * 2.4, dur: 2.6 + Math.random() * 2.4, color: HEX[E.COLORS[i % 4]], spin: Math.random() * 720 - 360, })); } return (
{confetti.current.map((c, i) => ( ))}
๐Ÿ†

{nameOf(state, state.winner)} wins!

    {state.ranking.map((color, i) => (
  1. {MEDALS[i] || i + 1 + 'th'} {nameOf(state, color)}
  2. ))}
{xpReport && xpReport.map((r, i) => (
{r.profile.name} {r.levelsUp && โฌ† Level {levelOf(r.profile.xp)}!} +{r.gained} XP
{r.unlocked.length > 0 && (
{r.unlocked.map((a) => ( {a.icon} {a.title} ))}
)}
))}
๐Ÿ’ฌ WhatsApp
{canRestart ? : Waiting for the host}
); } /* ============================================================================ * The game screen โ€” local, host or guest * ========================================================================== */ function Game({ seats, mode, myColor, roomCode, guests, netRef, handlersRef, onMenu, resume }) { // `resume` (local mode only) is a LocalSave payload from before a reload โ€” // restore that game instead of starting a fresh one. const [state, setState] = useState(() => (resume ? resume.state : E.createGame({ players: seats }))); const [anim, setAnim] = useState(null); // { color, t, center, release } while a token hops/pops out const [returnAnims, setReturnAnims] = useState(null); // [{ color, t, center }] captured tokens racing home const [rollFace, setRollFace] = useState(null); // face shown while the dice spins const [holdFace, setHoldFace] = useState(null); // settled face held so a no-move roll can be read const [muted, setMuted] = useState(Sound.muted); const [confirmLeave, setConfirmLeave] = useState(false); const busyRef = useRef(false); // true while any animation runs const stateRef = useRef(state); stateRef.current = state; // Bumped by resetFor(): animation timers started before a reset check this // and abandon their pending commit, so a stale game can't overwrite a new one. const epochRef = useRef(0); // Commit engine state SYNCHRONOUSLY to the ref before the (async) setState: // when onDone() drains the next queued action in the same task, it must // validate against the state we just committed, not the last rendered one โ€” // otherwise back-to-back actions (e.g. on a throttled tab) get dropped and // the board silently desyncs. const commit = (next) => { stateRef.current = next; setState(next); }; const seqRef = useRef(0); // action sequence number (online) const queueRef = useRef([]); // host: pending intents / guest: pending actions const guestColorsRef = useRef(new Map((guests || []).map((g) => [g.id, g.color]))); // Per-game tallies (by color) feeding XP/achievements; reset on every new game. // Restored along with the state so a reload doesn't shortchange XP/badges. const gameStatsRef = useRef((resume && resume.stats) || { captures: {}, suffered: {}, home: {} }); const recordedRef = useRef(false); // profiles written for this game const [xpReport, setXpReport] = useState(null); // per-human XP summary for the win overlay /* ---- Shared animate-then-commit executors (every mode uses these) ------ */ const executeRoll = (value, onDone) => { const s = stateRef.current; if (s.phase !== 'roll') { if (onDone) onDone(); return; } const epoch = epochRef.current; busyRef.current = true; Sound.dice(); // Decelerating tumble: faces flip quickly at first, then slower and slower // until the real value lands โ€” reads like a die losing momentum. let delay = 65; let elapsed = 0; const flip = () => { if (epochRef.current !== epoch) return; // game was reset mid-spin if (elapsed >= DICE_SPIN_MS) { const next = E.roll(s, value); setRollFace(null); if (next.phase === 'roll') { // The turn passed right on the roll (no legal moves, or three 6s // forfeited it) โ€” committing now would wipe the dice instantly. // Hold the rolled value so the player can read it first. setHoldFace(value); setTimeout(() => { if (epochRef.current !== epoch) return; setHoldFace(null); commit(next); busyRef.current = false; if (onDone) onDone(); }, MOVE_PAUSE_MS); return; } commit(next); busyRef.current = false; if (onDone) onDone(); return; } setRollFace(1 + Math.floor(Math.random() * 6)); elapsed += delay; delay = Math.min(delay * 1.22, 230); setTimeout(flip, delay); }; flip(); }; const executeMove = (t, onDone) => { const s = stateRef.current; if (s.phase !== 'move' || !s.legalMoves.includes(t)) { if (onDone) onDone(); return; } const epoch = epochRef.current; busyRef.current = true; const player = s.players[s.current]; const color = player.color; const from = player.tokens[t]; const next = E.move(s, t); // The centers the token passes through (a release is one springy glide // from the yard slot onto the start square โ€” see .releasing in styles.css). const centers = []; if (from === -1) { centers.push(E.tokenCenter(color, t, 0)); } else { for (let p = from + 1; p <= from + s.dice; p++) centers.push(E.tokenCenter(color, t, p)); } const finalize = () => { setAnim(null); setReturnAnims(null); commit(next); busyRef.current = false; const lm = next.lastMove; if (lm) { // Tally for XP/achievements. Every mode commits through here (guests // replay the host's moves), so the counts agree on all screens. const gs = gameStatsRef.current; if (lm.captured.length) { gs.captures[lm.color] = (gs.captures[lm.color] || 0) + lm.captured.length; lm.captured.forEach((c) => { gs.suffered[c.color] = (gs.suffered[c.color] || 0) + 1; }); } if (lm.to === E.HOME_POS) gs.home[lm.color] = (gs.home[lm.color] || 0) + 1; } if (lm && lm.to === E.HOME_POS) Sound.finish(); if (next.phase === 'gameover') Sound.win(); if (onDone) onDone(); }; // Captured tokens don't jump straight to their yard: each one races BACK // along the exact track it travelled โ€” cell by cell to its start square, // then into its yard slot โ€” while the capturing token holds the square. // Only after that does the state commit (guests replay the same path). const runCaptureReturns = () => { const caps = next.lastMove ? next.lastMove.captured : []; if (!caps.length) { finalize(); return; } Sound.capture(); const paths = caps.map((c) => { const fromPos = s.players.find((p) => p.color === c.color).tokens[c.token]; const cells = []; for (let p = fromPos - 1; p >= 0; p--) cells.push(E.tokenCenter(c.color, c.token, p)); cells.push(E.tokenCenter(c.color, c.token, -1)); // its own yard slot return { color: c.color, t: c.token, cells }; }); const maxLen = Math.max.apply(null, paths.map((p) => p.cells.length)); const frame = (i) => paths.map((p) => ({ color: p.color, t: p.t, center: p.cells[Math.min(i, p.cells.length - 1)], })); let step = 0; setReturnAnims(frame(0)); const rv = setInterval(() => { if (epochRef.current !== epoch) { clearInterval(rv); return; } // game was reset mid-return step++; if (step < maxLen) setReturnAnims(frame(step)); else { clearInterval(rv); finalize(); } }, RETURN_MS); }; if (from === -1) Sound.release(); else Sound.hop(); setAnim({ color, t, center: centers[0], release: from === -1 }); let i = 0; const iv = setInterval(() => { if (epochRef.current !== epoch) { clearInterval(iv); return; } // game was reset mid-hop i++; if (i < centers.length) { Sound.hop(); setAnim({ color, t, center: centers[i] }); } else { clearInterval(iv); runCaptureReturns(); } }, from === -1 ? RELEASE_MS : HOP_MS); }; /* ---- HOST: authoritative actions, broadcast then replay locally -------- */ const hostAction = (kind, token) => { const s = stateRef.current; if (kind === 'roll') { if (s.phase !== 'roll') return; const value = 1 + Math.floor(Math.random() * 6); seqRef.current++; netRef.current.broadcast({ t: 'action', seq: seqRef.current, kind: 'roll', value, snap: E.roll(s, value) }); executeRoll(value, drainHostQueue); } else { if (s.phase !== 'move' || !s.legalMoves.includes(token)) return; seqRef.current++; netRef.current.broadcast({ t: 'action', seq: seqRef.current, kind: 'move', token, snap: E.move(s, token) }); executeMove(token, drainHostQueue); } }; // Guest intents that arrive while an animation is running wait here. const handleIntent = (peerId, msg) => { const color = guestColorsRef.current.get(peerId); const s = stateRef.current; const cur = s.players[s.current]; if (!color || cur.color !== color || cur.isAI || s.phase === 'gameover') return; if (busyRef.current) { queueRef.current.push({ peerId, msg }); return; } hostAction(msg.kind, msg.token); }; const handleGuestLeft = (peerId) => { const color = guestColorsRef.current.get(peerId); guestColorsRef.current.delete(peerId); if (!color) return; if (busyRef.current) { queueRef.current.push({ leftColor: color }); return; } applySeatAI(color); }; const applySeatAI = (color) => { const patched = seatToAI(stateRef.current, color); seqRef.current++; netRef.current.broadcast({ t: 'seat-ai', seq: seqRef.current, color, snap: patched }); commit(patched); }; function drainHostQueue() { while (queueRef.current.length && !busyRef.current) { const item = queueRef.current.shift(); if (item.leftColor) applySeatAI(item.leftColor); else handleIntent(item.peerId, item.msg); } } /* ---- GUEST: replay actions from the host in order ----------------------- */ const pumpGuestQueue = () => { if (busyRef.current) return; const a = queueRef.current.shift(); if (!a) return; if (a.seq !== seqRef.current + 1) { // Missed something โ€” hard-resync from the snapshot, no animation. seqRef.current = a.seq; setAnim(null); commit(a.snap); setTimeout(pumpGuestQueue, 0); return; } seqRef.current = a.seq; if (a.t === 'seat-ai') { commit(a.snap); setTimeout(pumpGuestQueue, 0); } else if (a.kind === 'roll') executeRoll(a.value, pumpGuestQueue); else executeMove(a.token, pumpGuestQueue); }; const resetFor = (newState) => { epochRef.current++; // abandon any in-flight animation timers busyRef.current = false; queueRef.current = []; seqRef.current = 0; gameStatsRef.current = { captures: {}, suffered: {}, home: {} }; setXpReport(null); setAnim(null); setReturnAnims(null); setRollFace(null); setHoldFace(null); commit(newState); }; /* ---- Bind the network handlers for this screen (rebound every render so * they always close over the latest props/state) -------------------------- */ useEffect(() => { if (mode === 'local') return; if (mode === 'host') { handlersRef.current = { onMessage: (id, msg) => { if (!msg) return; if (msg.t === 'intent') handleIntent(id, msg); // Someone used the room code after the game started: tell them, // then drop them (delay lets the message flush first). else if (msg.t === 'hello') { netRef.current.send(id, { t: 'busy' }); setTimeout(() => { if (netRef.current) netRef.current.kick(id); }, 400); } }, onLeave: handleGuestLeft, onReady: () => {}, onError: () => {}, }; } else { handlersRef.current = { onMessage: (msg) => { if (!msg) return; if (msg.t === 'action' || msg.t === 'seat-ai') { queueRef.current.push(msg); pumpGuestQueue(); } // Prefer the host's seat list: it reflects seats that turned AI // after a disconnect, which our original `seats` prop predates. else if (msg.t === 'restart') resetFor(E.createGame({ players: msg.seats || seats })); }, onClose: () => onMenu('The host left โ€” game over.'), onOpen: () => {}, onError: () => {}, }; } }); /* ---- User input (what a click means depends on the mode) ---------------- */ const userRoll = () => { if (busyRef.current) return; if (mode === 'guest') { netRef.current.send({ t: 'intent', kind: 'roll' }); return; } if (mode === 'host') { hostAction('roll'); return; } executeRoll(1 + Math.floor(Math.random() * 6)); }; const userMove = (t) => { if (busyRef.current) return; if (mode === 'guest') { netRef.current.send({ t: 'intent', kind: 'move', token: t }); return; } if (mode === 'host') { hostAction('move', t); return; } executeMove(t); }; const restart = () => { // Rebuild seats from the CURRENT state, not the original `seats` prop: a // guest who disconnected mid-game was converted to AI, and reviving them // as a "human" would leave the new game waiting forever on a dead seat. const seatsNow = stateRef.current.players.map((p) => ({ type: p.active ? (p.isAI ? 'ai' : 'human') : 'off', name: p.isAI ? undefined : p.name, })); if (mode === 'host') netRef.current.broadcast({ t: 'restart', seats: seatsNow }); resetFor(E.createGame({ players: seatsNow })); }; // Leaving mid-game (Menu button) is confirmed first; once the game is over // there is nothing left to lose, so it goes straight to the menu. const requestMenu = () => { if (state.phase === 'gameover') onMenu(); else setConfirmLeave(true); }; // ONLINE: a refresh / tab close mid-game abandons it just as hard as the // Menu button (your seat turns AI; a host ends the room) โ€” let the browser // ask first. Local games skip the prompt: they autosave and a reload resumes. useEffect(() => { if (mode === 'local') return; const warn = (e) => { if (stateRef.current.phase === 'gameover') return; e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', warn); return () => window.removeEventListener('beforeunload', warn); }, [mode]); // LOCAL: autosave after every committed action so a reload lands back in // this game (App restores it on load). Cleared when the game ends; leaving // via the Menu button clears it too (see ConfirmLeave below). useEffect(() => { if (mode !== 'local') return; if (state.phase === 'gameover') LocalSave.clear(); else LocalSave.write(state, gameStatsRef.current); }, [state, mode]); /* ---- Record the finished game into local profiles. Each screen records * only the seats its person controls: all human seats in local play, just * your own online (a seat that turned AI mid-game is nobody's). ----------- */ useEffect(() => { if (state.phase !== 'gameover') { recordedRef.current = false; return; } if (recordedRef.current) return; recordedRef.current = true; const gs = gameStatsRef.current; const reports = state.players .filter((p) => p.active && !p.isAI && (mode === 'local' || p.color === myColor)) .map((p) => { const rank = state.ranking.indexOf(p.color); const g = { won: state.winner === p.color, rank: rank < 0 ? 3 : rank, captures: gs.captures[p.color] || 0, suffered: gs.suffered[p.color] || 0, home: gs.home[p.color] || 0, }; const rep = Profiles.record(p.name, g); // Global leaderboard: one row per human seat per finished game. Online, // each screen records only its own seat, so no double submission. Leaderboard.submit({ name: p.name, xp: rep.gained, won: g.won, rank: g.rank, captures: g.captures, home: g.home, }); return rep; }); if (reports.length) setXpReport(reports); }, [state.phase]); /* ---- Drive AI turns and single-move auto-play (never on guests: the host * decides for everyone there) --------------------------------------------- */ useEffect(() => { if (mode === 'guest' || state.phase === 'gameover') return; const p = state.players[state.current]; const act = (kind, token) => (mode === 'host' ? hostAction(kind, token) : kind === 'roll' ? executeRoll(1 + Math.floor(Math.random() * 6)) : executeMove(token)); let id; if (p.isAI) { // Think before ROLLING (turn hand-off needs to be readable), but move // quickly once the dice has settled โ€” the value was already on screen. id = setTimeout(() => { const s = stateRef.current; if (busyRef.current || !s.players[s.current].isAI) return; if (s.phase === 'roll') act('roll'); else if (s.phase === 'move') { const t = E.chooseAIMove(s); if (t != null) act('move', t); } }, state.phase === 'roll' ? AI_THINK_MS : MOVE_PAUSE_MS); } else if (state.phase === 'move' && state.legalMoves.length === 1) { id = setTimeout(() => { const s = stateRef.current; if (!busyRef.current && s.phase === 'move' && s.legalMoves.length === 1) act('move', s.legalMoves[0]); }, MOVE_PAUSE_MS); } return () => clearTimeout(id); }, [state, mode]); /* ---- Render -------------------------------------------------------------- */ const current = state.players[state.current]; // Which seats does the person AT THIS SCREEN control? const iControlCurrent = !current.isAI && state.phase !== 'gameover' && (mode === 'local' || current.color === myColor); const movableSet = new Set( iControlCurrent && state.phase === 'move' && !busyRef.current ? state.legalMoves : [] ); // Group tokens by rendered cell so stacks fan out instead of fully overlapping. const cellStacks = {}; state.players.forEach((p) => p.tokens.forEach((pos, t) => { if (!p.active) return; const [r, c] = E.tokenCenter(p.color, t, pos); const key = r + ',' + c; (cellStacks[key] = cellStacks[key] || []).push({ color: p.color, t }); })); const diceProps = { face: rollFace != null ? rollFace : holdFace != null ? holdFace : state.dice, spinning: rollFace != null, clickable: iControlCurrent && state.phase === 'roll' && !busyRef.current && holdFace == null, onRoll: userRoll, }; const humanCount = state.players.filter((p) => p.active && !p.isAI).length; const byColor = {}; state.players.forEach((p) => { byColor[p.color] = p; }); const rankOf = (color) => state.ranking.indexOf(color); const pod = (color) => ( ); return (
{roomCode && {roomCode}}
{state.message}
{pod('red')}{pod('green')}
Ludo board {state.players.map((p) => p.active && p.tokens.map((pos, t) => { const isAnim = anim && anim.color === p.color && anim.t === t; const ret = returnAnims && returnAnims.find((a) => a.color === p.color && a.t === t); const center = ret ? ret.center : isAnim ? anim.center : E.tokenCenter(p.color, t, pos); const [r, c] = E.tokenCenter(p.color, t, pos); const stack = cellStacks[r + ',' + c]; const stackIndex = Math.max(0, stack.findIndex((x) => x.color === p.color && x.t === t)); const solo = isAnim || ret; // animating tokens leave their stack return ( userMove(t)} /> ); }))}
{pod('blue')}{pod('yellow')}
{confirmLeave && state.phase !== 'gameover' && ( setConfirmLeave(false)} onLeave={() => { if (mode === 'local') LocalSave.clear(); onMenu(); }} /> )} {state.phase === 'gameover' && ( onMenu()} /> )}
); } /* ============================================================================ * Root โ€” screen switching + owns the network connection * ========================================================================== */ // Room code from an invite link (?room=CODE), sanitized. Read once at load. const INVITE_CODE = (() => { try { const c = new URLSearchParams(window.location.search).get('room'); const clean = c ? c.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 5) : ''; return clean.length >= 4 ? clean : null; } catch (e) { return null; } })(); function App() { // A saved local game (reload / accidental close) resumes immediately โ€” // unless the user arrived via an invite link, which is a clear intent to // join a friend's room instead. The save stays put either way; it is only // cleared when that game finishes or the player leaves it via Menu. const [screen, setScreen] = useState(() => { if (!INVITE_CODE) { const save = LocalSave.load(); if (save) return { name: 'local-game', resume: save, key: 'resume' }; } return { name: 'menu', notice: null }; }); const netRef = useRef(null); const roomCodeRef = useRef(null); // Screens rebind these; net.js reads them lazily via this stable proxy. const handlersRef = useRef(null); const proxyRef = useRef(null); if (!proxyRef.current) { const call = (fn) => (...args) => { const h = handlersRef.current; if (h && typeof h[fn] === 'function') h[fn](...args); }; proxyRef.current = { onReady: call('onReady'), onMessage: call('onMessage'), onLeave: call('onLeave'), onOpen: call('onOpen'), onClose: call('onClose'), onError: call('onError'), }; } const closeNet = () => { if (netRef.current) { netRef.current.close(); netRef.current = null; } handlersRef.current = null; }; const toMenu = (notice) => { closeNet(); setScreen({ name: 'menu', notice: notice || null }); }; // Menu/lobby screens get a floating theme switch; in-game it sits in the topbar. const themed = (view) => {view}; switch (screen.name) { case 'local-game': return ; case 'host-lobby': return themed( setScreen({ name: 'online-game', mode: 'host', seats, guests, myColor: 'red', roomCode: roomCodeRef.current, key: Date.now(), })} onCode={(code) => { roomCodeRef.current = code; }} onExit={() => toMenu()} />); case 'guest-lobby': return themed( setScreen({ name: 'online-game', mode: 'guest', seats, myColor, roomCode: screen.code, key: Date.now(), })} onExit={(notice) => toMenu(notice)} />); case 'online-game': return ; default: return themed( setScreen({ name: 'local-game', seats, key: Date.now() })} onHost={(myName, size) => setScreen({ name: 'host-lobby', myName, size })} onJoin={(code, myName) => setScreen({ name: 'guest-lobby', code, myName })} />); } } /* The lobbies own creating the connection (they know host vs guest) but must * route events through the App's stable proxy so the Game screen can take the * handlers over without reconnecting. */ function HostLobbyBridge({ myName, roomSize, netRef, proxy, handlersRef, onStart, onCode, onExit }) { const [code, setCode] = useState(null); const [guests, setGuests] = useState([]); // The room opens with (roomSize - 1) guest seats; the rest start closed. // 'ai' = open (a friend can take it; a bot fills it if still empty at start), // 'off' = closed (no friend can take it, dropped at start). const [extras, setExtras] = useState(() => { const ex = {}; GUEST_COLORS.forEach((c, i) => { ex[c] = i < (roomSize || 4) - 1 ? 'ai' : 'off'; }); return ex; }); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const guestsRef = useRef(guests); guestsRef.current = guests; // Invite link: opening it lands friends on the join form with the code filled. const inviteLink = () => window.location.origin + window.location.pathname + '?room=' + code; const shareLink = async () => { const url = inviteLink(); // Phones: native share sheet (WhatsApp etc.). Desktop: copy to clipboard โ€” // desktop Edge/Chrome also expose navigator.share, but the OS share dialog // is the wrong experience there, so gate on an actual mobile device. const isMobile = (navigator.userAgentData && navigator.userAgentData.mobile) || /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent); if (navigator.share && isMobile) { try { await navigator.share({ title: 'Ludo', text: 'Join my Ludo room ' + code, url }); return; } catch (e) { if (e && e.name === 'AbortError') return; /* else fall through to copy */ } } try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(url); } else { const ta = document.createElement('textarea'); ta.value = url; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (e) { alert('Copy this link and send it to your friends:\n' + url); } }; // `ex` rides along so guests render the same open-seat plan as the host. const sendLobby = (list, ex) => { const r = [{ color: 'red', name: myName, host: true }] .concat(list.map((g) => ({ color: g.color, name: g.name }))); list.forEach((g) => netRef.current.send(g.id, { t: 'lobby', roster: r, yourColor: g.color, extras: ex })); }; useEffect(() => { netRef.current = window.LudoNet.createHost(proxy); return undefined; // App root closes the connection }, []); // Live handlers for the lobby phase. useEffect(() => { handlersRef.current = { onReady: (c) => { setCode(c); onCode(c); }, onError: (err) => setError('Could not open a room (' + ((err && err.type) || 'network') + '). Check your connection.'), onMessage: (id, msg) => { if (!msg || msg.t !== 'hello') return; const list = guestsRef.current; if (list.some((g) => g.id === id)) return; const used = list.map((g) => g.color); // Friends only take OPEN seats โ€” a 2-player room really holds 2 people. const color = GUEST_COLORS.find((c) => !used.includes(c) && extras[c] !== 'off'); if (!color) { netRef.current.send(id, { t: 'full' }); // Let the message flush before dropping the connection, or the guest // only sees "host closed the room" instead of the real reason. setTimeout(() => { if (netRef.current) netRef.current.kick(id); }, 400); return; } const name = String(msg.name || 'Player').trim().slice(0, 14) || 'Player'; const next = list.concat([{ id, name, color }]); setGuests(next); sendLobby(next, extras); }, onLeave: (id) => { const next = guestsRef.current.filter((g) => g.id !== id); setGuests(next); sendLobby(next, extras); }, }; }); const start = () => { const byColor = {}; guests.forEach((g) => { byColor[g.color] = g; }); const seats = E.COLORS.map((color) => { if (color === 'red') return { type: 'human', name: myName }; if (byColor[color]) return { type: 'human', name: byColor[color].name }; return { type: extras[color], name: undefined }; }); netRef.current.broadcast({ t: 'start', seats }); onStart(seats, guests); }; const toggleSeat = (color) => { const ex = { ...extras, [color]: extras[color] === 'ai' ? 'off' : 'ai' }; setExtras(ex); sendLobby(guestsRef.current, ex); }; const roster = [{ color: 'red', name: myName, host: true }] .concat(guests.map((g) => ({ color: g.color, name: g.name }))); const openColors = GUEST_COLORS.filter((c) => !guests.some((g) => g.color === c)); const botCount = openColors.filter((c) => extras[c] === 'ai').length; const humanCount = 1 + guests.length; const activeSeats = humanCount + botCount; const startLabel = activeSeats < 2 ? 'Need at least 2 players' : 'Start game โ€” ' + humanCount + (humanCount > 1 ? ' players' : ' player') + (botCount ? ' + ' + botCount + (botCount > 1 ? ' bots' : ' bot') : ''); return (

Room lobby

{error ? (
{error}
) : (
Room code {code || 'ยทยทยทยทยท'} Friends can type this code โ€” or just send them the link:
)} {openColors.length > 0 && (

Empty seats โ€” friends can grab any open one:

{openColors.map((color) => { const open = extras[color] !== 'off'; return (
{open ? Waiting for a friend : Seat closed}
); })}
)}
); } function GuestLobbyBridge({ code, myName, netRef, proxy, handlersRef, onStart, onExit }) { const [status, setStatus] = useState('connecting'); const [error, setError] = useState(null); const [roster, setRoster] = useState([]); const [extras, setExtras] = useState(null); // host's open-seat plan (display only) const [myColor, setMyColor] = useState(null); const myColorRef = useRef(null); useEffect(() => { netRef.current = window.LudoNet.joinRoom(code, proxy); return undefined; // App root closes the connection }, []); useEffect(() => { handlersRef.current = { onOpen: () => { setStatus('waiting'); netRef.current.send({ t: 'hello', name: myName }); }, // A kick after 'full'/'busy' also closes the connection โ€” keep the first, // more specific error instead of overwriting it with this generic one. onClose: () => { setError((e) => e || 'The host closed the room.'); setStatus('error'); }, onError: (err) => { setError(err && err.type === 'peer-unavailable' ? 'No room found with code ' + code + '. Check the code and try again.' : 'Could not connect (' + ((err && err.type) || 'network') + ').'); setStatus('error'); }, onMessage: (msg) => { if (!msg) return; if (msg.t === 'lobby') { setRoster(msg.roster); setExtras(msg.extras || null); setMyColor(msg.yourColor); myColorRef.current = msg.yourColor; } else if (msg.t === 'full') { setError('That room is full โ€” all open seats are taken.'); setStatus('error'); } else if (msg.t === 'busy') { setError('That game has already started โ€” ask the host for a new room.'); setStatus('error'); } else if (msg.t === 'start') onStart(msg.seats, myColorRef.current); }, }; }); const openColors = GUEST_COLORS.filter((c) => !roster.some((p) => p.color === c)); return (

Room {code}

{status === 'error' ? (
{error}
) : status === 'connecting' ? (

Connecting

) : (
{openColors.length > 0 && (
{openColors.map((color) => { const open = !extras || extras[color] !== 'off'; return (
{open ? Waiting for a friend : Seat closed} {open ? 'if empty: ๐Ÿค– AI' : 'โœ• closed'}
); })}
)}

Waiting for the host to start

)}
); } ReactDOM.createRoot(document.getElementById('root')).render();