/* =============================================================================
* 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 (
setTheme(Theme.toggle())}>
{theme === 'dark' ? '๐' : 'โ๏ธ'}
);
}
/* ---- 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 (
{face || spinning ? (
/* While .rolling, the CSS tumble animation overrides this transform;
* when it's removed the cube transitions into the settled pose. The
* resting tilt keeps two extra faces peeking out, so it reads as 3D. */
{/* .front keeps the rolled value at full brightness while the other
* faces dim โ a fake light that makes the value pop at 40px. */}
{[1, 2, 3, 4, 5, 6].map((f) => (
))}
) : ๐ฒ }
);
}
/* ---- 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}
))}
Close
);
}
/* 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]) => (
setPeriod(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) => (
{i < 3 ? MEDALS[i] : i + 1}
{r.name}
L{levelOf(r.xp)} ยท {r.wins}W
{r.xp} XP
))}
)}
Open the full leaderboard page โ
Close
);
}
/* 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
Let's play ๐ฎ
);
}
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) => (
setRoomSize(n)}>
{n} players
))}
);
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 (
setShowAch(true)} title="View achievements">
{lvl}
{profile.name}
{profile.games} games ยท {profile.wins} wins ยท {profile.ach.length}/{ACHIEVEMENTS.length} ๐
{profile.xp} XP
);
})()}
{showAch && profile &&
setShowAch(false)} />}
setShowLB(true)}>
๐ Global leaderboard
{showLB && setShowLB(false)} />}
{showWelcome && }
setTab('local')}>
Local game
setTab('online')}>
Online with friends
{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) => (
setTypes(p.seats)}>{p.name}
))}
{activeCount < 2 ? 'Need at least 2 players' : 'Play'}
) : (
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.
)}
Your name
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, ''))} />
{ const n = requireName(); if (n) onJoin(joinCode, n); }}>
Join
or start your own room
{sizePicker}
{ const n = requireName(); if (n) onHost(n, roomSize); }}>
Create room
) : (
{sizePicker}
{ const n = requireName(); if (n) onHost(n, roomSize); }}>
Create room
or join a friend's room
setJoinCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} />
{ const n = requireName(); if (n) onJoin(joinCode, n); }}>
Join
)}
)}
{/* 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}
Keep playing
Leave game
);
}
/* ============================================================================
* 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) => (
{MEDALS[i] || i + 1 + 'th'}
{nameOf(state, color)}
))}
{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}
))}
)}
))}
{canRestart
? Play again
: Waiting for the host }
Main menu
);
}
/* ============================================================================
* 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 (
โน Menu
{roomCode && {roomCode} }
{state.message}
setMuted(Sound.toggle())}>
{muted ? '๐' : '๐'}
{pod('red')}{pod('green')}
{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:
{copied ? 'โ Link copied!' : '๐ Share invite 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 }
toggleSeat(color)}
title={open
? 'This seat becomes a bot if no friend has taken it when you start. Tap to close it instead.'
: 'Closed: no friend or bot can take this seat. Tap to reopen it.'}>
{open ? 'if empty: ๐ค AI' : 'tap to open'}
);
})}
)}
{startLabel}
Cancel
);
}
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
)}
onExit()}>Leave
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );